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/.agents/skills/frontend-design/LICENSE.txt +177 -0
- package/.agents/skills/frontend-design/SKILL.md +42 -0
- package/package.json +1 -1
- package/skills-lock.json +10 -0
- package/src/claude-parser.js +2 -2
- package/src/dashboard.html +827 -188
- package/src/index.js +43 -40
- package/src/metrics.js +10 -2
- package/src/server.js +18 -1
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
|
|
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 (
|
|
24
|
+
if (forceRefresh) {
|
|
46
25
|
deleteCache();
|
|
47
26
|
console.log('Cache cleared, performing full parse...');
|
|
48
27
|
}
|
|
49
28
|
|
|
50
|
-
const cached =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)}
|
|
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)}
|
|
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(
|
|
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({
|