
Vibe Usage
- 46 installs
- 95 repo stars
- Updated August 2, 2026
- vibe-cafe/vibe-usage
Helps with ai & agent building tasks.
About
vibe-usage is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- vibe-usage
- AI & Agent Building
- AI-coding skill
Vibe Usage by the numbers
- 46 all-time installs (skills.sh)
- +1 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #7,568 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/vibe-cafe/vibe-usage --skill vibe-usageAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 46 |
|---|---|
| repo stars | ★ 95 |
| Last updated | August 2, 2026 |
| Repository | vibe-cafe/vibe-usage ↗ |
What it does
Helps with ai & agent building tasks.
Files
Vibe Usage
Track your AI coding tool token usage and sync to vibecafe.ai.
Setup
First-time setup (interactive — asks for API key):
npx @vibe-cafe/vibe-usageGet your API key at https://vibecafe.ai/usage/setup
Commands
When the user asks to sync usage, check costs, or track tokens, run:
npx @vibe-cafe/vibe-usage syncOther available commands:
| Command | Description |
|---|---|
npx @vibe-cafe/vibe-usage sync | Sync latest usage data |
npx @vibe-cafe/vibe-usage status | Show config and detected tools |
npx @vibe-cafe/vibe-usage daemon | Continuous sync every 30 minutes |
npx @vibe-cafe/vibe-usage reset | Delete all data and re-upload |
npx @vibe-cafe/vibe-usage reset --local | Delete this host's data and re-upload |
When to Use
- User says "sync my usage", "upload usage", "track tokens"
- User asks "how much have I spent?", "what's my cost?"
- User wants to check if sync is working: run
status - User wants continuous background sync: run
daemon
Notes
- Requires initial setup with an API key (run
npx @vibe-cafe/vibe-usagefirst) - Config is stored at
~/.vibe-usage/config.json - Supports: Claude Code, Codex CLI, Copilot CLI, Gemini CLI, OpenCode, OpenClaw, Qwen Code, Kimi Code, Amp, Droid, ZCode
node_modules/
.env
.env.local
*.tgz
AGENTS.md
AI agent guidance for the vibe-usage CLI. See README.md for user-facing docs.
Repository Structure
vibe-usage/
├── bin/vibe-usage.js # CLI entry point → src/index.js
├── src/
│ ├── index.js # Command router (init, sync, daemon, reset, skill, status, config)
│ ├── parsers/ # One parser per tool, all export async parse() → { buckets, sessions }
│ │ ├── index.js # Parser registry, aggregateToBuckets(), extractSessions()
│ │ ├── claude-code.js
│ │ ├── codex.js
│ │ ├── copilot-cli.js
│ │ ├── sqlite.js # queryDbJson() — node:sqlite (Node ≥22.5), falls back to sqlite3 CLI
│ │ ├── cursor.js # SQLite (read auth token) + cursor.com CSV export
│ │ ├── gemini-cli.js
│ │ ├── opencode.js # SQLite (via sqlite.js), legacy JSON fallback
│ │ ├── openclaw.js
│ │ ├── qwen-code.js
│ │ ├── kimi-code.js
│ │ ├── amp.js
│ │ ├── droid.js
│ │ ├── kiro.js # SQLite (via sqlite.js), JSONL fallback
│ │ ├── hermes.js # SQLite (via sqlite.js), multi-profile
│ │ └── zcode.js # SQLite (via sqlite.js), reads message table
│ ├── tools.js # TOOLS[] registry + detectInstalledTools()
│ ├── sync.js # Orchestrator: parse all → diff vs state → batch upload only new/changed
│ ├── state.js # ~/.vibe-usage/state.json: key→hash of uploaded items (incremental sync)
│ ├── api.js # HTTP client: ingest() (always gzip), requestDeviceCode()/pollDeviceCode() (device flow), deleteAllData(), fetchSettings()
│ ├── summary.js # `summary --days N`: GET /api/usage with the saved vbu_ key, render markdown (cost / tokens / by-model / by-project). Powers the SKILL.md "查询用量" entries.
│ ├── config.js # ~/.vibe-usage/config.json (dev: config.dev.json)
│ ├── init.js # Setup flow (device-flow browser login by default; --manual-key for CI/headless, verify, initial sync, daemon install prompt)
│ ├── daemon.js # 30-minute sync loop (foreground)
│ ├── daemon-service.js # Background service management (systemd/launchd install/uninstall/status)
│ ├── reset.js # Delete remote data + re-sync
│ ├── skill.js # Install/remove SKILL.md for AI coding tools
│ └── output.js # Terminal output helpers: colors, OSC 8 links, big/small headers
├── SKILL.md # Skill definition (also used by `npx skills add`)
└── package.json # @vibe-cafe/vibe-usage, ESM, Node >=20 (≥22.5 enables built-in node:sqlite), zero dependenciesKey Conventions
- Pure ESM (
"type": "module") — no CommonJS, no build step - Zero dependencies — only Node built-ins (fs, path, os, crypto, https, readline, child_process, zlib,
node:sqlite) - Incremental sync — parsers stay stateless (compute full totals from raw logs each run, server upserts idempotently), but
sync.jsdiffs each item's content-hash against~/.vibe-usage/state.jsonand uploads only new/changed buckets/sessions — a quiet machine sends zero bytes. State is committed per-batch only after that batch's upload succeeds (failed batch re-sends next run); prune of dead keys (logs the parsers no longer emit) persists unconditionally and is bounded by liveness, never by age. Deletingstate.jsontriggers a one-time full re-upload. - Stable hostname — hostname is persisted in config at init;
sync.jsnever re-readsos.hostname()after first capture. This prevents macOS mDNS hostname drift (e.g.,-2,-3suffixes) from creating duplicate device entries in the DB. - No TypeScript — plain JavaScript throughout
- Output style — user-facing text is Chinese (colored via
output.jshelpers:success/failure/warn/arrow/link). Dashboard URLs use OSC 8 hyperlinks so terminals that support it (iTerm2, Warp, VSCode, Kitty, Terminal.app 14+) render them as clickable. Raw pass-through from external tools (parser errors,systemctl/launchctloutput, daemon loop timestamps) is kept in English and dimmed so it's visually de-emphasized.initprints a big ASCII logo; other commands print a compact one-line header (bigHeader()/smallHeader()fromoutput.js).
Architecture: Two-Track Data Model
Every parser produces two parallel data streams:
Track 1: Token Buckets
Per-message token usage aggregated into 30-minute windows via aggregateToBuckets().
{ source, model, project, bucketStart, inputTokens, outputTokens, cachedInputTokens, reasoningOutputTokens, totalTokens }Track 2: Sessions
Timing events fed to extractSessions() for interaction metadata.
// Input event shape:
{ sessionId, source, project, timestamp: Date, role: 'user' | 'assistant' }
// Output session shape:
{ source, project, sessionHash, firstMessageAt, lastMessageAt, durationSeconds, activeSeconds, messageCount, userMessageCount, userPromptHours }activeSeconds = sum of turn durations (user prompt to last assistant message before next user prompt).
Adding a New Parser
1. Create src/parsers/<tool-id>.js exporting async function parse() returning { buckets: [], sessions: [] } 2. Register in src/parsers/index.js — import + add to parsers object 3. Add tool entry in src/tools.js — { name, id, dataDir } (alphabetical by id) 4. Update README.md supported tools table 5. Backend: append the source to USAGE_SOURCES in vibe-cafe/apps/web/src/lib/usage-sources.ts (ingest filter and /usage chip list both derive from it). Release ordering between vibe-usage publish and vibe-cafe deploy is no longer load-bearing — the ingest endpoint soft-drops unknown sources (returns them in dropped: { buckets, unknownSources } instead of 400ing the batch) so other parsers' data still lands. Until the source is registered server-side, sync.js prints a dim "X buckets dropped (服务端未收录的 source: …)" line.
Parser pattern:
- Read local log files from the tool's data directory
- Extract per-message token entries →
aggregateToBuckets(entries) - Extract user/assistant timing events →
extractSessions(events) - Handle missing/corrupt files gracefully (try/catch, skip bad lines)
SQLite-backed parsers (cursor, opencode, kiro, hermes):
- Use
queryDbJson(dbPath, sql)fromsrc/parsers/sqlite.js— never shell out tosqlite3directly. It prefers Node's built-innode:sqlite(DatabaseSync, opened read-only; Node ≥ 22.5, works on Windows with no extra binary) and falls back to thesqlite3CLI on older Node. - Rows come back as plain objects (
{ column: value }), same shape assqlite3 -json— INTEGER → number, TEXT → string, JSON viajson_extract→ string. - If neither
node:sqlitenor the CLI is available the helper throws anENOENT-flavored error; catch it and rethrow'sqlite3 CLI not found. Install sqlite3 (or use Node >= 22.5) to sync X data.'so the user gets a hint. - For DBs the source app holds a write lock on (Cursor, Kiro): catch
/database is locked/i, copy the DB (+-wal/-shm) to a temp dir, and re-query the snapshot.
Network-fetch parsers (the Cursor exception):
- Cursor stores no usage locally — only an auth token in
state.vscdb. The parser reads the token viaqueryDbJson(), then GETs a CSV fromcursor.com. - Always wrap network calls with
AbortSignal.timeout(...)so a single hung host can't stall the whole sync (sync.js catches throws per-parser but cannot interrupt a hanging await). - Mark transient/network errors with
err.skip = trueso the parser silently returns empty (avoids noisy daemon logs every 5 min). Only auth/permanent errors should bubble up.
Codex forked sessions (codex.js):
- Forking a Codex conversation writes a new rollout file that replays the entire source conversation at the top — every
event_msg/token_countincluded, all timestamped in a 1–3s burst at the fork instant. Those tokens are already counted from the source session's own file, so naively parsing the fork double-counts and spikes token/cost at the fork timestamp. - Timestamps cannot discriminate the replay (Codex stamps it at/after the fork's
session_metatime, not before). The parser instead does two passes: pass 1 indexes every file bysession_meta.payload.idand counts itstoken_countrecords; pass 2 skips exactly that many leadingtoken_counts in any file whosesession_meta.payload.forked_from_idpoints at it. A fork copies the source file verbatim, so the skip count == source's total count — this is also correct for chained forks (fork-of-a-fork replays the parent's whole file). If the source file is missing, skip nothing (over-count on incomplete data beats silently dropping real usage). - Both passes stream each rollout file line-by-line (
node:readlineover acreateReadStream), neverreadFileSyncinto memory. Large~/.codex/sessionshistories (hundreds of files, some >100 MB) otherwise OOM the V8 heap duringJSON.parse. The trade-off is reading each file twice (pass 1 indexes id/fork/project/token_count count viaindexSessionFile; pass 2 re-streams for usage extraction) — bounded memory beats a single-pass read that retains gigabytes of transcript text.
Codex archived sessions (codex.js, tools.js):
- Codex moves a "completed" session's rollout file from
~/.codex/sessions/to~/.codex/archived_sessions/. The parser scans both dirs in one pass (SESSIONS_DIRS); scanning only the live dir permanently lost any session archived between two syncs. Re-reading an already-synced archived file is idempotent (stateless parser, server dedups), and indexing both dirs together keeps fork replay-skip correct when a fork and its parent are split across them. - Session timing events are grouped by the real
session_meta.payload.id, not the file path — the same session can momentarily exist in both dirs, and path-keying would emit twosessionHashes and double-count its stats.findCodexDataDirsintools.jslikewise treats either dir as "Codex installed".
Development & Testing
# Dev mode (separate config, custom API URL)
VIBE_USAGE_DEV=1 VIBE_USAGE_API_URL=http://localhost:3000 node ./bin/vibe-usage.js init
VIBE_USAGE_DEV=1 node ./bin/vibe-usage.js sync
# Quick parser test
node -e "import('./src/parsers/<tool-id>.js').then(m => m.parse()).then(r => console.log(JSON.stringify(r, null, 2)))"Versioning
- Bump
versioninpackage.jsonbefore publishing - Published as
@vibe-cafe/vibe-usageon npm - Users run via
npx @vibe-cafe/vibe-usage
#!/usr/bin/env node
/**
* vibe-usage CLI entry point.
* Routes to the appropriate command handler.
*/
import { run } from '../src/index.js';
run(process.argv.slice(2));
{
"name": "@vibe-cafe/vibe-usage",
"version": "0.9.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@vibe-cafe/vibe-usage",
"version": "0.9.2",
"license": "MIT",
"bin": {
"vibe-usage": "bin/vibe-usage.js"
},
"engines": {
"node": ">=20"
}
}
}
}
{
"name": "@vibe-cafe/vibe-usage",
"version": "0.9.4",
"description": "Track your AI coding tool token usage and sync to vibecafe.ai",
"type": "module",
"bin": {
"vibe-usage": "bin/vibe-usage.js"
},
"files": [
"bin/",
"src/"
],
"engines": {
"node": ">=20"
},
"keywords": [
"ai",
"coding",
"usage",
"tokens",
"claude",
"codex",
"gemini"
],
"dependencies": {},
"repository": {
"type": "git",
"url": "git+https://github.com/vibe-cafe/vibe-usage.git"
},
"homepage": "https://github.com/vibe-cafe/vibe-usage#readme",
"bugs": {
"url": "https://github.com/vibe-cafe/vibe-usage/issues"
},
"license": "MIT"
}
vibe-usage
Track your AI coding tool token usage and sync to vibecafe.ai.
Quick Start
npx @vibe-cafe/vibe-usageThat's it. The CLI opens vibecafe.ai/usage/device in your browser; sign in, confirm the verification code shown in your terminal, click 「确认链接」, and the CLI receives an API key automatically.
After approval, it will: 1. Save your API key to ~/.vibe-usage/config.json 2. Detect installed AI coding tools 3. Run an initial sync of your usage data 4. Prompt you to enable the background daemon for continuous syncing (recommended)
CI / Headless
If you don't have a local browser (CI, remote SSH session, container), pre-issue a key at vibecafe.ai/usage/setup and pass it on the command line:
npx @vibe-cafe/vibe-usage init --manual-key vbu_xxxxxxxxxxxxCommands
npx @vibe-cafe/vibe-usage # Init (first run, browser login) or sync (subsequent runs)
npx @vibe-cafe/vibe-usage init # Re-run setup via browser login
npx @vibe-cafe/vibe-usage init --manual-key <vbu_...> # Skip browser, use pre-issued key (CI/headless)
npx @vibe-cafe/vibe-usage sync # Manual sync
npx @vibe-cafe/vibe-usage summary # Print last 7 days as markdown (cost / tokens / by model / by project)
npx @vibe-cafe/vibe-usage summary --days N # Same, over the last N days (1-90)
npx @vibe-cafe/vibe-usage daemon # Continuous sync (every 30m, foreground)
npx @vibe-cafe/vibe-usage daemon install # Install background service (systemd/launchd)
npx @vibe-cafe/vibe-usage daemon uninstall # Remove background service
npx @vibe-cafe/vibe-usage daemon status # Show background service status
npx @vibe-cafe/vibe-usage daemon stop # Stop background service
npx @vibe-cafe/vibe-usage daemon restart # Restart background service
npx @vibe-cafe/vibe-usage reset # Delete all data and re-upload from local logs
npx @vibe-cafe/vibe-usage reset --local # Delete this host's data only and re-upload
npx @vibe-cafe/vibe-usage skill # Install skill for AI coding assistants
npx @vibe-cafe/vibe-usage skill --remove # Remove installed skills
npx @vibe-cafe/vibe-usage status # Show config & detected toolsSupported Tools
| Tool | Data Location |
|---|---|
| Claude Code | ~/.claude/projects/ (tokens + sessions), ~/.claude/transcripts/ (sessions only); also scans $CLAUDE_CONFIG_DIR when set (deduped), so relocated configs and GUI/CLI env mismatches are both covered |
| Codex CLI | ~/.codex/sessions/ and ~/.codex/archived_sessions/ |
| GitHub Copilot CLI | ~/.copilot/session-state/*/events.jsonl |
| Cursor | state.vscdb (SQLite, reads cursorAuth/accessToken, fetches CSV from cursor.com); cloud data is stamped with a fixed cursor-cloud hostname so multi-machine setups don't double-count |
| Gemini CLI | ~/.gemini/tmp/<project_hash>/chats/session-*.jsonl (current line-delimited format) and legacy session-*.json; recurses into nested subagent sessions |
| OpenCode | ~/.local/share/opencode/opencode.db (SQLite, json_extract query) |
| OpenClaw | ~/.openclaw/agents/, ~/.openclaw-<profile>/agents/ (profile deployments) |
| pi | ~/.pi/agent/sessions/ |
| Qwen Code | ~/.qwen/tmp/ |
| Kimi Code | ~/.kimi/sessions/<md5(workdir)>/<session-id>/wire.jsonl (wire protocol 1.9, model from ~/.kimi/config.toml, project from ~/.kimi/kimi.json) |
| Amp | ~/.local/share/amp/threads/ |
| Droid | ~/.factory/sessions/ |
| Hermes | ~/.hermes/state.db + ~/.hermes/profiles/<name>/state.db (SQLite, multi-profile) |
| Kiro | ~/Library/Application Support/Kiro/User/globalStorage/kiro.kiroagent/dev_data/devdata.sqlite (SQLite, JSONL fallback; model name resolved from .chat timeline) |
| Cline | <host>/User/globalStorage/saoudrizwan.claude-dev/{state/taskHistory.json,tasks/<id>/ui_messages.json} (walks all VSCode-fork hosts: Code, Cursor, Windsurf, VSCodium, Trae, ...) |
| Roo Code | <host>/User/globalStorage/rooveterinaryinc.roo-cline/{tasks/_index.json,tasks/<id>/{history_item,ui_messages}.json} (walks all VSCode-fork hosts) |
| Antigravity | ~/.gemini/antigravity/conversations/*.pb to discover cascades, then reads token usage + sessions from the running language server via Connect RPC (process discovered with ps/lsof on macOS/Linux, PowerShell CIM with a wmic fallback on Windows) |
| ZCode | ~/.zcode/cli/db/db.sqlite (SQLite; reads the message table for per-message tokens, model, and project cwd/root, joined to session.directory) |
How It Works
- Parses local session logs from each AI coding tool
- Aggregates token usage into 30-minute buckets
- Extracts session metadata from all parsers: active time (AI generation time, excluding queue/TTFT wait), total duration, message counts
- Uploads buckets + sessions to your vibecafe.ai dashboard (always gzip-compressed, ~94% smaller)
- Incremental: parsers still compute full totals from local logs each sync (idempotent), but only buckets/sessions that are new or changed since the last successful upload are sent — a quiet machine uploads nothing. Sync state is kept in
~/.vibe-usage/state.json; deleting it just triggers a one-time full re-upload - SQLite-backed tools (Cursor, OpenCode, Kiro, Hermes) are read via Node's built-in
node:sqliteon Node ≥ 22.5 — nosqlite3binary needed (works on Windows out of the box); on older Node it falls back to the systemsqlite3CLI - For continuous syncing, use
npx @vibe-cafe/vibe-usage daemonor the Vibe Usage Mac app
Trust Model
vibe-usage parses local tool logs and local application state on a machine the user fully controls. The reported data is self-reported telemetry — local logs, parsers, and upload requests can all be modified by the user.
Good for visibility, not sufficient for settlement.
Suitable for:
- personal analytics and efficiency review
- team-internal AI coding adoption visibility
- token usage trends across tools, models, and projects
- rough cost estimation and anomaly detection
Not sufficient for:
- financial settlement or team expense reimbursement
- user rewards, credits, token, or airdrop allocation
- agent contribution scoring or marketplace revenue sharing
- proof-of-work / proof-of-usage or contractual billing
In short: this solves the visibility problem, not the verifiability problem. High-trust use cases need additional, independently verifiable metering layers.
AI Skill
Install vibe-usage as a skill for your AI coding assistant, so it knows how to sync usage data on your behalf:
npx @vibe-cafe/vibe-usage skillThis auto-detects installed AI tools (Claude Code, Cursor, Windsurf, Codex CLI) and writes a SKILL.md to each tool's global skills directory. To remove:
npx @vibe-cafe/vibe-usage skill --removeYou can also install via the open skills ecosystem:
npx skills add vibe-cafe/vibe-usageDevelopment
Test against a local vibe-cafe dev server without publishing:
VIBE_USAGE_DEV=1 VIBE_USAGE_API_URL=http://localhost:3000 npx @vibe-cafe/vibe-usage init
VIBE_USAGE_DEV=1 npx @vibe-cafe/vibe-usage syncVIBE_USAGE_DEV=1 uses a separate config file (~/.vibe-usage/config.dev.json).
Config
Config stored at ~/.vibe-usage/config.json (dev: config.dev.json).
| Key | Description |
|---|---|
apiKey | Your API key (starts with vbu_) |
apiUrl | Server URL (default: https://vibecafe.ai) |
hostname | Stable device name for usage tracking (set at init, reused across syncs) |
The hostname is captured once during init and reused for all future syncs. This prevents macOS mDNS hostname changes (e.g., MacBook-Pro → MacBook-Pro-2) from creating duplicate device entries. To change it manually:
npx @vibe-cafe/vibe-usage config set hostname my-device-nameDaemon Mode
Background service (recommended)
Install as a system service for automatic background syncing:
npx @vibe-cafe/vibe-usage daemon installThis creates a user-level service (systemd on Linux, launchd on macOS) that syncs every 30 minutes and starts automatically on login. Manage with:
npx @vibe-cafe/vibe-usage daemon status
npx @vibe-cafe/vibe-usage daemon stop
npx @vibe-cafe/vibe-usage daemon restart
npx @vibe-cafe/vibe-usage daemon uninstallFor reliable operation, install globally first: npm install -g @vibe-cafe/vibe-usage
Foreground mode
Run continuous syncing in the foreground (every 30 minutes):
npx @vibe-cafe/vibe-usage daemonPress Ctrl+C to stop.
License
MIT
import https from 'node:https';
import http from 'node:http';
import { URL } from 'node:url';
import { gzipSync } from 'node:zlib';
const MAX_RETRIES = 3;
const INITIAL_DELAY = 1000;
// Always gzip: ingest bodies are repetitive JSON that compresses ~10:1, and
// the few-byte gzip header overhead on a tiny body is irrelevant next to
// guaranteeing no uncompressed request ever leaves the client.
const GZIP_MIN_BYTES = 0;
export async function ingest(apiUrl, apiKey, buckets, opts, sessions) {
let lastError;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
return await _send(apiUrl, apiKey, buckets, opts?.onProgress, sessions);
} catch (err) {
lastError = err;
// Don't retry auth errors or client errors
if (err.message === 'UNAUTHORIZED' || err.statusCode >= 400 && err.statusCode < 500) {
throw err;
}
if (attempt < MAX_RETRIES - 1) {
const delay = INITIAL_DELAY * 2 ** attempt;
await new Promise(r => setTimeout(r, delay));
}
}
}
throw lastError;
}
function _send(apiUrl, apiKey, buckets, onProgress, sessions) {
return new Promise((resolve, reject) => {
const url = new URL('/api/usage/ingest', apiUrl);
const payload = { buckets };
if (sessions && sessions.length > 0) payload.sessions = sessions;
const raw = Buffer.from(JSON.stringify(payload));
const useGzip = raw.length >= GZIP_MIN_BYTES;
const body = useGzip ? gzipSync(raw) : raw;
const totalBytes = body.length;
const mod = url.protocol === 'https:' ? https : http;
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'Content-Length': totalBytes,
};
if (useGzip) headers['Content-Encoding'] = 'gzip';
const req = mod.request(url, {
method: 'POST',
timeout: 60_000,
headers,
}, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
if (res.statusCode === 401) {
reject(new Error('UNAUTHORIZED'));
return;
}
if (res.statusCode < 200 || res.statusCode >= 300) {
const err = new Error(`HTTP ${res.statusCode}: ${data}`);
err.statusCode = res.statusCode;
reject(err);
return;
}
try {
resolve(JSON.parse(data));
} catch {
reject(new Error(`Invalid JSON response: ${data}`));
}
});
});
req.on('error', (err) => reject(err));
req.on('timeout', () => {
req.destroy();
reject(new Error('Request timed out (60s)'));
});
// Write body in chunks to report upload progress
const CHUNK = 16 * 1024;
let sent = 0;
function writeNext() {
let ok = true;
while (ok && sent < totalBytes) {
const slice = body.subarray(sent, sent + CHUNK);
sent += slice.length;
if (onProgress) onProgress(sent, totalBytes);
ok = req.write(slice);
}
if (sent < totalBytes) {
req.once('drain', writeNext);
} else {
req.end();
}
}
writeNext();
});
}
/**
* DELETE usage data for the authenticated user.
* @param {string} apiUrl
* @param {string} apiKey
* @param {{hostname?: string}} [opts]
* @returns {Promise<{deleted: number}>}
*/
export function deleteAllData(apiUrl, apiKey, opts) {
return new Promise((resolve, reject) => {
const url = new URL('/api/usage/ingest', apiUrl);
if (opts?.hostname) url.searchParams.set('hostname', opts.hostname);
const mod = url.protocol === 'https:' ? https : http;
const req = mod.request(url, {
method: 'DELETE',
timeout: 60_000,
headers: {
'Authorization': `Bearer ${apiKey}`,
},
}, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
if (res.statusCode === 401) {
reject(new Error('UNAUTHORIZED'));
return;
}
if (res.statusCode < 200 || res.statusCode >= 300) {
const err = new Error(`HTTP ${res.statusCode}: ${data}`);
err.statusCode = res.statusCode;
reject(err);
return;
}
try {
resolve(JSON.parse(data));
} catch {
reject(new Error(`Invalid JSON response: ${data}`));
}
});
});
req.on('error', (err) => reject(err));
req.on('timeout', () => {
req.destroy();
reject(new Error('Request timed out (60s)'));
});
req.end();
});
}
/**
* Start a device authorization flow.
* Returns the deviceCode (for polling) + userCode + URLs (for the user).
*/
export function requestDeviceCode(apiUrl, { clientName, hostname }) {
return _jsonRequest(apiUrl, '/api/usage/device/code', 'POST', { clientName, hostname }, 10_000);
}
/**
* One poll iteration. Resolves with one of:
* { apiKey, apiUrl } — approved, key delivered
* { error: 'authorization_pending' } — keep polling
* { error: 'access_denied' } — user pressed deny
* { error: 'expired_token' } — code expired or already consumed
* { error: 'invalid_grant' | ... } — unrecoverable
* Rejects only on network/server errors.
*/
export function pollDeviceCode(apiUrl, deviceCode) {
return _jsonRequest(apiUrl, '/api/usage/device/poll', 'POST', { deviceCode }, 15_000);
}
function _jsonRequest(apiUrl, path, method, body, timeoutMs) {
return new Promise((resolve, reject) => {
const url = new URL(path, apiUrl);
const mod = url.protocol === 'https:' ? https : http;
const raw = Buffer.from(JSON.stringify(body));
const req = mod.request(url, {
method,
timeout: timeoutMs,
headers: {
'Content-Type': 'application/json',
'Content-Length': raw.length,
},
}, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
if (res.statusCode < 200 || res.statusCode >= 300) {
const err = new Error(`HTTP ${res.statusCode}: ${data}`);
err.statusCode = res.statusCode;
reject(err);
return;
}
try {
resolve(JSON.parse(data));
} catch {
reject(new Error(`Invalid JSON response: ${data}`));
}
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error(`Request timed out (${timeoutMs}ms)`)); });
req.write(raw);
req.end();
});
}
/**
* GET user settings from the vibecafe API.
* Returns null on any failure (network, auth, timeout) — caller should fail-safe.
* @param {string} apiUrl
* @param {string} apiKey
* @returns {Promise<{uploadProject: boolean} | null>}
*/
export function fetchSettings(apiUrl, apiKey) {
return new Promise((resolve) => {
const url = new URL('/api/usage/settings', apiUrl);
const mod = url.protocol === 'https:' ? https : http;
const req = mod.request(url, {
method: 'GET',
timeout: 10_000,
headers: {
'Authorization': `Bearer ${apiKey}`,
},
}, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
if (res.statusCode < 200 || res.statusCode >= 300) {
resolve(null);
return;
}
try {
resolve(JSON.parse(data));
} catch {
resolve(null);
}
});
});
req.on('error', () => resolve(null));
req.on('timeout', () => { req.destroy(); resolve(null); });
req.end();
});
}
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
const CONFIG_DIR = join(homedir(), '.vibe-usage');
const isDev = process.env.VIBE_USAGE_DEV === '1';
const CONFIG_FILE = join(CONFIG_DIR, isDev ? 'config.dev.json' : 'config.json');
export function getConfigPath() {
return CONFIG_FILE;
}
export function loadConfig() {
if (!existsSync(CONFIG_FILE)) return null;
try {
return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
} catch {
return null;
}
}
export function saveConfig(config) {
mkdirSync(CONFIG_DIR, { recursive: true });
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n', 'utf-8');
}
import { execFileSync } from 'node:child_process';
import { writeFileSync, unlinkSync, mkdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir, platform } from 'node:os';
import { fileURLToPath } from 'node:url';
import { success, failure, warn, dim } from './output.js';
const SERVICE_NAME = 'vibe-usage';
const LAUNCHD_LABEL = 'ai.vibecafe.vibe-usage';
function detectPlatform() {
const os = platform();
if (os === 'linux') {
if (existsSync('/run/systemd/system')) return 'systemd';
return null;
}
if (os === 'darwin') {
return 'launchd';
}
return null;
}
function resolvePaths() {
const nodePath = process.execPath;
const thisFile = fileURLToPath(import.meta.url);
const binPath = join(thisFile, '..', '..', 'bin', 'vibe-usage.js');
// npx cache paths are unstable — service will break when cache is cleared
const isNpxCache = binPath.includes('.npm/_npx');
return { nodePath, binPath, isNpxCache };
}
function getServicePaths(plat) {
if (plat === 'systemd') {
const dir = join(homedir(), '.config', 'systemd', 'user');
return { dir, file: join(dir, `${SERVICE_NAME}.service`) };
}
if (plat === 'launchd') {
const dir = join(homedir(), 'Library', 'LaunchAgents');
return { dir, file: join(dir, `${LAUNCHD_LABEL}.plist`) };
}
return null;
}
function generateSystemdUnit(nodePath, binPath) {
return `[Unit]
Description=VibeCafe Usage Tracker
After=network.target
[Service]
Type=simple
ExecStart=${nodePath} ${binPath} daemon
Restart=on-failure
RestartSec=10
Environment=NODE_ENV=production
WorkingDirectory=${homedir()}
[Install]
WantedBy=default.target
`;
}
function generateLaunchdPlist(nodePath, binPath) {
const logDir = join(homedir(), '.vibe-usage');
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${LAUNCHD_LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>${nodePath}</string>
<string>${binPath}</string>
<string>daemon</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>WorkingDirectory</key>
<string>${homedir()}</string>
<key>StandardOutPath</key>
<string>${join(logDir, 'daemon.log')}</string>
<key>StandardErrorPath</key>
<string>${join(logDir, 'daemon.err')}</string>
<key>EnvironmentVariables</key>
<dict>
<key>NODE_ENV</key>
<string>production</string>
</dict>
</dict>
</plist>
`;
}
function run(cmd, args) {
try {
const output = execFileSync(cmd, args, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
return { ok: true, output: output.trim() };
} catch (err) {
return { ok: false, output: (err.stderr || err.stdout || err.message || '').trim() };
}
}
function install() {
const plat = detectPlatform();
if (!plat) {
console.log(failure('当前平台不支持 daemon。'));
console.log(dim(' 支持: Linux (systemd) / macOS (launchd)'));
return;
}
const { nodePath, binPath, isNpxCache } = resolvePaths();
if (isNpxCache) {
console.log(warn('检测到从 npx 缓存运行 vibe-usage,缓存清理后 daemon 会失效。'));
console.log(dim(' 建议先全局安装: npm install -g @vibe-cafe/vibe-usage'));
console.log();
}
const paths = getServicePaths(plat);
if (existsSync(paths.file)) {
console.log(warn('Daemon 已安装,运行 `vibe-usage daemon restart` 或 `uninstall` 先处理。'));
return;
}
mkdirSync(paths.dir, { recursive: true });
if (plat === 'systemd') {
writeFileSync(paths.file, generateSystemdUnit(nodePath, binPath), 'utf-8');
console.log(dim(` 已写入 ${paths.file}`));
run('systemctl', ['--user', 'daemon-reload']);
const result = run('systemctl', ['--user', 'enable', '--now', `${SERVICE_NAME}.service`]);
if (!result.ok) {
console.error(failure(`启动服务失败: ${result.output}`));
return;
}
console.log(success('服务已启用并启动。'));
}
if (plat === 'launchd') {
mkdirSync(join(homedir(), '.vibe-usage'), { recursive: true });
writeFileSync(paths.file, generateLaunchdPlist(nodePath, binPath), 'utf-8');
console.log(dim(` 已写入 ${paths.file}`));
const result = run('launchctl', ['load', paths.file]);
if (!result.ok) {
console.error(failure(`加载服务失败: ${result.output}`));
return;
}
console.log(success('服务已加载并启动。'));
}
console.log();
console.log(success('Daemon 已安装,用量数据将每 30 分钟自动同步。'));
console.log(dim(' 运行 `vibe-usage daemon status` 查看状态。'));
}
function uninstall() {
const plat = detectPlatform();
if (!plat) {
console.log(failure('未检测到支持的服务平台。'));
return;
}
const paths = getServicePaths(plat);
if (!existsSync(paths.file)) {
console.log(dim('未安装 daemon 服务。'));
return;
}
if (plat === 'systemd') {
run('systemctl', ['--user', 'stop', `${SERVICE_NAME}.service`]);
run('systemctl', ['--user', 'disable', `${SERVICE_NAME}.service`]);
unlinkSync(paths.file);
run('systemctl', ['--user', 'daemon-reload']);
console.log(success('服务已停止、禁用并删除。'));
}
if (plat === 'launchd') {
run('launchctl', ['unload', paths.file]);
unlinkSync(paths.file);
console.log(success('服务已卸载并删除。'));
}
}
function status() {
const plat = detectPlatform();
if (!plat) {
console.log(failure('未检测到支持的服务平台。'));
return;
}
const paths = getServicePaths(plat);
if (!existsSync(paths.file)) {
console.log(dim('未安装 daemon 服务。'));
console.log(dim(' 运行 `vibe-usage daemon install` 安装。'));
return;
}
if (plat === 'systemd') {
const result = run('systemctl', ['--user', 'status', `${SERVICE_NAME}.service`]);
console.log(dim(result.output));
}
if (plat === 'launchd') {
const result = run('launchctl', ['list', LAUNCHD_LABEL]);
if (result.ok) {
console.log(dim(`Service: ${LAUNCHD_LABEL}`));
console.log(dim(result.output));
} else {
console.log(warn('服务已安装但当前未运行。'));
}
}
}
function stop() {
const plat = detectPlatform();
if (!plat) {
console.log(failure('未检测到支持的服务平台。'));
return;
}
if (plat === 'systemd') {
const result = run('systemctl', ['--user', 'stop', `${SERVICE_NAME}.service`]);
console.log(result.ok ? success('服务已停止。') : failure(`停止失败: ${result.output}`));
}
if (plat === 'launchd') {
const result = run('launchctl', ['stop', LAUNCHD_LABEL]);
console.log(result.ok ? success('服务已停止。') : failure(`停止失败: ${result.output}`));
}
}
function restart() {
const plat = detectPlatform();
if (!plat) {
console.log(failure('未检测到支持的服务平台。'));
return;
}
if (plat === 'systemd') {
const result = run('systemctl', ['--user', 'restart', `${SERVICE_NAME}.service`]);
console.log(result.ok ? success('服务已重启。') : failure(`重启失败: ${result.output}`));
}
if (plat === 'launchd') {
run('launchctl', ['stop', LAUNCHD_LABEL]);
const result = run('launchctl', ['start', LAUNCHD_LABEL]);
console.log(result.ok ? success('服务已重启。') : failure(`重启失败: ${result.output}`));
}
}
const SUBCOMMANDS = { install, uninstall, status, stop, restart };
export async function manageDaemon(subcommand) {
const fn = SUBCOMMANDS[subcommand];
if (!fn) {
console.error(failure(`未知 daemon 子命令: ${subcommand}`));
console.error(dim(' 用法: vibe-usage daemon <install|uninstall|status|stop|restart>'));
process.exit(1);
}
fn();
}
import { loadConfig } from './config.js';
import { runSync } from './sync.js';
import { failure, dim } from './output.js';
const INTERVAL = 30 * 60_000; // 30 minutes
function log(msg) {
const ts = new Date().toLocaleTimeString('en-US', { hour12: false });
process.stdout.write(dim(`[${ts}] ${msg}\n`));
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export async function runDaemon() {
const config = loadConfig();
if (!config?.apiKey) {
console.error(failure('尚未配置,请先运行 `npx @vibe-cafe/vibe-usage init`。'));
process.exit(1);
}
log('daemon started (sync every 30m, Ctrl+C to stop)');
// Why we don't exit on the first 401: launchd KeepAlive / systemd
// Restart=on-failure relaunch in ~10s, which used to turn a single bad/
// revoked key into ~360 ingest-401s per hour per machine. Sleeping a full
// INTERVAL between auth retries collapses that storm to the daemon's normal
// 30m cadence; only after MAX_AUTH_FAILURES consecutive 401s do we hand
// off to the supervisor, which by then can't relaunch fast enough to matter.
const MAX_AUTH_FAILURES = 5;
let consecutiveAuthFailures = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
try {
await runSync({ throws: true, quiet: true });
consecutiveAuthFailures = 0;
} catch (err) {
if (err.message === 'UNAUTHORIZED') {
consecutiveAuthFailures++;
if (consecutiveAuthFailures >= MAX_AUTH_FAILURES) {
log(`API key invalid for ${MAX_AUTH_FAILURES} consecutive syncs, exiting.`);
process.exit(1);
}
log(`API key invalid (attempt ${consecutiveAuthFailures}/${MAX_AUTH_FAILURES}), retrying in 30m.`);
} else {
log(`sync error: ${err.message}`);
}
}
await sleep(INTERVAL);
}
}
import { loadConfig, saveConfig, getConfigPath } from './config.js';
import { detectInstalledTools, TOOLS } from './tools.js';
import { existsSync } from 'node:fs';
import { smallHeader } from './output.js';
function printSmallHeader() {
console.log();
console.log(smallHeader());
console.log();
}
async function showStatus() {
const config = loadConfig();
console.log('\nvibe-usage status\n');
if (!config?.apiKey) {
console.log(' Config: not configured');
console.log(` Run \`npx @vibe-cafe/vibe-usage init\` to set up.\n`);
} else {
console.log(` Config: ${getConfigPath()}`);
console.log(` API key: ${config.apiKey.slice(0, 8)}...`);
console.log(` API URL: ${config.apiUrl || 'https://vibecafe.ai'}`);
}
console.log('\n Detected tools:');
const detected = detectInstalledTools();
if (detected.length === 0) {
console.log(' (none)\n');
} else {
for (const tool of detected) {
console.log(` ${tool.name}`);
}
console.log();
}
console.log(' All supported tools:');
for (const tool of TOOLS) {
const installed = existsSync(tool.dataDir) ? 'installed' : 'not found';
console.log(` ${tool.name}: ${installed}`);
}
console.log();
}
const VALID_CONFIG_KEYS = ['apiKey', 'apiUrl', 'hostname'];
function handleConfig(args) {
const sub = args[0];
switch (sub) {
case 'get': {
const key = args[1];
if (!key) {
console.error('Usage: vibe-usage config get <key>');
process.exit(1);
}
const config = loadConfig();
if (!config || !(key in config)) {
// Output nothing — caller checks exit code or empty output
process.exit(0);
}
// Output raw value (no formatting) for machine parsing
console.log(config[key] ?? '');
break;
}
case 'set': {
const key = args[1];
const value = args[2];
if (!key || value === undefined) {
console.error('Usage: vibe-usage config set <key> <value>');
process.exit(1);
}
if (!VALID_CONFIG_KEYS.includes(key)) {
console.error(`Unknown config key: ${key}`);
console.error(`Valid keys: ${VALID_CONFIG_KEYS.join(', ')}`);
process.exit(1);
}
const config = loadConfig() || {};
config[key] = value;
saveConfig(config);
break;
}
case 'show': {
const config = loadConfig();
if (!config) {
console.log('{}');
} else {
console.log(JSON.stringify(config, null, 2));
}
break;
}
default:
console.error(`Unknown config subcommand: ${sub || '(none)'}`);
console.error('Usage: vibe-usage config <get|set|show>');
process.exit(1);
}
}
function extractOption(args, name) {
const flag = `--${name}`;
const idx = args.findIndex(a => a === flag);
if (idx === -1) return { args, value: undefined };
const value = args[idx + 1];
if (value === undefined || value.startsWith('--')) {
console.error(`Option ${flag} requires a value.`);
process.exit(1);
}
return { args: [...args.slice(0, idx), ...args.slice(idx + 2)], value };
}
export async function run(rawArgs) {
// --key and --manual-key both mean "skip device flow, take this vbu_ key".
// --manual-key is the documented name; --key is kept as a legacy alias so
// existing scripts/docs don't break when device flow becomes the default.
let stripped;
let apiKey;
({ args: stripped, value: apiKey } = extractOption(rawArgs, 'manual-key'));
if (apiKey === undefined) {
({ args: stripped, value: apiKey } = extractOption(stripped, 'key'));
}
const args = stripped;
const command = args[0];
switch (command) {
case 'init': {
const { runInit } = await import('./init.js');
await runInit({ apiKey });
break;
}
case 'sync': {
printSmallHeader();
const { runSync } = await import('./sync.js');
await runSync();
break;
}
case 'summary': {
const { runSummary } = await import('./summary.js');
await runSummary(args.slice(1));
break;
}
case 'reset': {
printSmallHeader();
const { runReset } = await import('./reset.js');
await runReset(args.slice(1));
break;
}
case 'daemon':
case '--daemon': {
const sub = args[1];
if (sub && ['install', 'uninstall', 'status', 'stop', 'restart'].includes(sub)) {
printSmallHeader();
const { manageDaemon } = await import('./daemon-service.js');
await manageDaemon(sub);
} else {
// Foreground daemon loop — no header, just start syncing
const { runDaemon } = await import('./daemon.js');
await runDaemon();
}
break;
}
case 'skill': {
printSmallHeader();
const { runSkill } = await import('./skill.js');
await runSkill(args.slice(1));
break;
}
case 'config': {
handleConfig(args.slice(1));
break;
}
case 'status': {
await showStatus();
break;
}
case 'help':
case '--help':
case '-h': {
console.log(`
vibe-usage - Vibe Usage Tracker by VibeCafé
Usage:
npx @vibe-cafe/vibe-usage Init (first run, browser login) or sync
npx @vibe-cafe/vibe-usage init Set up via browser login (default)
npx @vibe-cafe/vibe-usage init --manual-key <vbu_...> Skip browser, use a pre-issued key (CI/headless)
npx @vibe-cafe/vibe-usage sync Manually sync usage data
npx @vibe-cafe/vibe-usage summary Print last 7 days as markdown (cost/tokens/model/project)
npx @vibe-cafe/vibe-usage summary --days N Same, but over the last N days (1-90)
npx @vibe-cafe/vibe-usage daemon Continuous sync (every 30m, foreground)
npx @vibe-cafe/vibe-usage daemon install Install background service (systemd/launchd)
npx @vibe-cafe/vibe-usage daemon uninstall Remove background service
npx @vibe-cafe/vibe-usage daemon status Show background service status
npx @vibe-cafe/vibe-usage daemon stop Stop background service
npx @vibe-cafe/vibe-usage daemon restart Restart background service
npx @vibe-cafe/vibe-usage reset Delete all data and re-upload
npx @vibe-cafe/vibe-usage reset --local Delete data for this host only and re-upload
npx @vibe-cafe/vibe-usage skill Install skill for AI coding tools
npx @vibe-cafe/vibe-usage skill --remove Remove installed skills
npx @vibe-cafe/vibe-usage status Show config and detected tools
npx @vibe-cafe/vibe-usage config show Show full config as JSON
npx @vibe-cafe/vibe-usage config get <key> Get a config value
npx @vibe-cafe/vibe-usage config set <key> <value> Set a config value
npx @vibe-cafe/vibe-usage help Show this help
`);
break;
}
default: {
const config = loadConfig();
if (!config?.apiKey || apiKey) {
// First run OR user passed --key for a one-shot setup — init.js prints the big header
const { runInit } = await import('./init.js');
await runInit({ apiKey });
} else {
// Already configured: small header + sync
printSmallHeader();
const { runSync } = await import('./sync.js');
await runSync();
}
}
}
}
import { createInterface } from 'node:readline';
import { execFile } from 'node:child_process';
import { hostname as osHostname, platform } from 'node:os';
import { loadConfig, saveConfig } from './config.js';
import { ingest, requestDeviceCode, pollDeviceCode } from './api.js';
import { runSync } from './sync.js';
import { detectInstalledTools } from './tools.js';
import { bigHeader, success, failure, warn, arrow, link, dim, divider } from './output.js';
const CLIENT_NAME = 'vibe-usage CLI';
function prompt(question) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
function openBrowser(url) {
const cmds = { darwin: 'open', linux: 'xdg-open', win32: 'start' };
const cmd = cmds[platform()] || cmds.linux;
// Use execFile with args array to avoid shell injection via VIBE_USAGE_API_URL
execFile(cmd, [url], () => {});
}
function isDaemonPlatform() {
return process.platform === 'linux' || process.platform === 'darwin';
}
export async function runInit(options = {}) {
const { apiKey: providedKey } = options;
console.log(bigHeader());
const existing = loadConfig();
if (existing?.apiKey) {
if (providedKey && existing.apiKey === providedKey) {
console.log(dim('已配置同一个 Key,直接同步数据。'));
console.log();
await runSync();
return;
}
const answer = await prompt('检测到已有配置,是否覆盖? (y/N) ');
if (answer.toLowerCase() !== 'y') {
console.log(dim('已取消。'));
return;
}
}
const apiUrl = process.env.VIBE_USAGE_API_URL || 'https://vibecafe.ai';
const host = existing?.hostname || osHostname().replace(/\.local$/, '');
let apiKey;
if (providedKey) {
if (!providedKey.startsWith('vbu_')) {
console.error(failure('API Key 无效,必须以 vbu_ 开头。'));
process.exit(1);
}
apiKey = providedKey;
} else {
apiKey = await runDeviceFlow(apiUrl, host);
if (!apiKey) process.exit(1);
}
try {
await ingest(apiUrl, apiKey, []);
console.log(success(`验证通过 ${dim(apiKey.slice(0, 12) + '...')}`));
} catch (err) {
if (err.message === 'UNAUTHORIZED') {
console.error(failure('API Key 无效,请检查后重试。'));
process.exit(1);
}
console.log(warn(`网络异常(${err.message}),跳过验证直接保存。`));
}
const config = {
apiKey,
apiUrl,
hostname: host,
};
saveConfig(config);
const tools = detectInstalledTools();
if (tools.length > 0) {
console.log(success(`检测到 ${tools.length} 款工具: ${dim(tools.map(t => t.name).join(' · '))}`));
} else {
console.log(warn('未检测到 AI 编码工具,安装后重新运行即可。'));
}
console.log();
console.log(divider());
console.log();
await runSync();
if (isDaemonPlatform()) {
if (process.stdin.isTTY) {
console.log();
const answer = await prompt(`开启后台自动同步?${dim('(推荐)')} [Y/n] `);
const normalized = answer.toLowerCase();
if (normalized === '' || normalized === 'y' || normalized === 'yes') {
const { manageDaemon } = await import('./daemon-service.js');
await manageDaemon('install');
} else {
console.log();
console.log(dim('随时运行 `npx @vibe-cafe/vibe-usage daemon install` 开启后台同步。'));
}
} else {
console.log();
console.log(dim('提示: 运行 `npx @vibe-cafe/vibe-usage daemon install` 开启后台自动同步。'));
}
}
}
async function runDeviceFlow(apiUrl, hostname) {
let device;
try {
device = await requestDeviceCode(apiUrl, { clientName: CLIENT_NAME, hostname });
} catch (err) {
console.error(failure(`无法连接 ${apiUrl}:${err.message}`));
return null;
}
console.log(`${arrow('登录确认')} ${link(device.verificationUriComplete)}`);
console.log(` 验证码: ${device.userCode}`);
console.log(dim(' 浏览器会自动打开;如果没反应,请手动复制上方链接。'));
console.log();
openBrowser(device.verificationUriComplete);
const intervalMs = (device.interval || 5) * 1000;
const deadline = Date.now() + (device.expiresIn || 900) * 1000;
process.stdout.write(dim('等待审批…'));
const aborter = new AbortController();
const onSigint = () => { aborter.abort(); };
process.on('SIGINT', onSigint);
try {
while (Date.now() < deadline) {
if (aborter.signal.aborted) {
process.stdout.write('\n');
console.log(warn('已取消。'));
return null;
}
await sleep(intervalMs);
let res;
try {
res = await pollDeviceCode(apiUrl, device.deviceCode);
} catch (err) {
// Transient network blip — keep polling until deadline.
process.stdout.write(dim('.'));
continue;
}
if (res.apiKey) {
process.stdout.write('\n');
console.log(success('已批准,获取到 API Key。'));
return res.apiKey;
}
if (res.error === 'authorization_pending') {
process.stdout.write(dim('.'));
continue;
}
if (res.error === 'access_denied') {
process.stdout.write('\n');
console.error(failure('请求被拒绝。'));
return null;
}
if (res.error === 'expired_token') {
process.stdout.write('\n');
console.error(failure('验证码已过期,请重跑 init。'));
return null;
}
process.stdout.write('\n');
console.error(failure(`服务端返回未知错误:${res.error}`));
return null;
}
process.stdout.write('\n');
console.error(failure('验证码已过期,请重跑 init。'));
return null;
} finally {
process.removeListener('SIGINT', onSigint);
}
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Terminal output helpers: colors, status markers, OSC 8 clickable links.
// Falls back to plain text when NO_COLOR is set or stdout is not a TTY.
const NO_COLOR = !!process.env.NO_COLOR || !process.stdout.isTTY;
const CODES = {
reset: '\x1b[0m',
bold: '\x1b[1m',
dim: '\x1b[2m',
underline: '\x1b[4m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
cyan: '\x1b[36m',
gray: '\x1b[90m',
};
function wrap(code, text) {
if (NO_COLOR) return String(text);
return `${code}${text}${CODES.reset}`;
}
export const bold = (t) => wrap(CODES.bold, t);
export const dim = (t) => wrap(CODES.dim, t);
export const red = (t) => wrap(CODES.red, t);
export const green = (t) => wrap(CODES.green, t);
export const yellow = (t) => wrap(CODES.yellow, t);
export const cyan = (t) => wrap(CODES.cyan, t);
export const gray = (t) => wrap(CODES.gray, t);
/** OSC 8 hyperlink — supported by modern terminals (iTerm2, Kitty, Warp, VSCode, macOS Terminal 14+). */
export function link(url, text = url) {
if (NO_COLOR) return url === text ? url : `${text} (${url})`;
return `\x1b]8;;${url}\x1b\\${CODES.cyan}${CODES.underline}${text}${CODES.reset}\x1b]8;;\x1b\\`;
}
export const success = (msg) => `${green('✓')} ${msg}`;
export const failure = (msg) => `${red('✗')} ${msg}`;
export const warn = (msg) => `${yellow('!')} ${msg}`;
export const arrow = (msg) => `${cyan('→')} ${msg}`;
export const divider = () => dim('─'.repeat(48));
/** Print a blank line. */
export const nl = () => console.log();
const LOGO_LINES = [
'██╗ ██╗██╗██████╗ ███████╗ ██╗ ██╗███████╗ █████╗ ██████╗ ███████╗',
'██║ ██║██║██╔══██╗██╔════╝ ██║ ██║██╔════╝██╔══██╗██╔════╝ ██╔════╝',
'██║ ██║██║██████╔╝█████╗ ██║ ██║███████╗███████║██║ ███╗█████╗ ',
'╚██╗ ██╔╝██║██╔══██╗██╔══╝ ██║ ██║╚════██║██╔══██║██║ ██║██╔══╝ ',
' ╚████╔╝ ██║██████╔╝███████╗ ╚██████╔╝███████║██║ ██║╚██████╔╝███████╗',
' ╚═══╝ ╚═╝╚═════╝ ╚══════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝',
];
/** Big ASCII logo — used once at the top of `init` / first-run. */
export function bigHeader() {
const logo = NO_COLOR
? LOGO_LINES.join('\n')
: LOGO_LINES.map(l => `${CODES.cyan}${l}${CODES.reset}`).join('\n');
return `\n${logo}\n${dim(' Vibe Usage · by VibeCafé')}\n`;
}
/** Compact one-line header — used for `sync`, `daemon`, `reset`, `skill`. */
export function smallHeader() {
return `${bold('Vibe Usage')} ${dim('· by VibeCafé')}`;
}
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { aggregateToBuckets, extractSessions } from './index.js';
function resolveThreadsDir() {
if (process.env.AMP_DATA_DIR) return process.env.AMP_DATA_DIR;
if (process.env.XDG_DATA_HOME) return join(process.env.XDG_DATA_HOME, 'amp', 'threads');
return join(homedir(), '.local', 'share', 'amp', 'threads');
}
function findThreadFiles(dir) {
const results = [];
if (!existsSync(dir)) return results;
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...findThreadFiles(fullPath));
} else if (entry.isFile() && entry.name.startsWith('T-') && entry.name.endsWith('.json')) {
results.push(fullPath);
}
}
} catch {
}
return results;
}
function setMessageTimestamp(map, messageId, timestamp) {
if (!Number.isInteger(messageId)) return;
const current = map.get(messageId);
if (!current || timestamp < current) {
map.set(messageId, timestamp);
}
}
function buildMessageTimestampMap(events) {
const map = new Map();
if (!Array.isArray(events)) return map;
for (const event of events) {
const ts = new Date(event?.timestamp);
if (isNaN(ts.getTime())) continue;
setMessageTimestamp(map, event.fromMessageId, ts);
setMessageTimestamp(map, event.toMessageId, ts);
}
return map;
}
export async function parse() {
const threadsDir = resolveThreadsDir();
const threadFiles = findThreadFiles(threadsDir);
if (threadFiles.length === 0) return { buckets: [], sessions: [] };
const entries = [];
const sessionEvents = [];
for (const filePath of threadFiles) {
let thread;
try {
thread = JSON.parse(readFileSync(filePath, 'utf-8'));
} catch {
continue;
}
const sessionId = thread?.id || filePath;
const messages = Array.isArray(thread?.messages) ? thread.messages : [];
const ledgerEvents = Array.isArray(thread?.usageLedger?.events) ? thread.usageLedger.events : [];
const hasLedger = ledgerEvents.length > 0;
if (hasLedger) {
for (const event of ledgerEvents) {
const ts = new Date(event?.timestamp);
if (isNaN(ts.getTime())) continue;
const inputTokens = event?.tokens?.input || 0;
const outputTokens = event?.tokens?.output || 0;
if (inputTokens === 0 && outputTokens === 0) continue;
const toMessage = Number.isInteger(event.toMessageId) ? messages[event.toMessageId] : null;
const cacheReadInputTokens = toMessage?.usage?.cacheReadInputTokens || 0;
entries.push({
source: 'amp',
model: event?.model || 'unknown',
project: 'unknown',
timestamp: ts,
inputTokens,
outputTokens,
cachedInputTokens: cacheReadInputTokens,
reasoningOutputTokens: 0,
});
}
} else {
for (const message of messages) {
const usage = message?.usage;
if (!usage) continue;
const ts = new Date(message?.timestamp || thread?.created);
if (isNaN(ts.getTime())) continue;
const inputTokens = usage.inputTokens || 0;
const outputTokens = usage.outputTokens || 0;
if (inputTokens === 0 && outputTokens === 0 && (usage.cacheReadInputTokens || 0) === 0) continue;
entries.push({
source: 'amp',
model: usage.model || 'unknown',
project: 'unknown',
timestamp: ts,
inputTokens,
outputTokens,
cachedInputTokens: usage.cacheReadInputTokens || 0,
reasoningOutputTokens: 0,
});
}
}
const messageTsMap = buildMessageTimestampMap(ledgerEvents);
const baseTimestamp = new Date(thread?.created);
const hasBaseTimestamp = !isNaN(baseTimestamp.getTime());
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
const mappedTs = messageTsMap.get(i);
const ts = mappedTs || (hasBaseTimestamp ? baseTimestamp : null);
if (!ts || isNaN(ts.getTime())) continue;
sessionEvents.push({
sessionId,
source: 'amp',
project: 'unknown',
timestamp: ts,
role: message?.role === 'user' ? 'user' : 'assistant',
});
}
}
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
}
import { execSync } from 'node:child_process';
import { readdirSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { aggregateToBuckets, extractSessions } from './index.js';
/**
* Antigravity parser (file-based).
* Scans .pb files in ~/.gemini/antigravity/conversations/ to discover cascade IDs.
* Calls GetCascadeTrajectory via a running language server to extract token usage
* (from generatorMetadata) and session events (from trajectory steps).
*/
const SOURCE = 'antigravity';
const CONVERSATIONS_DIR = join(homedir(), '.gemini', 'antigravity', 'conversations');
// User sources → role 'user'; Model source → role 'assistant'; System sources → skip
const USER_SOURCES = new Set([
'CORTEX_STEP_SOURCE_USER_EXPLICIT',
'CORTEX_STEP_SOURCE_USER_IMPLICIT',
]);
const ASSISTANT_SOURCES = new Set([
'CORTEX_STEP_SOURCE_MODEL',
]);
// ── Process discovery (single instance) ──────────────────────────────
const IS_WIN = process.platform === 'win32';
/**
* Find ONE running language server process with a CSRF token.
* Returns { pid, csrfToken } or null.
*/
function findLanguageServer() {
try {
return IS_WIN ? findLanguageServerWin() : findLanguageServerUnix();
} catch {
return null;
}
}
function findLanguageServerUnix() {
const out = execSync("ps aux | grep 'antigravity/bin/language_server_'", { encoding: 'utf-8', timeout: 5000 });
for (const line of out.split('\n')) {
if (!line.trim()) continue;
if (line.includes('grep')) continue;
const parts = line.trim().split(/\s+/);
if (parts.length < 2) continue;
const pid = parts[1];
const csrfMatch = line.match(/--csrf_token\s+([0-9a-f-]+)/);
const csrfToken = csrfMatch ? csrfMatch[1] : '';
if (csrfToken) return { pid, csrfToken };
}
return null;
}
function findLanguageServerWin() {
// Prefer PowerShell/CIM: wmic is disabled by default on Windows 11 23H2+
// and removed entirely from 25H2 onward. Fall back to wmic for old/stripped
// environments without PowerShell. Each probe is independently time-boxed and
// failures are swallowed, so a missing/hung tool never blocks the next one or
// the parsers that run after antigravity.
const out = queryProcessesWinPowerShell() ?? queryProcessesWinWmic();
if (!out) return null;
return parseWinProcessList(out);
}
/**
* Query language_server processes via PowerShell + CIM.
* Emits "ProcessId=..." / "CommandLine=..." lines (wmic /format:list shape)
* so parseWinProcessList handles either source. Returns null on failure.
*/
function queryProcessesWinPowerShell() {
// Filter is applied in PowerShell so the LIKE wildcards stay server-side.
// A "---" separator before each process's ProcessId/CommandLine lines keeps
// fields grouped even when multiple processes match.
const script =
"Get-CimInstance Win32_Process -Filter \"CommandLine LIKE '%antigravity%language_server%'\" | " +
'ForEach-Object { "---"; "ProcessId=" + $_.ProcessId; "CommandLine=" + $_.CommandLine }';
for (const exe of ['powershell.exe', 'pwsh.exe']) {
try {
const out = execSync(
`${exe} -NoProfile -NonInteractive -Command "${script.replace(/"/g, '\\"')}"`,
{ encoding: 'utf-8', timeout: 4000, windowsHide: true },
);
if (out && out.trim()) return out;
// Empty (no matching process) — no point trying another shell.
return null;
} catch {
// Try next shell (pwsh on systems without legacy powershell.exe).
}
}
return null;
}
/** Legacy fallback: wmic /format:list. Returns null on failure. */
function queryProcessesWinWmic() {
try {
return execSync(
'wmic process where "CommandLine like \'%antigravity%language_server%\'" get ProcessId,CommandLine /format:list',
{ encoding: 'utf-8', timeout: 4000, shell: 'cmd.exe' },
);
} catch {
return null;
}
}
/**
* Parse "ProcessId=..." / "CommandLine=..." records (from either PowerShell or
* wmic /format:list) and return the first language_server that carries a
* --csrf_token, or null. PowerShell emits an explicit "---" separator per
* process; wmic does not and may emit the two fields in either order, so a
* record also ends whenever a field we've already captured reappears.
*/
function parseWinProcessList(out) {
let pid = '';
let cmdLine = '';
const finish = () => {
if (pid && cmdLine && !/WMIC\.exe|powershell\.exe|pwsh\.exe/i.test(cmdLine)) {
const csrfMatch = cmdLine.match(/--csrf_token\s+([0-9a-f-]+)/);
if (csrfMatch) return { pid, csrfToken: csrfMatch[1] };
}
return null;
};
const reset = () => { pid = ''; cmdLine = ''; };
for (const line of out.split('\n')) {
const trimmed = line.trim();
const isPid = trimmed.startsWith('ProcessId=');
const isCmd = trimmed.startsWith('CommandLine=');
// Record boundary: explicit "---", or a field that would overwrite one we
// already hold (next process began without a separator, e.g. wmic output).
if (trimmed === '---' || (isPid && pid) || (isCmd && cmdLine)) {
const found = finish();
if (found) return found;
reset();
}
if (isPid) pid = trimmed.slice('ProcessId='.length);
else if (isCmd) cmdLine = trimmed.slice('CommandLine='.length);
}
return finish();
}
function findListeningPorts(pid) {
try {
return IS_WIN ? findListeningPortsWin(pid) : findListeningPortsUnix(pid);
} catch {
return [];
}
}
function findListeningPortsUnix(pid) {
const out = execSync(`lsof -iTCP -sTCP:LISTEN -nP -a -p ${pid}`, {
encoding: 'utf-8',
timeout: 5000,
});
const ports = [];
for (const line of out.split('\n')) {
const match = line.match(/:(\d+)\s+\(LISTEN\)/);
if (match) ports.push(parseInt(match[1], 10));
}
return ports;
}
function findListeningPortsWin(pid) {
// netstat output: TCP 127.0.0.1:49327 0.0.0.0:0 LISTENING 12345
const out = execSync('netstat -ano', { encoding: 'utf-8', timeout: 5000 });
const ports = [];
for (const line of out.split('\n')) {
if (!line.includes('LISTENING')) continue;
const parts = line.trim().split(/\s+/);
// parts: [TCP, local_addr:port, foreign_addr, LISTENING, pid]
const linePid = parts[parts.length - 1];
if (linePid !== String(pid)) continue;
const addrMatch = parts[1]?.match(/:(\d+)$/);
if (addrMatch) ports.push(parseInt(addrMatch[1], 10));
}
return ports;
}
async function rpcPost(baseUrl, path, body, csrfToken, timeoutMs = 10000) {
const url = new URL(path, baseUrl);
const headers = {
'Content-Type': 'application/json',
'Connect-Protocol-Version': '1',
};
if (csrfToken) headers['X-Codeium-Csrf-Token'] = csrfToken;
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) throw new Error(`HTTP ${res.status} from ${path}`);
return res.json();
}
async function probeHttpPort(ports, csrfToken) {
for (const port of ports) {
const baseUrl = `http://127.0.0.1:${port}`;
try {
await rpcPost(
baseUrl,
'/exa.language_server_pb.LanguageServerService/GetWorkspaceInfos',
{},
csrfToken,
3000,
);
return baseUrl;
} catch {
// Not the right port, try next
}
}
return null;
}
// ── Helpers ──────────────────────────────────────────────────────────
/**
* Normalize model names to canonical forms.
*/
const MODEL_NORMALIZE_MAP = {
'claude-opus-4-6-thinking': 'claude-opus-4-6',
'claude-sonnet-4-6-thinking': 'claude-sonnet-4-6',
'gemini-3-flash-c': 'gemini-3-flash',
"gemini-3.1-pro-high": "gemini-3.1-pro",
"gemini-3.1-pro-low": "gemini-3.1-pro",
"gemini-3-pro-high": "gemini-3-pro",
"gemini-3-pro-low": "gemini-3-pro",
};
/**
* Map internal placeholder model IDs to canonical names.
* Used when responseModel is empty and only chatModel.model is available.
*/
const PLACEHOLDER_MODEL_MAP = {
'MODEL_PLACEHOLDER_M37': 'gemini-3.1-pro',
'MODEL_PLACEHOLDER_M36': 'gemini-3.1-pro',
'MODEL_PLACEHOLDER_M47': 'gemini-3-flash',
'MODEL_PLACEHOLDER_M35': 'claude-sonnet-4-6',
'MODEL_PLACEHOLDER_M26': 'claude-opus-4-6',
'MODEL_OPENAI_GPT_OSS_120B_MEDIUM': 'gpt-oss-120b',
};
function normalizeModel(raw) {
return MODEL_NORMALIZE_MAP[raw] || raw;
}
/**
* Resolve model name: prefer responseModel, fall back to placeholder map.
*/
function resolveModel(chatModel) {
if (chatModel.responseModel) return normalizeModel(chatModel.responseModel);
const placeholder = chatModel.model || '';
if (PLACEHOLDER_MODEL_MAP[placeholder]) return PLACEHOLDER_MODEL_MAP[placeholder];
return 'unknown';
}
function toSafeNumber(value) {
if (value == null) return 0;
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
/**
* Extract project name from a workspace URI (e.g. "file:///Users/x/myproject" → "myproject").
*/
function projectFromUri(uri) {
if (!uri) return null;
const parts = uri.replace(/\/$/, '').split('/');
return parts[parts.length - 1] || null;
}
/**
* List cascade IDs from .pb files in the conversations directory.
*/
function listCascades() {
try {
const files = readdirSync(CONVERSATIONS_DIR);
const results = [];
for (const f of files) {
if (!f.endsWith('.pb')) continue;
results.push(f.slice(0, -3)); // strip .pb → cascadeId
}
return results;
} catch {
return [];
}
}
// ── Main parse ───────────────────────────────────────────────────────
export async function parse() {
// Step 1: List cascade .pb files
const cascadeIds = listCascades();
if (cascadeIds.length === 0) return { buckets: [], sessions: [] };
// Step 2: Find a running language server to make RPC calls
const server = findLanguageServer();
if (!server) return { buckets: [], sessions: [] };
const ports = findListeningPorts(server.pid);
if (ports.length === 0) return { buckets: [], sessions: [] };
const baseUrl = await probeHttpPort(ports, server.csrfToken);
if (!baseUrl) return { buckets: [], sessions: [] };
const rpc = (method, body) =>
rpcPost(
baseUrl,
`/exa.language_server_pb.LanguageServerService/${method}`,
body,
server.csrfToken,
);
// Step 3: Fetch trajectory for each changed cascade
const entries = [];
const sessionEvents = [];
const seenResponseIds = new Set();
for (const cascadeId of cascadeIds) {
let resp;
try {
resp = await rpc('GetCascadeTrajectory', { cascadeId });
} catch {
continue; // skip this cascade if RPC fails
}
const trajectory = resp?.trajectory;
if (!trajectory) continue;
const steps = trajectory.steps || [];
const metadataList = trajectory.generatorMetadata || [];
// Extract project from trajectory metadata workspaces
let project = 'unknown';
const workspaces = trajectory.metadata?.workspaces || [];
if (workspaces.length > 0) {
project = workspaces[0].repository?.computedName || projectFromUri(workspaces[0].workspaceFolderAbsoluteUri) || 'unknown';
}
// ── Token entries from generatorMetadata ──
for (const meta of metadataList) {
const chatModel = meta?.chatModel;
if (!chatModel) continue;
const responseModel = resolveModel(chatModel);
const createdAt = chatModel?.chatStartMetadata?.createdAt;
const ts = createdAt ? new Date(createdAt) : null;
if (!ts || isNaN(ts.getTime())) continue;
const retryInfos = chatModel.retryInfos || [];
for (const retry of retryInfos) {
const usage = retry.usage;
if (!usage) continue;
const responseId = usage.responseId || '';
if (responseId && seenResponseIds.has(responseId)) continue;
if (responseId) seenResponseIds.add(responseId);
entries.push({
source: SOURCE,
model: responseModel,
project,
timestamp: ts,
inputTokens: toSafeNumber(usage.inputTokens),
outputTokens: toSafeNumber(usage.outputTokens),
cachedInputTokens: toSafeNumber(usage.cacheReadTokens),
reasoningOutputTokens: toSafeNumber(usage.thinkingOutputTokens),
});
}
}
// ── Session events from trajectory steps ──
for (const step of steps) {
const stepSource = step?.metadata?.source || '';
let role;
if (USER_SOURCES.has(stepSource)) {
role = 'user';
} else if (ASSISTANT_SOURCES.has(stepSource)) {
role = 'assistant';
} else {
continue; // skip SYSTEM / SYSTEM_SDK / UNSPECIFIED
}
const createdAt = step?.metadata?.createdAt;
const ts = createdAt ? new Date(createdAt) : null;
if (!ts || isNaN(ts.getTime())) continue;
sessionEvents.push({
sessionId: cascadeId,
source: SOURCE,
project,
timestamp: ts,
role,
});
}
}
return {
buckets: aggregateToBuckets(entries),
sessions: extractSessions(sessionEvents),
};
}
import { readdirSync, readFileSync, existsSync, realpathSync } from 'node:fs';
import { join, basename, sep } from 'node:path';
import { homedir } from 'node:os';
import { aggregateToBuckets, extractSessions } from './index.js';
/**
* Stateless Claude Code parser.
* Reads ALL *.jsonl files under <root>/projects/ and extracts per-message
* token usage from assistant messages. No state file needed — every sync
* computes the full bucket totals from raw data, making server-side
* ON CONFLICT ... DO UPDATE SET idempotent.
*
* Roots: always ~/.claude, plus $CLAUDE_CONFIG_DIR when set to a different
* path. Claude Code itself relocates its whole tree (incl. projects/) to
* $CLAUDE_CONFIG_DIR and uses only that dir — but a GUI launched from the
* Dock may not inherit the shell's env, so usage can be split across both
* roots. We scan both and dedup so neither source is missed or double-counted.
*/
/**
* Resolve the set of Claude config roots to scan.
* Always includes ~/.claude; adds $CLAUDE_CONFIG_DIR when set and it resolves
* to a different real path. Deduped by canonical path.
*/
function getClaudeRoots() {
const roots = [join(homedir(), '.claude')];
const cfg = process.env.CLAUDE_CONFIG_DIR?.trim();
if (cfg) {
let custom = cfg;
if (custom.startsWith('~')) custom = join(homedir(), custom.slice(1));
custom = custom.replace(/[/\\]+$/, '') || custom;
roots.push(custom);
}
// Dedup by canonical path (realpath when the dir exists, else the raw string).
const seen = new Set();
const unique = [];
for (const r of roots) {
let key = r;
try {
key = realpathSync(r);
} catch {
// dir may not exist yet — fall back to the literal path
}
if (seen.has(key)) continue;
seen.add(key);
unique.push(r);
}
return unique;
}
/**
* Recursively find all .jsonl files under a directory.
* Claude Code stores sessions in two layouts:
* 2-layer: projects/{projectPath}/{sessionId}.jsonl
* 3-layer: projects/{projectPath}/{sessionId}/subagents/agent-*.jsonl
*/
function findJsonlFiles(dir) {
const results = [];
if (!existsSync(dir)) return results;
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...findJsonlFiles(fullPath));
} else if (entry.name.endsWith('.jsonl')) {
results.push(fullPath);
}
}
} catch {
// ignore unreadable directories
}
return results;
}
/**
* Path of a project file relative to its root's projects/ dir, e.g.
* "<root>/projects/-Users-foo-app/abc.jsonl" → "-Users-foo-app/abc.jsonl".
* Used both for project-name extraction and cross-root dedup.
*/
function projectRelativePath(filePath, projectsDir) {
const prefix = projectsDir + sep;
return filePath.startsWith(prefix) ? filePath.slice(prefix.length) : null;
}
/**
* Extract project name from a projects-relative path.
* The first segment is the dash-encoded project path (e.g. -Users-foo-myproject);
* we take its last component as the project name.
*/
function extractProject(relative) {
if (!relative) return 'unknown';
const firstSeg = relative.split(sep)[0];
if (!firstSeg) return 'unknown';
const parts = firstSeg.split('-').filter(Boolean);
return parts.length > 0 ? parts[parts.length - 1] : 'unknown';
}
function extractSessionId(filePath) {
return basename(filePath, '.jsonl');
}
/**
* Scan one root's projects/ dir → token entries + session events (mutates ctx).
*/
function scanProjectsRoot(root, ctx) {
const projectsDir = join(root, 'projects');
for (const filePath of findJsonlFiles(projectsDir)) {
const relative = projectRelativePath(filePath, projectsDir);
// Same session present under two roots (e.g. data copied between them):
// process it once so session message counts aren't inflated.
if (relative !== null) {
if (ctx.seenProjectFiles.has(relative)) continue;
ctx.seenProjectFiles.add(relative);
}
let content;
try {
content = readFileSync(filePath, 'utf-8');
} catch {
continue;
}
const project = extractProject(relative);
const sessionId = extractSessionId(filePath);
ctx.seenSessionIds.add(sessionId);
for (const line of content.split('\n')) {
if (!line.trim()) continue;
try {
const obj = JSON.parse(line);
const timestamp = obj.timestamp;
if (!timestamp) continue;
const ts = new Date(timestamp);
if (isNaN(ts.getTime())) continue;
if (obj.type === 'user' || obj.type === 'assistant' || obj.type === 'tool_use' || obj.type === 'tool_result') {
ctx.sessionEvents.push({
sessionId,
source: 'claude-code',
project,
timestamp: ts,
role: obj.type === 'user' ? 'user' : 'assistant',
});
}
if (obj.type !== 'assistant') continue;
const msg = obj.message;
if (!msg || !msg.usage) continue;
const usage = msg.usage;
if (usage.input_tokens == null && usage.output_tokens == null) continue;
const uuid = obj.uuid;
if (uuid) {
if (ctx.seenUuids.has(uuid)) continue;
ctx.seenUuids.add(uuid);
}
ctx.entries.push({
source: 'claude-code',
model: msg.model || 'unknown',
project,
timestamp: ts,
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cachedInputTokens: usage.cache_read_input_tokens || 0,
reasoningOutputTokens: 0,
});
} catch {
continue;
}
}
}
}
/**
* Scan one root's transcripts/ dir → session events only (no token data).
* Skips sessions already covered by a projects/ or transcripts/ scan.
*/
function scanTranscriptsRoot(root, ctx) {
for (const filePath of findJsonlFiles(join(root, 'transcripts'))) {
const sessionId = extractSessionId(filePath);
if (ctx.seenSessionIds.has(sessionId)) continue;
ctx.seenSessionIds.add(sessionId);
let content;
try {
content = readFileSync(filePath, 'utf-8');
} catch {
continue;
}
for (const line of content.split('\n')) {
if (!line.trim()) continue;
try {
const obj = JSON.parse(line);
const timestamp = obj.timestamp;
if (!timestamp) continue;
const ts = new Date(timestamp);
if (isNaN(ts.getTime())) continue;
if (obj.type === 'user' || obj.type === 'assistant' || obj.type === 'tool_use' || obj.type === 'tool_result') {
ctx.sessionEvents.push({
sessionId,
source: 'claude-code',
project: 'unknown',
timestamp: ts,
role: obj.type === 'user' ? 'user' : 'assistant',
});
}
} catch {
continue;
}
}
}
}
export async function parse() {
const ctx = {
entries: [],
sessionEvents: [],
seenUuids: new Set(),
seenSessionIds: new Set(),
seenProjectFiles: new Set(), // projects-relative path → dedup same session across roots
};
const roots = getClaudeRoots();
// projects/ yields BOTH token buckets and session events.
for (const root of roots) scanProjectsRoot(root, ctx);
// transcripts/ yields session events only, for sessions not already covered.
for (const root of roots) scanTranscriptsRoot(root, ctx);
return {
buckets: aggregateToBuckets(ctx.entries),
sessions: extractSessions(ctx.sessionEvents),
};
}
import { readFileSync, statSync } from 'node:fs';
import { basename, join } from 'node:path';
import { homedir } from 'node:os';
import { aggregateToBuckets, extractSessions } from './index.js';
const EXTENSION_ID = 'saoudrizwan.claude-dev';
// VSCode-fork application names that may host extensions.
const HOSTS = ['Code', 'Cursor', 'Windsurf', 'VSCodium', 'Code - Insiders', 'Trae', 'Trae CN'];
function getHostRoots() {
const out = [];
if (process.platform === 'darwin') {
const base = join(homedir(), 'Library', 'Application Support');
for (const h of HOSTS) out.push(join(base, h));
} else if (process.platform === 'win32') {
const appData = process.env.APPDATA?.trim() || join(homedir(), 'AppData', 'Roaming');
for (const h of HOSTS) out.push(join(appData, h));
} else {
const xdg = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), '.config');
for (const h of HOSTS) out.push(join(xdg, h));
}
return out;
}
export function findClineExtensionDirs() {
const dirs = [];
for (const root of getHostRoots()) {
const ext = join(root, 'User', 'globalStorage', EXTENSION_ID);
try {
if (statSync(ext).isDirectory()) dirs.push(ext);
} catch {
// not installed in this host; skip
}
}
return dirs;
}
function readJsonSafe(path) {
try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; }
}
function projectFromPath(absPath) {
if (!absPath || typeof absPath !== 'string') return 'unknown';
const trimmed = absPath.replace(/[\\/]+$/, '');
const name = basename(trimmed);
return name || 'unknown';
}
export async function parse() {
const extDirs = findClineExtensionDirs();
if (extDirs.length === 0) return { buckets: [], sessions: [] };
const entries = [];
const events = [];
for (const extDir of extDirs) {
const history = readJsonSafe(join(extDir, 'state', 'taskHistory.json'));
if (!Array.isArray(history)) continue;
for (const item of history) {
try {
if (!item || typeof item !== 'object' || !item.id) continue;
const taskId = String(item.id);
const project = projectFromPath(item.cwdOnTaskInitialization || item.shadowGitConfigWorkTree);
const fallbackModel = (item.modelId && String(item.modelId).trim()) || 'cline-unknown';
const messages = readJsonSafe(join(extDir, 'tasks', taskId, 'ui_messages.json'));
if (!Array.isArray(messages)) continue;
for (const msg of messages) {
if (!msg || typeof msg !== 'object') continue;
const ts = Number(msg.ts);
if (!Number.isFinite(ts)) continue;
const timestamp = new Date(ts);
if (msg.type === 'say' && msg.say === 'api_req_started') {
let info = null;
try { info = JSON.parse(msg.text); } catch { /* skip */ }
if (!info) continue;
const inputTokens = Math.max(0, Number(info.tokensIn) || 0);
const outputTokens = Math.max(0, Number(info.tokensOut) || 0);
const cacheWrites = Math.max(0, Number(info.cacheWrites) || 0);
const cacheReads = Math.max(0, Number(info.cacheReads) || 0);
if (inputTokens + outputTokens + cacheWrites + cacheReads === 0) continue;
// Newer Cline embeds the model id directly on the api_req_started payload.
const model = (info.model && String(info.model).trim()) || fallbackModel;
// Bucket schema (matches Cursor's CSV semantics):
// inputTokens = non-cache input + cache-write tokens (both billed as input)
// cachedInputTokens = cache-read tokens (10% input rate)
entries.push({
source: 'cline',
model,
project,
timestamp,
inputTokens: inputTokens + cacheWrites,
outputTokens,
cachedInputTokens: cacheReads,
reasoningOutputTokens: 0,
});
events.push({ sessionId: taskId, source: 'cline', project, timestamp, role: 'assistant' });
} else if (msg.type === 'ask' || (msg.type === 'say' && msg.say === 'user_feedback')) {
events.push({ sessionId: taskId, source: 'cline', project, timestamp, role: 'user' });
}
}
} catch {
// Skip this task; keep going for the rest of the history.
}
}
}
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(events) };
}
import { createReadStream, readdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { createInterface } from 'node:readline';
import { aggregateToBuckets, extractSessions } from './index.js';
// Codex stores live sessions in ~/.codex/sessions and, once a session is
// "completed", moves its rollout file verbatim into ~/.codex/archived_sessions.
// A session can be archived between two syncs, so scanning only the live dir
// loses that session's usage forever. We scan both: the parser is stateless
// and the server dedups on (source, sessionHash/bucket), so re-reading an
// archived file that was already synced from sessions/ is idempotent. Indexing
// both together also keeps fork replay-skip correct when a fork and its parent
// end up split across the two directories.
const SESSIONS_DIRS = [
join(homedir(), '.codex', 'sessions'),
join(homedir(), '.codex', 'archived_sessions'),
];
/**
* Recursively find all .jsonl files under a directory.
* Codex CLI stores sessions as: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
*/
function findJsonlFiles(dir) {
const results = [];
if (!existsSync(dir)) return results;
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...findJsonlFiles(fullPath));
} else if (entry.name.endsWith('.jsonl')) {
results.push(fullPath);
}
}
} catch {
// ignore unreadable directories
}
return results;
}
function readLines(filePath) {
return createInterface({
input: createReadStream(filePath, { encoding: 'utf-8' }),
crlfDelay: Infinity,
});
}
function extractProject(meta) {
if (meta.git?.repository_url) {
// e.g. https://github.com/org/repo.git → org/repo
const match = meta.git.repository_url.match(/([^/]+\/[^/]+?)(?:\.git)?$/);
if (match) return match[1];
}
if (meta.cwd) return meta.cwd.split('/').pop() || 'unknown';
return 'unknown';
}
/**
* Stream a session file once and extract its index metadata: the session
* id, the forked-from id, the project name, and the total count of
* `event_msg/token_count` records. The token_count total is used to size
* the replayed-history block of a forked session — a fork copies the
* original conversation verbatim, so it begins with exactly as many
* token_count records as the source session has in total.
*/
async function indexSessionFile(filePath) {
let sessionId = null;
let forkedFromId = null;
let sessionProject = 'unknown';
let tokenCountRecords = 0;
for await (const line of readLines(filePath)) {
if (!line.trim()) continue;
try {
const obj = JSON.parse(line);
if (obj.type === 'session_meta' && obj.payload) {
const meta = obj.payload;
sessionId = meta.id || sessionId;
forkedFromId = meta.forked_from_id || null;
sessionProject = extractProject(meta);
} else if (obj.type === 'event_msg' && obj.payload?.type === 'token_count') {
tokenCountRecords++;
}
} catch {
continue;
}
}
return { sessionId, forkedFromId, sessionProject, tokenCountRecords };
}
export async function parse() {
if (!SESSIONS_DIRS.some(existsSync)) return { buckets: [], sessions: [] };
const entries = [];
const sessionEvents = [];
const files = SESSIONS_DIRS.flatMap(findJsonlFiles);
if (files.length === 0) return { buckets: [], sessions: [] };
// Pass 1: index every session by its UUID and count its token_count
// records. A forked session (session_meta.payload.forked_from_id) starts
// with the original conversation replayed verbatim — including every
// token_count, all timestamped in a burst at the fork instant. Those
// tokens are already counted from the original session's own file, so
// re-counting them here double-counts usage and produces a spurious
// token/cost spike at the fork time. Timestamps cannot distinguish the
// replay from new activity (the replay burst is stamped at/after the fork
// instant, within the same 1–3s window), so we instead skip exactly the
// original session's token_count count from the start of each fork.
const tokenCountById = new Map(); // sessionId → number of token_count records
const fileMeta = new Map(); // filePath -> { forkedFromId, sessionProject }
for (const filePath of files) {
let meta;
try {
meta = await indexSessionFile(filePath);
} catch {
continue;
}
fileMeta.set(filePath, meta);
if (meta.sessionId) {
tokenCountById.set(meta.sessionId, meta.tokenCountRecords);
}
}
// Pass 2: parse usage, skipping each fork's replayed-history token_counts.
for (const filePath of files) {
const fm = fileMeta.get(filePath);
if (!fm) continue;
const { forkedFromId } = fm;
// How many leading token_count records are copied history. A fork's file
// begins with the *entire* source file replayed verbatim, so the count
// to skip is the source's total token_count count. This is correct even
// for chained forks: a fork-of-a-fork replays the parent fork's whole
// file (which itself already contains the grandparent's replay), so
// skipping the parent's full count skips exactly the duplicated region.
// If the source file is missing (rotated/deleted) we cannot locate the
// boundary; skip nothing so incomplete data over-counts rather than
// silently dropping real usage.
let replayTokenCountToSkip = 0;
if (forkedFromId != null) {
replayTokenCountToSkip = tokenCountById.get(forkedFromId) ?? 0;
}
let tokenCountSeen = 0;
const sessionProject = fm.sessionProject;
// Group timing events by the real Codex session id, not the file path: the
// same session can briefly exist in both sessions/ and archived_sessions/
// (mid-archive, or a re-synced archive). Path-keyed grouping would emit it
// as two different sessionHashes and double-count its session stats. Fall
// back to the path only when the id is unknown (corrupt/missing meta).
const sessionKey = fm.sessionId || filePath;
let turnContextModel = 'unknown';
const prevTotal = new Map();
for await (const line of readLines(filePath)) {
if (!line.trim()) continue;
try {
const obj = JSON.parse(line);
// A fork's replayed-history block is the run from the start of the
// file up to and including the Nth token_count, where N is the source
// session's total token_count count. We are still inside that block
// until we have *passed* the Nth token_count. (token_count is the
// last event of each turn, so the boundary lands cleanly at a turn
// edge — the new conversation's events come strictly after it.)
const inReplayBlock = tokenCountSeen < replayTokenCountToSkip;
if (obj.timestamp) {
const evTs = new Date(obj.timestamp);
if (!isNaN(evTs.getTime())) {
// Skip replayed history events so a forked session's
// duration/active-time/message counts reflect only the new
// conversation, not the copied original. session_meta itself is
// kept: it marks when the fork actually started.
const isReplay = inReplayBlock && obj.type !== 'session_meta';
if (!isReplay) {
const isUserTurn = obj.type === 'turn_context' || obj.type === 'session_meta';
sessionEvents.push({
sessionId: sessionKey,
source: 'codex',
project: sessionProject,
timestamp: evTs,
role: isUserTurn ? 'user' : 'assistant',
});
}
}
}
if (obj.type === 'turn_context' && obj.payload?.model) {
turnContextModel = obj.payload.model;
continue;
}
if (obj.type !== 'event_msg') continue;
const payload = obj.payload;
if (!payload) continue;
if (payload.type !== 'token_count') continue;
const info = payload.info;
if (!info) continue;
const timestamp = obj.timestamp ? new Date(obj.timestamp) : null;
if (!timestamp || isNaN(timestamp.getTime())) continue;
// This is the (tokenCountSeen+1)-th token_count in the file. If it
// falls inside the fork's replay block it's an exact copy of a record
// already counted from the source session's own file — skip it (but
// still advance the cumulative-total baseline below so the first real
// post-fork delta is measured correctly).
const isReplayedHistory = tokenCountSeen < replayTokenCountToSkip;
tokenCountSeen++;
// Prefer incremental per-request usage; compute delta from cumulative total as fallback
let usage = info.last_token_usage;
if (!usage && info.total_token_usage) {
const totalKey = `${info.model || payload.model || turnContextModel || ''}`;
const prev = prevTotal.get(totalKey);
const curr = info.total_token_usage;
if (prev) {
usage = {
input_tokens: (curr.input_tokens || 0) - (prev.input_tokens || 0),
output_tokens: (curr.output_tokens || 0) - (prev.output_tokens || 0),
cached_input_tokens: (curr.cached_input_tokens || 0) - (prev.cached_input_tokens || 0),
reasoning_output_tokens: (curr.reasoning_output_tokens || 0) - (prev.reasoning_output_tokens || 0),
};
} else {
// First cumulative entry — use as-is (it's the first event's total)
usage = curr;
}
// Always advance the cumulative baseline, even for replayed history,
// so the first real post-fork delta is measured against the last
// replayed total instead of being mistaken for a fresh "first entry".
prevTotal.set(totalKey, { ...curr });
}
if (!usage) continue;
if (isReplayedHistory) continue;
const model = info.model || payload.model || turnContextModel || 'unknown';
// OpenAI API: input_tokens INCLUDES cached, output_tokens INCLUDES reasoning.
// Normalize to Anthropic-style semantics where each field is non-overlapping.
const cachedInput = usage.cached_input_tokens || usage.cache_read_input_tokens || 0;
const reasoningOutput = usage.reasoning_output_tokens || 0;
entries.push({
source: 'codex',
model,
project: sessionProject,
timestamp,
inputTokens: (usage.input_tokens || 0) - cachedInput,
outputTokens: (usage.output_tokens || 0) - reasoningOutput,
cachedInputTokens: cachedInput,
reasoningOutputTokens: reasoningOutput,
});
} catch {
continue;
}
}
}
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
}
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { basename, join } from 'node:path';
import { homedir } from 'node:os';
import { aggregateToBuckets, extractSessions } from './index.js';
const SESSION_STATE_DIR = join(homedir(), '.copilot', 'session-state');
function findEventFiles(baseDir) {
const results = [];
if (!existsSync(baseDir)) return results;
try {
for (const entry of readdirSync(baseDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const eventsFile = join(baseDir, entry.name, 'events.jsonl');
if (existsSync(eventsFile)) {
results.push({ filePath: eventsFile, sessionId: entry.name });
}
}
} catch {
return results;
}
return results;
}
function getProjectFromContext(context) {
const projectPath = context?.gitRoot || context?.cwd;
if (!projectPath) return 'unknown';
return basename(projectPath) || 'unknown';
}
/**
* Parse GitHub Copilot CLI session logs from ~/.copilot/session-state.
* Returns usage buckets from session shutdown summaries and session metadata
* from user/assistant message timings.
*/
export async function parse() {
const eventFiles = findEventFiles(SESSION_STATE_DIR);
if (eventFiles.length === 0) return { buckets: [], sessions: [] };
const entries = [];
const sessionEvents = [];
for (const { filePath, sessionId } of eventFiles) {
let content;
try {
content = readFileSync(filePath, 'utf-8');
} catch {
continue;
}
let currentProject = 'unknown';
for (const line of content.split('\n')) {
if (!line.trim()) continue;
try {
const obj = JSON.parse(line);
const timestamp = obj.timestamp ? new Date(obj.timestamp) : null;
const hasTimestamp = timestamp && !isNaN(timestamp.getTime());
if (obj.type === 'session.start' || obj.type === 'session.resume') {
currentProject = getProjectFromContext(obj.data?.context);
}
if (hasTimestamp && obj.type === 'user.message') {
sessionEvents.push({
sessionId,
source: 'copilot-cli',
project: currentProject,
timestamp,
role: 'user',
});
}
if (hasTimestamp && obj.type === 'assistant.message') {
sessionEvents.push({
sessionId,
source: 'copilot-cli',
project: currentProject,
timestamp,
role: 'assistant',
});
}
if (obj.type !== 'session.shutdown' || !hasTimestamp) continue;
const modelMetrics = obj.data?.modelMetrics || {};
for (const [model, metrics] of Object.entries(modelMetrics)) {
const usage = metrics?.usage;
if (!usage) continue;
const totalInput = usage.inputTokens || 0;
const cachedRead = usage.cacheReadTokens || 0;
const cacheWrite = usage.cacheWriteTokens || 0;
const output = usage.outputTokens || 0;
if (totalInput === 0 && cachedRead === 0 && cacheWrite === 0 && output === 0) {
continue;
}
entries.push({
source: 'copilot-cli',
model,
project: currentProject,
timestamp,
// Copilot reports cache reads separately, but cache writes are part of
// regular input for this schema because buckets don't have a dedicated field.
inputTokens: Math.max(0, totalInput - cachedRead),
outputTokens: output,
cachedInputTokens: cachedRead,
reasoningOutputTokens: 0,
});
}
} catch {
continue;
}
}
}
return {
buckets: aggregateToBuckets(entries),
sessions: extractSessions(sessionEvents),
};
}
import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { homedir, tmpdir } from 'node:os';
import { aggregateToBuckets } from './index.js';
import { queryDbJson } from './sqlite.js';
const STATE_DB_RELATIVE = join('User', 'globalStorage', 'state.vscdb');
const ACCESS_TOKEN_KEY = 'cursorAuth/accessToken';
const SESSION_COOKIE = 'WorkosCursorSessionToken';
function getDefaultStateDbPath() {
if (process.platform === 'darwin') {
return join(homedir(), 'Library', 'Application Support', 'Cursor', STATE_DB_RELATIVE);
}
if (process.platform === 'win32') {
const appData = process.env.APPDATA?.trim() || join(homedir(), 'AppData', 'Roaming');
return join(appData, 'Cursor', STATE_DB_RELATIVE);
}
const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), '.config');
return join(xdgConfigHome, 'Cursor', STATE_DB_RELATIVE);
}
export function getCursorStateDbPath() {
const explicit = process.env.CURSOR_STATE_DB_PATH?.trim();
if (explicit) {
const resolved = resolve(explicit);
return existsSync(resolved) ? resolved : null;
}
const configDirs = process.env.CURSOR_CONFIG_DIR?.trim();
const candidates = configDirs
? configDirs.split(',').map(v => v.trim()).filter(Boolean).map(v => {
const r = resolve(v);
return r.endsWith('.vscdb') ? r : join(r, STATE_DB_RELATIVE);
})
: [getDefaultStateDbPath()];
for (const c of candidates) {
if (existsSync(c)) return c;
}
return null;
}
function readAccessToken(dbPath) {
let snapshotDir = null;
let queryPath = dbPath;
try {
return queryAccessToken(queryPath);
} catch (err) {
// Cursor app holds a write lock; copy WAL set to a temp dir and retry
if (!isLockError(err)) throw err;
snapshotDir = mkdtempSync(join(tmpdir(), 'vibe-usage-cursor-'));
queryPath = join(snapshotDir, 'state.vscdb');
copyFileSync(dbPath, queryPath);
for (const suffix of ['-shm', '-wal']) {
const companion = `${dbPath}${suffix}`;
if (existsSync(companion)) copyFileSync(companion, `${queryPath}${suffix}`);
}
try {
return queryAccessToken(queryPath);
} finally {
rmSync(snapshotDir, { recursive: true, force: true });
}
}
}
function queryAccessToken(dbPath) {
const sql = `SELECT value FROM ItemTable WHERE key = '${ACCESS_TOKEN_KEY}' LIMIT 1`;
const rows = queryDbJson(dbPath, sql, { maxBuffer: 4 * 1024 * 1024, timeout: 15000 });
const value = rows[0]?.value;
if (typeof value !== 'string') return null;
const t = value.trim();
return t || null;
}
function isLockError(err) {
return err && typeof err.message === 'string' && /database is locked/i.test(err.message);
}
function decodeJwtSub(token) {
const payload = token.split('.')[1];
if (!payload) return null;
try {
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/');
const padded = b64.padEnd(Math.ceil(b64.length / 4) * 4, '=');
const json = JSON.parse(Buffer.from(padded, 'base64').toString('utf-8'));
return typeof json.sub === 'string' ? json.sub.trim() : null;
} catch {
return null;
}
}
const FETCH_TIMEOUT_MS = 10_000;
async function fetchUsageCsv(token) {
const url = `${(process.env.CURSOR_WEB_BASE_URL?.trim() || 'https://cursor.com').replace(/\/+$/, '')}/api/dashboard/export-usage-events-csv?strategy=tokens`;
const sub = decodeJwtSub(token);
const cookieValues = sub ? [token, `${sub}::${token}`] : [token];
const attempts = [{ Authorization: `Bearer ${token}` }];
for (const cv of cookieValues) {
attempts.push({ Cookie: `${SESSION_COOKIE}=${cv}` });
attempts.push({ Authorization: `Bearer ${token}`, Cookie: `${SESSION_COOKIE}=${cv}` });
}
const failures = [];
for (const headers of attempts) {
let resp;
try {
resp = await fetch(url, {
headers: { Accept: 'text/csv,*/*;q=0.8', ...headers },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
} catch (e) {
// Hard-fail on network/timeout: stop trying further headers (won't fix
// a downed host) and signal a soft skip to the caller.
const reason = e.name === 'TimeoutError' ? 'timeout' : `network: ${e.message}`;
const err = new Error(`Cursor usage export skipped (${reason})`);
err.skip = true;
throw err;
}
if (resp.ok) return await resp.text();
failures.push(`${resp.status} ${resp.statusText}`);
}
// All auth combos rejected — token is likely expired. Surface to user.
throw new Error(`Cursor usage export auth failed (${failures.join('; ')})`);
}
function parseCsv(text) {
const rows = [];
let field = '';
let row = [];
let inQuotes = false;
let i = 0;
while (i < text.length) {
const c = text[i];
if (inQuotes) {
if (c === '"') {
if (text[i + 1] === '"') { field += '"'; i += 2; continue; }
inQuotes = false; i++; continue;
}
field += c; i++; continue;
}
if (c === '"') { inQuotes = true; i++; continue; }
if (c === ',') { row.push(field); field = ''; i++; continue; }
if (c === '\r') { i++; continue; }
if (c === '\n') { row.push(field); rows.push(row); field = ''; row = []; i++; continue; }
field += c; i++;
}
if (field !== '' || row.length > 0) { row.push(field); rows.push(row); }
return rows;
}
function parseDate(value) {
if (!value) return null;
const t = String(value).trim();
if (!t) return null;
if (/^\d{4}-\d{2}-\d{2}$/.test(t)) return new Date(`${t}T00:00:00Z`);
const d = new Date(t);
return isNaN(d.getTime()) ? null : d;
}
function parseInt0(value) {
if (value == null) return 0;
const n = Number(String(value).replace(/,/g, '').trim());
return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
}
export async function parse() {
const dbPath = getCursorStateDbPath();
if (!dbPath) return { buckets: [], sessions: [] };
let token;
try {
token = readAccessToken(dbPath);
} catch (err) {
if (err && typeof err.message === 'string' && err.message.includes('ENOENT')) {
throw new Error('sqlite3 CLI not found. Install sqlite3 (or use Node >= 22.5) to sync Cursor data.');
}
throw err;
}
if (!token) return { buckets: [], sessions: [] };
let csv;
try {
csv = await fetchUsageCsv(token);
} catch (err) {
// Network/timeout → silent skip (avoid noisy daemon logs every 5 min).
// Auth failure → bubble up so user sees they need to re-login in Cursor.
if (err && err.skip) return { buckets: [], sessions: [] };
throw err;
}
const rows = parseCsv(csv);
if (rows.length < 2) return { buckets: [], sessions: [] };
const header = rows[0].map(h => h.trim());
const idx = (name) => header.indexOf(name);
const dateIdx = idx('Date');
const modelIdx = idx('Model');
const inputCacheWriteIdx = idx('Input (w/ Cache Write)');
const inputNoCacheIdx = idx('Input (w/o Cache Write)');
const cacheReadIdx = idx('Cache Read');
const outputIdx = idx('Output Tokens');
if (dateIdx < 0 || modelIdx < 0) return { buckets: [], sessions: [] };
const entries = [];
for (let r = 1; r < rows.length; r++) {
const row = rows[r];
if (row.length === 1 && row[0].trim() === '') continue;
const timestamp = parseDate(row[dateIdx]);
const model = row[modelIdx]?.trim();
if (!timestamp || !model) continue;
const inputCacheWrite = inputCacheWriteIdx >= 0 ? parseInt0(row[inputCacheWriteIdx]) : 0;
const inputNoCache = inputNoCacheIdx >= 0 ? parseInt0(row[inputNoCacheIdx]) : 0;
const cacheRead = cacheReadIdx >= 0 ? parseInt0(row[cacheReadIdx]) : 0;
const output = outputIdx >= 0 ? parseInt0(row[outputIdx]) : 0;
if (inputCacheWrite + inputNoCache + cacheRead + output === 0) continue;
entries.push({
source: 'cursor',
model,
project: 'unknown',
// Cursor usage is pulled from the cloud API — it reflects the same account
// data on every machine. Use a fixed sentinel so all machines share one row
// per (model, bucket_start) rather than duplicating per hostname.
hostname: 'cursor-cloud',
timestamp,
inputTokens: inputCacheWrite + inputNoCache,
outputTokens: output,
cachedInputTokens: cacheRead,
reasoningOutputTokens: 0,
});
}
return { buckets: aggregateToBuckets(entries), sessions: [] };
}
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { join, basename, dirname } from 'node:path';
import { homedir } from 'node:os';
import { aggregateToBuckets, extractSessions } from './index.js';
const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions');
function findJsonlFiles(dir) {
const results = [];
if (!existsSync(dir)) return results;
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...findJsonlFiles(fullPath));
} else if (entry.name.endsWith('.jsonl') && !entry.name.endsWith('.settings.json')) {
results.push(fullPath);
}
}
} catch {
}
return results;
}
function extractProjectFromSlug(slug) {
const parts = slug.split('-').filter(Boolean);
return parts.length > 0 ? parts[parts.length - 1] : 'unknown';
}
function toSafeNumber(value) {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
export async function parse() {
const entries = [];
const sessionEvents = [];
const sessionFiles = findJsonlFiles(DROID_SESSIONS_DIR);
for (const filePath of sessionFiles) {
const sessionId = basename(filePath, '.jsonl');
const slug = basename(dirname(filePath));
const project = extractProjectFromSlug(slug);
let firstMessageTimestamp = null;
let content;
try {
content = readFileSync(filePath, 'utf-8');
} catch {
continue;
}
for (const line of content.split('\n')) {
if (!line.trim()) continue;
let obj;
try {
obj = JSON.parse(line);
} catch {
continue;
}
if (obj.type !== 'message') continue;
if (!obj.timestamp) continue;
const ts = new Date(obj.timestamp);
if (isNaN(ts.getTime())) continue;
if (firstMessageTimestamp === null) firstMessageTimestamp = ts;
sessionEvents.push({
sessionId,
source: 'droid',
project,
timestamp: ts,
role: obj.message?.role === 'user' ? 'user' : 'assistant',
});
}
const settingsPath = join(dirname(filePath), `${sessionId}.settings.json`);
if (!existsSync(settingsPath) || firstMessageTimestamp === null) continue;
let settings;
try {
settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
} catch {
continue;
}
const tokenUsage = settings?.tokenUsage;
if (!tokenUsage) continue;
const cacheReadTokens = toSafeNumber(tokenUsage.cacheReadTokens);
const thinkingTokens = toSafeNumber(tokenUsage.thinkingTokens);
const inputTokens = Math.max(0, toSafeNumber(tokenUsage.inputTokens) - cacheReadTokens);
const outputTokens = Math.max(0, toSafeNumber(tokenUsage.outputTokens) - thinkingTokens);
entries.push({
source: 'droid',
model: settings.model || 'unknown',
project,
timestamp: firstMessageTimestamp,
inputTokens,
outputTokens,
cachedInputTokens: cacheReadTokens,
reasoningOutputTokens: thinkingTokens,
});
}
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
}
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { join, basename } from 'node:path';
import { homedir } from 'node:os';
import { aggregateToBuckets, extractSessions } from './index.js';
const TMP_DIR = join(homedir(), '.gemini', 'tmp');
// Gemini CLI session storage:
// ~/.gemini/tmp/<project_hash>/chats/session-<ts>-<id>.jsonl (current, v0.39+)
// ~/.gemini/tmp/<project_hash>/chats/session-<ts>-<id>.json (legacy, single JSON object)
// ~/.gemini/tmp/<project_hash>/chats/<parent_id>/<sub_id>.jsonl (subagent sessions, nested)
// The .jsonl migration (PR #23749, ~v0.39.0) made the old .json-only glob miss every new
// session — collect both extensions, and recurse one level for nested subagent files.
/**
* Walk each project's chats/ directory and collect every session file
* (both .json and .jsonl), descending into subagent subdirectories.
*/
function findSessionFiles(baseDir) {
const results = [];
if (!existsSync(baseDir)) return results;
let projectDirs;
try {
projectDirs = readdirSync(baseDir, { withFileTypes: true });
} catch {
return results;
}
for (const entry of projectDirs) {
if (!entry.isDirectory()) continue;
collectChatFiles(join(baseDir, entry.name, 'chats'), results, 0);
}
return results;
}
function collectChatFiles(dir, out, depth) {
if (depth > 2) return; // chats/ + nested subagent dirs is as deep as it goes
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const full = join(dir, e.name);
if (e.isDirectory()) {
collectChatFiles(full, out, depth + 1);
} else if (e.name.endsWith('.jsonl') || e.name.endsWith('.json')) {
out.push(full);
}
}
}
/**
* Read a session file into a uniform { messages, directories } shape.
* .jsonl: line 1 is session metadata, each following line is one record.
* .json: a single ConversationRecord object with a messages[] array.
*/
function readRecords(filePath) {
let raw;
try {
raw = readFileSync(filePath, 'utf-8');
} catch {
return null;
}
if (filePath.endsWith('.jsonl')) {
const messages = [];
let directories = null;
for (const line of raw.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
let obj;
try {
obj = JSON.parse(trimmed);
} catch {
continue;
}
// The metadata line carries directories; message lines carry a `type`.
if (!directories && Array.isArray(obj.directories)) directories = obj.directories;
if (typeof obj.type === 'string' || typeof obj.role === 'string') messages.push(obj);
}
return { messages, directories };
}
let data;
try {
data = JSON.parse(raw);
} catch {
return null;
}
return {
messages: data.messages || data.history || [],
directories: Array.isArray(data.directories) ? data.directories : null,
};
}
// Model/assistant messages are recorded as type 'gemini'; user turns as 'user'.
// info/error/warning are system noise and skipped. `role` is accepted as a
// fallback for any older format that used it.
function classifyRole(msg) {
const t = msg.type ?? msg.role;
if (t === 'user') return 'user';
if (t === 'gemini' || t === 'model' || t === 'assistant') return 'assistant';
return null;
}
// Tokens live in msg.tokens.{input,output,cached,thoughts} (TokensSummary, where
// `input` already includes cached). Fall back to the raw Gemini API usageMetadata
// shape for any legacy record that stored it.
function extractTokens(msg) {
const t = msg.tokens;
if (t) {
const cached = t.cached || 0;
const thoughts = t.thoughts || 0;
return {
inputTokens: (t.input || 0) - cached,
outputTokens: (t.output || 0) - thoughts,
cachedInputTokens: cached,
reasoningOutputTokens: thoughts,
};
}
const u = msg.usageMetadata || msg.usage;
if (u) {
const cached = u.cachedContentTokenCount || 0;
const thoughts = u.thoughtsTokenCount || 0;
return {
inputTokens: (u.promptTokenCount || u.input_tokens || 0) - cached,
outputTokens: (u.candidatesTokenCount || u.output_tokens || 0) - thoughts,
cachedInputTokens: cached,
reasoningOutputTokens: thoughts,
};
}
return null;
}
function projectFromDirectories(directories) {
if (!directories || directories.length === 0) return 'unknown';
const first = directories[0];
if (!first) return 'unknown';
return basename(String(first).replace(/[\\/]+$/, '')) || 'unknown';
}
export async function parse() {
const sessionFiles = findSessionFiles(TMP_DIR);
if (sessionFiles.length === 0) return { buckets: [], sessions: [] };
const entries = [];
const sessionEvents = [];
for (const filePath of sessionFiles) {
const record = readRecords(filePath);
if (!record) continue;
const project = projectFromDirectories(record.directories);
for (const msg of record.messages) {
const role = classifyRole(msg);
if (!role) continue;
const stamp = msg.timestamp || msg.createTime;
if (!stamp) continue;
const ts = new Date(stamp);
if (isNaN(ts.getTime())) continue;
sessionEvents.push({
sessionId: filePath,
source: 'gemini-cli',
project,
timestamp: ts,
role,
});
if (role !== 'assistant') continue;
const tokens = extractTokens(msg);
if (!tokens) continue;
entries.push({
source: 'gemini-cli',
model: msg.model || 'unknown',
project,
timestamp: ts,
...tokens,
});
}
}
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
}
import { existsSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { aggregateToBuckets, extractSessions } from './index.js';
import { queryDbJson } from './sqlite.js';
const HERMES_HOME = process.env.HERMES_HOME || join(homedir(), '.hermes');
/**
* Parse Hermes Agent usage data from its SQLite databases.
*
* Hermes supports multiple profiles — the default profile lives at
* ~/.hermes/state.db, while named profiles live at ~/.hermes/profiles/<name>/state.db.
* Each profile is an independent HERMES_HOME with its own state.db, so we scan all of them.
*
* Token buckets come from the sessions table (cumulative per-session totals).
* Session timing comes from the messages table (per-message role + timestamp).
*/
export async function parse() {
const dbs = discoverDbPaths(HERMES_HOME);
if (dbs.length === 0) return { buckets: [], sessions: [] };
const entries = [];
const sessionEvents = [];
for (const { path: dbPath, profile } of dbs) {
let sessionRows;
try {
sessionRows = queryDb(dbPath, `SELECT
id,
model,
started_at as startedAt,
input_tokens as inputTokens,
output_tokens as outputTokens,
cache_read_tokens as cacheReadTokens,
reasoning_tokens as reasoningTokens
FROM sessions
WHERE input_tokens > 0 OR output_tokens > 0`);
} catch (err) {
if (err.message && err.message.includes('ENOENT')) {
throw new Error('sqlite3 CLI not found. Install sqlite3 (or use Node >= 22.5) to sync Hermes data.');
}
throw err;
}
for (const row of sessionRows) {
// started_at is a Unix timestamp (float)
const timestamp = new Date(row.startedAt * 1000);
if (isNaN(timestamp.getTime())) continue;
// Hermes stores input_tokens exclusive of cache (Anthropic-style semantics)
entries.push({
source: 'hermes',
model: row.model || 'unknown',
project: profile,
timestamp,
inputTokens: row.inputTokens || 0,
outputTokens: row.outputTokens || 0,
cachedInputTokens: row.cacheReadTokens || 0,
reasoningOutputTokens: row.reasoningTokens || 0,
});
}
let messageRows;
try {
messageRows = queryDb(dbPath, `SELECT
session_id as sessionId,
role,
timestamp
FROM messages
WHERE role IN ('user', 'assistant')
ORDER BY timestamp`);
} catch {
// Messages query failed for this profile — skip its session events
continue;
}
for (const row of messageRows) {
const timestamp = new Date(row.timestamp * 1000);
if (isNaN(timestamp.getTime())) continue;
sessionEvents.push({
sessionId: row.sessionId,
source: 'hermes',
project: profile,
timestamp,
role: row.role === 'user' ? 'user' : 'assistant',
});
}
}
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
}
function discoverDbPaths(home) {
const dbs = [];
const defaultDb = join(home, 'state.db');
if (existsSync(defaultDb)) dbs.push({ path: defaultDb, profile: 'default' });
const profilesDir = join(home, 'profiles');
if (existsSync(profilesDir)) {
let entries;
try {
entries = readdirSync(profilesDir, { withFileTypes: true });
} catch {
return dbs;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const profileDb = join(profilesDir, entry.name, 'state.db');
try {
if (statSync(profileDb).isFile()) dbs.push({ path: profileDb, profile: entry.name });
} catch {
// missing or unreadable — skip
}
}
}
return dbs;
}
function queryDb(dbPath, sql) {
return queryDbJson(dbPath, sql);
}
import { createHash } from 'node:crypto';
import { parse as parseClaudeCode } from './claude-code.js';
import { parse as parseCline } from './cline.js';
import { parse as parseCodex } from './codex.js';
import { parse as parseCopilotCli } from './copilot-cli.js';
import { parse as parseCursor } from './cursor.js';
import { parse as parseRooCode } from './roo-code.js';
import { parse as parseGeminiCli } from './gemini-cli.js';
import { parse as parseOpencode } from './opencode.js';
import { parse as parseOpenclaw } from './openclaw.js';
import { parse as parseQwenCode } from './qwen-code.js';
import { parse as parseKimiCode } from './kimi-code.js';
import { parse as parseAmp } from './amp.js';
import { parse as parseDroid } from './droid.js';
import { parse as parseAntigravity } from './antigravity.js';
import { parse as parseHermes } from './hermes.js';
import { parse as parseKiro } from './kiro.js';
import { parse as parsePiCodingAgent } from './pi-coding-agent.js';
import { parse as parseZcode } from './zcode.js';
export const parsers = {
'claude-code': parseClaudeCode,
'cline': parseCline,
'codex': parseCodex,
'copilot-cli': parseCopilotCli,
'cursor': parseCursor,
'roo-code': parseRooCode,
'gemini-cli': parseGeminiCli,
'opencode': parseOpencode,
'openclaw': parseOpenclaw,
'qwen-code': parseQwenCode,
'kimi-code': parseKimiCode,
'amp': parseAmp,
'droid': parseDroid,
'antigravity': parseAntigravity,
'hermes': parseHermes,
'kiro': parseKiro,
'pi-coding-agent': parsePiCodingAgent,
'zcode': parseZcode,
};
export function roundToHalfHour(date) {
const d = new Date(date);
d.setMinutes(d.getMinutes() < 30 ? 0 : 30, 0, 0);
return d;
}
export function aggregateToBuckets(entries) {
const map = new Map();
for (const e of entries) {
const bucketStart = roundToHalfHour(e.timestamp).toISOString();
const key = `${e.source}|${e.model}|${e.project}|${bucketStart}`;
if (!map.has(key)) {
map.set(key, {
source: e.source,
model: e.model,
project: e.project,
bucketStart,
inputTokens: 0,
outputTokens: 0,
cachedInputTokens: 0,
reasoningOutputTokens: 0,
totalTokens: 0,
});
}
const b = map.get(key);
b.inputTokens += e.inputTokens || 0;
b.outputTokens += e.outputTokens || 0;
b.cachedInputTokens += e.cachedInputTokens || 0;
b.reasoningOutputTokens += e.reasoningOutputTokens || 0;
b.totalTokens += (e.inputTokens || 0) + (e.outputTokens || 0) + (e.reasoningOutputTokens || 0);
}
return Array.from(map.values());
}
/**
* Extract session metadata from timing events.
* Each event: { sessionId, source, project, timestamp: Date, role: 'user'|'assistant' }
*
* Turn = first AI response → last AI response before next user prompt.
* activeSeconds = sum(generation durations), excluding queue/TTFT wait.
* durationSeconds = wall clock from first to last message.
*/
export function extractSessions(events) {
const groups = new Map();
for (const e of events) {
if (!groups.has(e.sessionId)) groups.set(e.sessionId, []);
groups.get(e.sessionId).push(e);
}
const sessions = [];
for (const [sessionId, sessionEvents] of groups) {
sessionEvents.sort((a, b) => a.timestamp - b.timestamp);
const first = sessionEvents[0];
const last = sessionEvents[sessionEvents.length - 1];
const durationSeconds = Math.round((last.timestamp - first.timestamp) / 1000);
let activeSeconds = 0;
let turnStart = null;
let turnEnd = null;
let waitingForFirstResponse = false;
for (const event of sessionEvents) {
if (event.role === 'user') {
if (turnStart !== null && turnEnd !== null && turnEnd > turnStart) {
activeSeconds += Math.round((turnEnd - turnStart) / 1000);
}
turnStart = null;
turnEnd = null;
waitingForFirstResponse = true;
} else if (waitingForFirstResponse) {
turnStart = event.timestamp;
turnEnd = event.timestamp;
waitingForFirstResponse = false;
} else if (turnStart !== null) {
turnEnd = event.timestamp;
}
}
if (turnStart !== null && turnEnd !== null && turnEnd > turnStart) {
activeSeconds += Math.round((turnEnd - turnStart) / 1000);
}
const userPromptHours = new Array(24).fill(0);
let userMessageCount = 0;
for (const event of sessionEvents) {
if (event.role === 'user') {
userMessageCount++;
userPromptHours[event.timestamp.getUTCHours()]++;
}
}
const sessionHash = createHash('sha256').update(sessionId).digest('hex').slice(0, 16);
sessions.push({
source: first.source,
project: first.project || 'unknown',
sessionHash,
firstMessageAt: first.timestamp.toISOString(),
lastMessageAt: last.timestamp.toISOString(),
durationSeconds,
activeSeconds,
messageCount: sessionEvents.length,
userMessageCount,
userPromptHours,
});
}
return sessions;
}
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
/**
* Run a SQL query against a SQLite database and return rows as plain objects
* (column name → value), mirroring the shape of `sqlite3 -json` output.
*
* Prefers Node's built-in `node:sqlite` (available on Node >= 22.5, no external
* binary needed — important on Windows where the `sqlite3` CLI is rarely on
* PATH). Falls back to shelling out to the `sqlite3` CLI on older Node.
*
* If neither is available, throws an Error whose message contains "ENOENT" so
* callers can surface an "Install sqlite3" hint, matching the previous behavior.
*/
export function queryDbJson(dbPath, sql, { timeout = 30000, maxBuffer = 100 * 1024 * 1024 } = {}) {
const db = openNodeSqlite(dbPath);
if (db) {
try {
return db.prepare(sql).all();
} finally {
db.close();
}
}
return queryViaCli(dbPath, sql, { timeout, maxBuffer });
}
let nodeSqlite; // undefined = not tried, null = unavailable
function getNodeSqlite() {
if (nodeSqlite !== undefined) return nodeSqlite;
try {
// Suppress the one-time "SQLite is an experimental feature" ExperimentalWarning
// on Node versions where node:sqlite is still flagged experimental.
const prevEmit = process.emitWarning;
process.emitWarning = (warning, ...rest) => {
const opts = rest[0];
const type = typeof opts === 'object' && opts ? opts.type : opts;
const name = typeof warning === 'object' && warning ? warning.name : undefined;
if ((type === 'ExperimentalWarning' || name === 'ExperimentalWarning') && String(warning).includes('SQLite')) return;
return prevEmit.call(process, warning, ...rest);
};
try {
nodeSqlite = require('node:sqlite');
} finally {
process.emitWarning = prevEmit;
}
} catch {
nodeSqlite = null;
}
return nodeSqlite;
}
function openNodeSqlite(dbPath) {
const mod = getNodeSqlite();
if (!mod || !mod.DatabaseSync) return null;
try {
return new mod.DatabaseSync(dbPath, { readOnly: true });
} catch {
return null;
}
}
function queryViaCli(dbPath, sql, { timeout, maxBuffer }) {
const out = execFileSync('sqlite3', ['-json', dbPath, sql], {
encoding: 'utf-8',
maxBuffer,
timeout,
});
const trimmed = out.trim();
if (!trimmed || trimmed === '[]') return [];
return JSON.parse(trimmed);
}