@vibe-cafe/vibe-usage 0.8.2 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,7 +46,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
46
46
 
47
47
  | Tool | Data Location |
48
48
  |------|---------------|
49
- | Claude Code | `~/.claude/projects/` (tokens + sessions), `~/.claude/transcripts/` (sessions only) |
49
+ | 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 |
50
50
  | Codex CLI | `~/.codex/sessions/` and `~/.codex/archived_sessions/` |
51
51
  | GitHub Copilot CLI | `~/.copilot/session-state/*/events.jsonl` |
52
52
  | 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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,18 +1,54 @@
1
- import { readdirSync, readFileSync, existsSync } from 'node:fs';
1
+ import { readdirSync, readFileSync, existsSync, realpathSync } from 'node:fs';
2
2
  import { join, basename, sep } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { aggregateToBuckets, extractSessions } from './index.js';
5
5
 
6
6
  /**
7
7
  * Stateless Claude Code parser.
8
- * Reads ALL *.jsonl files under ~/.claude/projects/ and extracts per-message
8
+ * Reads ALL *.jsonl files under <root>/projects/ and extracts per-message
9
9
  * token usage from assistant messages. No state file needed — every sync
10
10
  * computes the full bucket totals from raw data, making server-side
11
11
  * ON CONFLICT ... DO UPDATE SET idempotent.
12
+ *
13
+ * Roots: always ~/.claude, plus $CLAUDE_CONFIG_DIR when set to a different
14
+ * path. Claude Code itself relocates its whole tree (incl. projects/) to
15
+ * $CLAUDE_CONFIG_DIR and uses only that dir — but a GUI launched from the
16
+ * Dock may not inherit the shell's env, so usage can be split across both
17
+ * roots. We scan both and dedup so neither source is missed or double-counted.
12
18
  */
13
19
 
14
- const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
15
- const CLAUDE_TRANSCRIPTS_DIR = join(homedir(), '.claude', 'transcripts');
20
+ /**
21
+ * Resolve the set of Claude config roots to scan.
22
+ * Always includes ~/.claude; adds $CLAUDE_CONFIG_DIR when set and it resolves
23
+ * to a different real path. Deduped by canonical path.
24
+ */
25
+ function getClaudeRoots() {
26
+ const roots = [join(homedir(), '.claude')];
27
+
28
+ const cfg = process.env.CLAUDE_CONFIG_DIR?.trim();
29
+ if (cfg) {
30
+ let custom = cfg;
31
+ if (custom.startsWith('~')) custom = join(homedir(), custom.slice(1));
32
+ custom = custom.replace(/[/\\]+$/, '') || custom;
33
+ roots.push(custom);
34
+ }
35
+
36
+ // Dedup by canonical path (realpath when the dir exists, else the raw string).
37
+ const seen = new Set();
38
+ const unique = [];
39
+ for (const r of roots) {
40
+ let key = r;
41
+ try {
42
+ key = realpathSync(r);
43
+ } catch {
44
+ // dir may not exist yet — fall back to the literal path
45
+ }
46
+ if (seen.has(key)) continue;
47
+ seen.add(key);
48
+ unique.push(r);
49
+ }
50
+ return unique;
51
+ }
16
52
 
17
53
  /**
18
54
  * Recursively find all .jsonl files under a directory.
@@ -39,15 +75,22 @@ function findJsonlFiles(dir) {
39
75
  }
40
76
 
41
77
  /**
42
- * Extract project name from file path.
43
- * Path format: ~/.claude/projects/{encodedProjectPath}/{sessionId}.jsonl
44
- * The encodedProjectPath uses dashes for separators (e.g. -Users-foo-myproject).
45
- * We extract the last path segment as the project name.
78
+ * Path of a project file relative to its root's projects/ dir, e.g.
79
+ * "<root>/projects/-Users-foo-app/abc.jsonl" "-Users-foo-app/abc.jsonl".
80
+ * Used both for project-name extraction and cross-root dedup.
81
+ */
82
+ function projectRelativePath(filePath, projectsDir) {
83
+ const prefix = projectsDir + sep;
84
+ return filePath.startsWith(prefix) ? filePath.slice(prefix.length) : null;
85
+ }
86
+
87
+ /**
88
+ * Extract project name from a projects-relative path.
89
+ * The first segment is the dash-encoded project path (e.g. -Users-foo-myproject);
90
+ * we take its last component as the project name.
46
91
  */
47
- function extractProject(filePath) {
48
- const projectsPrefix = CLAUDE_PROJECTS_DIR + sep;
49
- if (!filePath.startsWith(projectsPrefix)) return 'unknown';
50
- const relative = filePath.slice(projectsPrefix.length);
92
+ function extractProject(relative) {
93
+ if (!relative) return 'unknown';
51
94
  const firstSeg = relative.split(sep)[0];
52
95
  if (!firstSeg) return 'unknown';
53
96
  const parts = firstSeg.split('-').filter(Boolean);
@@ -58,16 +101,21 @@ function extractSessionId(filePath) {
58
101
  return basename(filePath, '.jsonl');
59
102
  }
60
103
 
61
- export async function parse() {
62
- const entries = [];
63
- const sessionEvents = [];
64
- const seenUuids = new Set();
65
- const seenSessionIds = new Set();
66
-
67
- // --- projects/ directory: extract BOTH token buckets AND session events ---
68
- const projectFiles = findJsonlFiles(CLAUDE_PROJECTS_DIR);
104
+ /**
105
+ * Scan one root's projects/ dir → token entries + session events (mutates ctx).
106
+ */
107
+ function scanProjectsRoot(root, ctx) {
108
+ const projectsDir = join(root, 'projects');
109
+
110
+ for (const filePath of findJsonlFiles(projectsDir)) {
111
+ const relative = projectRelativePath(filePath, projectsDir);
112
+ // Same session present under two roots (e.g. data copied between them):
113
+ // process it once so session message counts aren't inflated.
114
+ if (relative !== null) {
115
+ if (ctx.seenProjectFiles.has(relative)) continue;
116
+ ctx.seenProjectFiles.add(relative);
117
+ }
69
118
 
70
- for (const filePath of projectFiles) {
71
119
  let content;
72
120
  try {
73
121
  content = readFileSync(filePath, 'utf-8');
@@ -75,9 +123,9 @@ export async function parse() {
75
123
  continue;
76
124
  }
77
125
 
78
- const project = extractProject(filePath);
126
+ const project = extractProject(relative);
79
127
  const sessionId = extractSessionId(filePath);
80
- seenSessionIds.add(sessionId);
128
+ ctx.seenSessionIds.add(sessionId);
81
129
 
82
130
  for (const line of content.split('\n')) {
83
131
  if (!line.trim()) continue;
@@ -90,7 +138,7 @@ export async function parse() {
90
138
  if (isNaN(ts.getTime())) continue;
91
139
 
92
140
  if (obj.type === 'user' || obj.type === 'assistant' || obj.type === 'tool_use' || obj.type === 'tool_result') {
93
- sessionEvents.push({
141
+ ctx.sessionEvents.push({
94
142
  sessionId,
95
143
  source: 'claude-code',
96
144
  project,
@@ -108,11 +156,11 @@ export async function parse() {
108
156
 
109
157
  const uuid = obj.uuid;
110
158
  if (uuid) {
111
- if (seenUuids.has(uuid)) continue;
112
- seenUuids.add(uuid);
159
+ if (ctx.seenUuids.has(uuid)) continue;
160
+ ctx.seenUuids.add(uuid);
113
161
  }
114
162
 
115
- entries.push({
163
+ ctx.entries.push({
116
164
  source: 'claude-code',
117
165
  model: msg.model || 'unknown',
118
166
  project,
@@ -127,13 +175,17 @@ export async function parse() {
127
175
  }
128
176
  }
129
177
  }
178
+ }
130
179
 
131
- // --- transcripts/ directory: extract session events ONLY (no token data) ---
132
- const transcriptFiles = findJsonlFiles(CLAUDE_TRANSCRIPTS_DIR);
133
-
134
- for (const filePath of transcriptFiles) {
180
+ /**
181
+ * Scan one root's transcripts/ dir → session events only (no token data).
182
+ * Skips sessions already covered by a projects/ or transcripts/ scan.
183
+ */
184
+ function scanTranscriptsRoot(root, ctx) {
185
+ for (const filePath of findJsonlFiles(join(root, 'transcripts'))) {
135
186
  const sessionId = extractSessionId(filePath);
136
- if (seenSessionIds.has(sessionId)) continue;
187
+ if (ctx.seenSessionIds.has(sessionId)) continue;
188
+ ctx.seenSessionIds.add(sessionId);
137
189
 
138
190
  let content;
139
191
  try {
@@ -153,7 +205,7 @@ export async function parse() {
153
205
  if (isNaN(ts.getTime())) continue;
154
206
 
155
207
  if (obj.type === 'user' || obj.type === 'assistant' || obj.type === 'tool_use' || obj.type === 'tool_result') {
156
- sessionEvents.push({
208
+ ctx.sessionEvents.push({
157
209
  sessionId,
158
210
  source: 'claude-code',
159
211
  project: 'unknown',
@@ -166,6 +218,26 @@ export async function parse() {
166
218
  }
167
219
  }
168
220
  }
221
+ }
169
222
 
170
- return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
223
+ export async function parse() {
224
+ const ctx = {
225
+ entries: [],
226
+ sessionEvents: [],
227
+ seenUuids: new Set(),
228
+ seenSessionIds: new Set(),
229
+ seenProjectFiles: new Set(), // projects-relative path → dedup same session across roots
230
+ };
231
+
232
+ const roots = getClaudeRoots();
233
+
234
+ // projects/ yields BOTH token buckets and session events.
235
+ for (const root of roots) scanProjectsRoot(root, ctx);
236
+ // transcripts/ yields session events only, for sessions not already covered.
237
+ for (const root of roots) scanTranscriptsRoot(root, ctx);
238
+
239
+ return {
240
+ buckets: aggregateToBuckets(ctx.entries),
241
+ sessions: extractSessions(ctx.sessionEvents),
242
+ };
171
243
  }
package/src/tools.js CHANGED
@@ -80,6 +80,20 @@ function findOpenclawDataDirs() {
80
80
  return dirs;
81
81
  }
82
82
 
83
+ // Claude Code lives in ~/.claude/projects, but $CLAUDE_CONFIG_DIR relocates its
84
+ // whole tree. Detect either so a user who only set CLAUDE_CONFIG_DIR is still
85
+ // recognized (the parser scans both roots; see parsers/claude-code.js).
86
+ function findClaudeCodeDataDirs() {
87
+ const dirs = [join(homedir(), '.claude', 'projects')];
88
+ const cfg = process.env.CLAUDE_CONFIG_DIR?.trim();
89
+ if (cfg) {
90
+ let custom = cfg.startsWith('~') ? join(homedir(), cfg.slice(1)) : cfg;
91
+ custom = custom.replace(/[/\\]+$/, '') || custom;
92
+ dirs.push(join(custom, 'projects'));
93
+ }
94
+ return dirs.filter(existsSync);
95
+ }
96
+
83
97
  // Codex keeps live sessions in ~/.codex/sessions and moves completed ones to
84
98
  // ~/.codex/archived_sessions. Detect Codex if either dir exists, so a user
85
99
  // whose sessions have all been archived is still recognized.
@@ -100,6 +114,7 @@ export const TOOLS = [
100
114
  name: 'Claude Code',
101
115
  id: 'claude-code',
102
116
  dataDir: join(homedir(), '.claude', 'projects'),
117
+ detectDataDirs: findClaudeCodeDataDirs,
103
118
  },
104
119
  {
105
120
  name: 'Cline',