claude-roi 0.2.3 → 0.3.0

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/src/index.js CHANGED
@@ -15,42 +15,20 @@ const { version: VERSION } = JSON.parse(
15
15
  readFileSync(new URL('../package.json', import.meta.url), 'utf8')
16
16
  );
17
17
 
18
- async function main() {
19
- const program = new Command();
20
- program
21
- .name('claude-roi')
22
- .description('Correlate Claude Code token usage with git output to measure AI coding agent ROI')
23
- .version(VERSION)
24
- .option('-p, --port <number>', 'port to serve dashboard', '3457')
25
- .option('-d, --days <number>', 'number of days to look back', '30')
26
- .option('--no-open', 'do not auto-open browser')
27
- .option('--json', 'output raw JSON to stdout instead of starting server')
28
- .option('--project <name>', 'filter to specific project')
29
- .option('--refresh', 'force full re-parse, ignore cache');
30
-
31
- program.parse();
32
- const opts = program.opts();
33
- const port = parseInt(opts.port, 10);
34
- const days = parseInt(opts.days, 10);
35
-
36
- console.log(`\x1b[36mclaude-roi\x1b[0m v${VERSION}`);
37
-
38
- const claudeDir = path.join(os.homedir(), '.claude', 'projects');
39
-
18
+ async function buildPayload(claudeDir, days, project, forceRefresh = false) {
40
19
  // Step 1: Parse sessions (with caching)
41
20
  let sessions;
42
21
  let fileIndex;
43
22
  const startParse = Date.now();
44
23
 
45
- if (opts.refresh) {
24
+ if (forceRefresh) {
46
25
  deleteCache();
47
26
  console.log('Cache cleared, performing full parse...');
48
27
  }
49
28
 
50
- const cached = opts.refresh ? null : loadCache();
29
+ const cached = forceRefresh ? null : loadCache();
51
30
 
52
31
  if (cached) {
53
- // Incremental parse: only process new/modified files
54
32
  const stale = getStaleFiles(claudeDir, cached.fileIndex);
55
33
  const newCount = stale.newFiles.length;
56
34
  const modifiedCount = stale.modifiedFiles.length;
@@ -58,33 +36,24 @@ async function main() {
58
36
  const cachedCount = Object.keys(cached.fileIndex).length - modifiedCount - deletedCount;
59
37
 
60
38
  if (newCount === 0 && modifiedCount === 0 && deletedCount === 0) {
61
- // Nothing changed, use cache as-is
62
39
  sessions = cached.sessions;
63
40
  fileIndex = cached.fileIndex;
64
41
  console.log(`Parsing sessions... ${cached.sessions.length} cached (${Date.now() - startParse}ms)`);
65
42
  } else {
66
- // Parse only new/modified files
67
- const { sessions: freshSessions, fileIndex: freshIndex } = await parseAllProjects(claudeDir, days, opts.project);
68
-
69
- // For a simpler approach: just do a full re-parse when files change
70
- // This avoids complex merging logic while still benefiting from caching
71
- // when nothing has changed
43
+ const { sessions: freshSessions, fileIndex: freshIndex } = await parseAllProjects(claudeDir, days, project);
72
44
  sessions = freshSessions;
73
45
  fileIndex = freshIndex;
74
46
  console.log(`Parsing sessions... ${newCount} new, ${modifiedCount} updated, ${Math.max(0, cachedCount)} cached (${Date.now() - startParse}ms)`);
75
47
  }
76
48
  } else {
77
- // Full parse
78
- const result = await parseAllProjects(claudeDir, days, opts.project);
49
+ const result = await parseAllProjects(claudeDir, days, project);
79
50
  sessions = result.sessions;
80
51
  fileIndex = result.fileIndex;
81
52
  console.log(`Parsing sessions... ${sessions.length} parsed (${Date.now() - startParse}ms)`);
82
53
  }
83
54
 
84
55
  if (sessions.length === 0) {
85
- console.log('\x1b[33mNo Claude Code sessions found.\x1b[0m');
86
- console.log('Make sure you have used Claude Code and session files exist in ~/.claude/projects/');
87
- process.exit(0);
56
+ return null;
88
57
  }
89
58
 
90
59
  // Step 2: Analyze git repos
@@ -107,14 +76,48 @@ async function main() {
107
76
  // Save cache for next run
108
77
  saveCache(sessions, fileIndex);
109
78
 
110
- // Step 5: Output
79
+ return payload;
80
+ }
81
+
82
+ async function main() {
83
+ const program = new Command();
84
+ program
85
+ .name('claude-roi')
86
+ .description('Correlate Claude Code token usage with git output to measure AI coding agent ROI')
87
+ .version(VERSION)
88
+ .option('-p, --port <number>', 'port to serve dashboard', '3457')
89
+ .option('-d, --days <number>', 'number of days to look back', '30')
90
+ .option('--no-open', 'do not auto-open browser')
91
+ .option('--json', 'output raw JSON to stdout instead of starting server')
92
+ .option('--project <name>', 'filter to specific project')
93
+ .option('--refresh', 'force full re-parse, ignore cache');
94
+
95
+ program.parse();
96
+ const opts = program.opts();
97
+ const port = parseInt(opts.port, 10);
98
+ const days = parseInt(opts.days, 10);
99
+
100
+ console.log(`\x1b[36mclaude-roi\x1b[0m v${VERSION}`);
101
+
102
+ const claudeDir = path.join(os.homedir(), '.claude', 'projects');
103
+
104
+ const payload = await buildPayload(claudeDir, days, opts.project, opts.refresh);
105
+
106
+ if (!payload) {
107
+ console.log('\x1b[33mNo Claude Code sessions found.\x1b[0m');
108
+ console.log('Make sure you have used Claude Code and session files exist in ~/.claude/projects/');
109
+ process.exit(0);
110
+ }
111
+
112
+ // Output
111
113
  if (opts.json) {
112
114
  process.stdout.write(JSON.stringify(payload, null, 2));
113
115
  process.exit(0);
114
116
  }
115
117
 
116
- // Start server
117
- const app = createServer(payload);
118
+ // Start server — pass a rebuild function so /api/refresh can re-run the pipeline
119
+ const rebuild = () => buildPayload(claudeDir, days, opts.project, true);
120
+ const app = createServer(payload, rebuild);
118
121
  const server = app.listen(port, () => {
119
122
  const url = `http://localhost:${port}`;
120
123
  console.log(`\x1b[32mDashboard:\x1b[0m ${url}`);
package/src/metrics.js CHANGED
@@ -9,6 +9,14 @@ function formatBigNumber(n) {
9
9
  return n.toString();
10
10
  }
11
11
 
12
+ function formatDuration(minutes) {
13
+ if (minutes < 60) return `${minutes} minutes`;
14
+ const hrs = Math.floor(minutes / 60);
15
+ const mins = minutes % 60;
16
+ if (mins === 0) return `${hrs} hr${hrs > 1 ? 's' : ''}`;
17
+ return `${hrs} hr${hrs > 1 ? 's' : ''} ${mins} min`;
18
+ }
19
+
12
20
  function sessionTokens(s) {
13
21
  return s.totalInputTokens + s.totalOutputTokens + s.cacheReadTokens + s.cacheCreationTokens;
14
22
  }
@@ -254,7 +262,7 @@ function generateInsights(summary, correlatedSessions, modelBreakdown, sessionBu
254
262
  if (avgDuration > 0) {
255
263
  insights.push({
256
264
  type: 'info',
257
- text: `Average session duration: ${Math.round(avgDuration)} minutes.`,
265
+ text: `Average session duration: ${formatDuration(Math.round(avgDuration))}.`,
258
266
  });
259
267
  }
260
268
 
@@ -272,7 +280,7 @@ function generateInsights(summary, correlatedSessions, modelBreakdown, sessionBu
272
280
  const avgDelayMs = delays.reduce((s, d) => s + d, 0) / delays.length;
273
281
  const avgDelayHours = avgDelayMs / (1000 * 60 * 60);
274
282
  if (avgDelayHours < 1) {
275
- insights.push({ type: 'success', text: `On average, commits happen ${Math.round(avgDelayHours * 60)} minutes after a session ends.` });
283
+ insights.push({ type: 'success', text: `On average, commits happen ${formatDuration(Math.round(avgDelayHours * 60))} after a session ends.` });
276
284
  } else {
277
285
  insights.push({ type: 'info', text: `On average, commits happen ${avgDelayHours.toFixed(1)} hours after a session ends.` });
278
286
  }
package/src/server.js CHANGED
@@ -5,8 +5,9 @@ import path from 'node:path';
5
5
 
6
6
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
7
 
8
- export function createServer(payload) {
8
+ export function createServer(initialPayload, rebuildFn) {
9
9
  const app = express();
10
+ let payload = initialPayload;
10
11
 
11
12
  // Serve dashboard HTML
12
13
  const dashboardHtml = readFileSync(path.join(__dirname, 'dashboard.html'), 'utf-8');
@@ -20,6 +21,22 @@ export function createServer(payload) {
20
21
  res.json(payload);
21
22
  });
22
23
 
24
+ // Re-run the full pipeline: clear cache, re-parse sessions, re-analyze git, recompute metrics
25
+ app.post('/api/refresh', async (req, res) => {
26
+ if (!rebuildFn) return res.status(501).json({ error: 'Refresh not available' });
27
+ try {
28
+ console.log('\x1b[36m[refresh]\x1b[0m Re-parsing sessions and recomputing metrics...');
29
+ const newPayload = await rebuildFn();
30
+ if (!newPayload) return res.status(404).json({ error: 'No sessions found after refresh' });
31
+ payload = newPayload;
32
+ console.log('\x1b[32m[refresh]\x1b[0m Done');
33
+ res.json({ ok: true });
34
+ } catch (err) {
35
+ console.error('\x1b[31m[refresh]\x1b[0m Error:', err.message);
36
+ res.status(500).json({ error: err.message });
37
+ }
38
+ });
39
+
23
40
  // Hero stats + insights
24
41
  app.get('/api/summary', (req, res) => {
25
42
  res.json({