token-studio 4.8.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.
Files changed (139) hide show
  1. package/.nvmrc +1 -0
  2. package/CHANGELOG.md +89 -0
  3. package/Dockerfile +17 -0
  4. package/LICENSE +22 -0
  5. package/NOTICE.md +21 -0
  6. package/PRIVACY.md +68 -0
  7. package/README.en.md +220 -0
  8. package/README.md +220 -0
  9. package/config/collectors.json +54 -0
  10. package/data/.gitkeep +1 -0
  11. package/docker-compose.yml +17 -0
  12. package/docs/assets/.gitkeep +1 -0
  13. package/docs/assets/token-studio-v44-dashboard.png +0 -0
  14. package/docs/assets/token-studio-v44-live.png +0 -0
  15. package/docs/assets/token-studio-v44-review-mobile.png +0 -0
  16. package/docs/assets/token-studio-v44-review.png +0 -0
  17. package/docs/assets/token-studio-v45-dashboard.png +0 -0
  18. package/docs/assets/token-studio-v45-live.png +0 -0
  19. package/docs/assets/token-studio-v45-review-mobile.png +0 -0
  20. package/docs/assets/token-studio-v45-review.png +0 -0
  21. package/docs/blog-case-study.md +34 -0
  22. package/docs/collector-support-matrix.md +65 -0
  23. package/docs/competitive-notes.md +87 -0
  24. package/docs/demo-data/README.md +12 -0
  25. package/docs/demo-data/token-studio-v2-demo.json +146 -0
  26. package/docs/demo-flow.md +39 -0
  27. package/docs/first-run.md +95 -0
  28. package/docs/local-collectors.md +49 -0
  29. package/docs/public-launch-checklist.md +45 -0
  30. package/docs/resume-bullets.md +7 -0
  31. package/docs/statusline.md +52 -0
  32. package/index.html +16 -0
  33. package/package.json +36 -0
  34. package/render.yaml +17 -0
  35. package/src/auto-attribution.mjs +396 -0
  36. package/src/ccusage-bridge.mjs +74 -0
  37. package/src/ccusage-import.mjs +415 -0
  38. package/src/cli.mjs +643 -0
  39. package/src/client/dashboard/App.jsx +1734 -0
  40. package/src/client/dashboard/annotation-presets.js +138 -0
  41. package/src/client/dashboard/attribution.js +328 -0
  42. package/src/client/dashboard/components-charts.jsx +622 -0
  43. package/src/client/dashboard/components-tables.jsx +1531 -0
  44. package/src/client/dashboard/components-top.jsx +307 -0
  45. package/src/client/dashboard/import-budget.js +41 -0
  46. package/src/client/dashboard/model-usage.js +108 -0
  47. package/src/client/dashboard/onboarding.js +80 -0
  48. package/src/client/dashboard/styles.css +2606 -0
  49. package/src/client/live/LiveApp.jsx +226 -0
  50. package/src/client/live/styles.css +446 -0
  51. package/src/client/main.jsx +20 -0
  52. package/src/client/review/ReviewApp.jsx +507 -0
  53. package/src/client/review/closure-progress.js +165 -0
  54. package/src/client/review/markdown-report.js +401 -0
  55. package/src/client/review/model-strategy.js +273 -0
  56. package/src/client/review/roi-advisor.js +255 -0
  57. package/src/client/review/roi-evidence.js +78 -0
  58. package/src/client/review/savings-simulator.js +252 -0
  59. package/src/client/review/sections-1.jsx +277 -0
  60. package/src/client/review/sections-2.jsx +927 -0
  61. package/src/client/review/styles.css +2321 -0
  62. package/src/client/review/utils.js +345 -0
  63. package/src/client/shared/utils.js +236 -0
  64. package/src/closure-check.mjs +537 -0
  65. package/src/closure-import.mjs +646 -0
  66. package/src/collect.mjs +247 -0
  67. package/src/collector-config.mjs +82 -0
  68. package/src/collector-registry.mjs +333 -0
  69. package/src/collectors/claude-code.mjs +355 -0
  70. package/src/collectors/codex.mjs +418 -0
  71. package/src/collectors/copilot.mjs +19 -0
  72. package/src/collectors/cursor.mjs +23 -0
  73. package/src/collectors/gemini.mjs +530 -0
  74. package/src/collectors/goose.mjs +15 -0
  75. package/src/collectors/hermes.mjs +206 -0
  76. package/src/collectors/kimi.mjs +15 -0
  77. package/src/collectors/openclaw.mjs +400 -0
  78. package/src/collectors/opencode.mjs +349 -0
  79. package/src/collectors/qwen.mjs +15 -0
  80. package/src/collectors/structured-usage.mjs +437 -0
  81. package/src/collectors/utils.mjs +93 -0
  82. package/src/db.mjs +1397 -0
  83. package/src/demo-seed.mjs +39 -0
  84. package/src/dev.mjs +43 -0
  85. package/src/live.mjs +428 -0
  86. package/src/model-policy.mjs +147 -0
  87. package/src/pricing.mjs +434 -0
  88. package/src/privacy-check.mjs +126 -0
  89. package/src/server.mjs +1240 -0
  90. package/src/source-health.mjs +195 -0
  91. package/src/statusline.mjs +156 -0
  92. package/src/terminal-report.mjs +245 -0
  93. package/src/update-pricing.mjs +8 -0
  94. package/test/annotation-presets.test.mjs +137 -0
  95. package/test/api-annotations.test.mjs +202 -0
  96. package/test/api-auto-attribution.test.mjs +169 -0
  97. package/test/api-source-health.test.mjs +109 -0
  98. package/test/api-v2.test.mjs +278 -0
  99. package/test/api-v43.test.mjs +151 -0
  100. package/test/api-v44.test.mjs +128 -0
  101. package/test/attribution-summary.test.mjs +164 -0
  102. package/test/auto-attribution.test.mjs +116 -0
  103. package/test/ccusage-bridge.test.mjs +36 -0
  104. package/test/ccusage-import.test.mjs +93 -0
  105. package/test/cli-v43.test.mjs +64 -0
  106. package/test/cli-v45.test.mjs +34 -0
  107. package/test/cli-v46.test.mjs +129 -0
  108. package/test/cli-v47.test.mjs +98 -0
  109. package/test/closure-check.test.mjs +202 -0
  110. package/test/closure-import.test.mjs +263 -0
  111. package/test/collector-config.test.mjs +25 -0
  112. package/test/collector-registry.test.mjs +56 -0
  113. package/test/csv.test.mjs +19 -0
  114. package/test/db-annotations.test.mjs +186 -0
  115. package/test/db-v2.test.mjs +200 -0
  116. package/test/db-v4.test.mjs +178 -0
  117. package/test/experimental-collectors.test.mjs +103 -0
  118. package/test/fixtures/collectors/copilot/usage.jsonl +2 -0
  119. package/test/fixtures/collectors/cursor/usage.jsonl +2 -0
  120. package/test/fixtures/collectors/goose/usage.jsonl +2 -0
  121. package/test/fixtures/collectors/kimi/usage.jsonl +2 -0
  122. package/test/fixtures/collectors/qwen/usage.jsonl +2 -0
  123. package/test/import-budget.test.mjs +40 -0
  124. package/test/live.test.mjs +256 -0
  125. package/test/markdown-report.test.mjs +193 -0
  126. package/test/model-policy.test.mjs +34 -0
  127. package/test/model-strategy.test.mjs +116 -0
  128. package/test/model-usage.test.mjs +99 -0
  129. package/test/official-pricing.test.mjs +70 -0
  130. package/test/onboarding.test.mjs +55 -0
  131. package/test/privacy-check.test.mjs +33 -0
  132. package/test/review-closure-progress.test.mjs +99 -0
  133. package/test/roi-advisor.test.mjs +188 -0
  134. package/test/roi-evidence.test.mjs +48 -0
  135. package/test/roi-summary.test.mjs +101 -0
  136. package/test/savings-simulator.test.mjs +141 -0
  137. package/test/source-health.test.mjs +62 -0
  138. package/test/statusline.test.mjs +148 -0
  139. package/vite.config.js +23 -0
package/src/cli.mjs ADDED
@@ -0,0 +1,643 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from 'node:child_process';
4
+ import { createInterface } from 'node:readline/promises';
5
+ import { stdin as input, stdout as output } from 'node:process';
6
+ import { createServer } from 'node:net';
7
+ import { existsSync } from 'node:fs';
8
+ import { dirname, resolve } from 'node:path';
9
+ import { hostname } from 'node:os';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { seedDemoDatabase } from './demo-seed.mjs';
12
+ import { auditExperimentalCollectors, detectCollectors } from './collector-registry.mjs';
13
+ import { CCUSAGE_CLI_REPORTS, ccusageInvocation, runCcusageCliImportPlan } from './ccusage-bridge.mjs';
14
+ import { applyCcusageImport, parseCcusageJsonText, planCcusageImport, readCcusageImportInput } from './ccusage-import.mjs';
15
+ import { createSqliteBackup, defaultDbPath, deleteBudgetProfile, listBudgetProfiles, openDb, openReadOnlyDb, upsertBudgetProfile } from './db.mjs';
16
+ import { formatPrivacyCheckReport, runPrivacyCheck } from './privacy-check.mjs';
17
+ import { buildTerminalReport, formatTerminalReport } from './terminal-report.mjs';
18
+ import { buildEmptyStatuslineSnapshot, buildStatuslineSnapshot, formatStatuslineText } from './statusline.mjs';
19
+ import { buildModelPolicy, formatModelPolicy } from './model-policy.mjs';
20
+
21
+ const command = process.argv[2] || 'help';
22
+ const args = parseArgs(process.argv.slice(3));
23
+ const SOURCE_DIR = dirname(fileURLToPath(import.meta.url));
24
+ const PACKAGE_ROOT = resolve(SOURCE_DIR, '..');
25
+ const USER_CWD = process.cwd();
26
+
27
+ try {
28
+ if (command === 'start') {
29
+ await startCommand({ demo: false });
30
+ } else if (command === 'open') {
31
+ await startCommand({ demo: false, openBrowser: true });
32
+ } else if (command === 'demo') {
33
+ await demoCommand();
34
+ } else if (command === 'live') {
35
+ await startCommand({ demo: false, route: '/live' });
36
+ } else if (command === 'statusline') {
37
+ await statuslineCommand();
38
+ } else if (command === 'collect') {
39
+ await collectCommand();
40
+ } else if (command === 'collectors') {
41
+ await collectorsCommand();
42
+ } else if (command === 'import-usage') {
43
+ await importUsageCommand();
44
+ } else if (command === 'budget') {
45
+ await budgetCommand();
46
+ } else if (command === 'report') {
47
+ await reportCommand();
48
+ } else if (command === 'policy') {
49
+ await policyCommand();
50
+ } else if (command === 'doctor') {
51
+ await doctorCommand();
52
+ } else if (command === 'privacy-check') {
53
+ await privacyCheckCommand();
54
+ } else {
55
+ printHelp();
56
+ process.exit(command === 'help' || command === '--help' || command === '-h' ? 0 : 1);
57
+ }
58
+ } catch (error) {
59
+ console.error(error.message);
60
+ process.exit(1);
61
+ }
62
+
63
+ async function demoCommand() {
64
+ const dbPath = resolve(USER_CWD, args.db || 'data/demo.sqlite');
65
+ const result = seedDemoDatabase({
66
+ dbPath,
67
+ demoPath: resolve(PACKAGE_ROOT, 'docs', 'demo-data', 'token-studio-v2-demo.json')
68
+ });
69
+ console.log(`[demo] seeded ${result.sessions} sessions and ${result.daily} daily rows into ${result.dbPath}`);
70
+ if (args.seedOnly) return;
71
+ await startCommand({ demo: true, dbPath });
72
+ }
73
+
74
+ async function startCommand({ demo = false, dbPath = null, route = '/', openBrowser = false } = {}) {
75
+ const apiPort = Number(args.apiPort || args.port || await freePort(4173));
76
+ const uiPort = Number(args.uiPort || await freePort(5173));
77
+ const env = {
78
+ ...process.env,
79
+ PORT: String(apiPort),
80
+ API_PORT: String(apiPort),
81
+ DB_PATH: dbPath || resolve(USER_CWD, args.db || process.env.DB_PATH || 'data/usage.sqlite'),
82
+ TOKEN_STUDIO_DEMO_MODE: demo ? '1' : process.env.TOKEN_STUDIO_DEMO_MODE || ''
83
+ };
84
+ const viteBin = resolve(PACKAGE_ROOT, 'node_modules', 'vite', 'bin', 'vite.js');
85
+ if (!existsSync(viteBin)) {
86
+ throw new Error('Vite is not installed. Run npm install first, then retry token-studio start.');
87
+ }
88
+ const server = spawn(process.execPath, [resolve(SOURCE_DIR, 'server.mjs')], {
89
+ cwd: PACKAGE_ROOT,
90
+ env,
91
+ stdio: 'inherit',
92
+ windowsHide: true
93
+ });
94
+ const client = spawn(process.execPath, [viteBin, '--host', '127.0.0.1', '--port', String(uiPort)], {
95
+ cwd: PACKAGE_ROOT,
96
+ env,
97
+ stdio: 'inherit',
98
+ windowsHide: true
99
+ });
100
+ const uiUrl = `http://127.0.0.1:${uiPort}${route}`;
101
+ console.log(`[token-studio] UI ${uiUrl}${demo ? ' (Demo Mode)' : ''}`);
102
+ console.log(`[token-studio] API http://127.0.0.1:${apiPort}`);
103
+ if (openBrowser) {
104
+ setTimeout(() => openUrl(uiUrl), 900).unref?.();
105
+ }
106
+ await waitForChildren([server, client]);
107
+ }
108
+
109
+ async function collectCommand() {
110
+ const sources = args.sources || args.collectors || 'claude,codex';
111
+ const confirmed = args.yes || process.env.TOKEN_STUDIO_COLLECT_CONFIRMED === '1'
112
+ || await confirmCollect(sources);
113
+ if (!confirmed) {
114
+ throw new Error('Collection cancelled. No local AI logs were scanned.');
115
+ }
116
+ const collectArgs = ['src/collect.mjs', '--sources', sources];
117
+ if (args.db) collectArgs.push('--db', args.db);
118
+ const child = spawn(process.execPath, collectArgs, {
119
+ cwd: PACKAGE_ROOT,
120
+ env: {
121
+ ...process.env,
122
+ TOKEN_STUDIO_COLLECTORS: sources
123
+ },
124
+ stdio: 'inherit',
125
+ windowsHide: true
126
+ });
127
+ const code = await childExitCode(child);
128
+ process.exitCode = code;
129
+ }
130
+
131
+ async function doctorCommand() {
132
+ const collectors = detectCollectors();
133
+ console.log('Token Studio Doctor');
134
+ console.log(`node=${process.version}`);
135
+ console.log(`cwd=${process.cwd()}`);
136
+ console.log(`db=${args.db || process.env.DB_PATH || 'data/usage.sqlite'}`);
137
+ console.log('');
138
+ console.log('Collectors');
139
+ for (const item of collectors) {
140
+ console.log(`- ${item.id}: ${item.supportStatus}, detected=${item.detected ? 'yes' : 'no'}, privacy=${item.privacyLevel}`);
141
+ if (item.existingRoots.length) console.log(` roots=${item.existingRoots.join('; ')}`);
142
+ if (item.note) console.log(` note=${item.note}`);
143
+ }
144
+ }
145
+
146
+ async function collectorsCommand() {
147
+ if (args.audit) {
148
+ const audit = await auditExperimentalCollectors();
149
+ if (args.json) {
150
+ console.log(JSON.stringify(audit, null, 2));
151
+ return;
152
+ }
153
+ console.log('Token Studio Collector Audit');
154
+ console.log(`auditedAt=${audit.auditedAt}`);
155
+ console.log(`totals: files=${audit.totals.candidateFiles}, usable=${audit.totals.usableTokenRecords}, noToken=${audit.totals.skippedNoTokenRecords}, unsafe=${audit.totals.skippedConversationLikeRecords}, oversized=${audit.totals.skippedOversizedFiles}, parseErrors=${audit.totals.parseErrors}`);
156
+ for (const item of audit.collectors) {
157
+ const s = item.summary;
158
+ console.log(`- ${item.id}: detected=${item.detected ? 'yes' : 'no'}, files=${s.candidateFiles}, usable=${s.usableTokenRecords}, noToken=${s.skippedNoTokenRecords}, unsafe=${s.skippedConversationLikeRecords}, oversized=${s.skippedOversizedFiles}, parseErrors=${s.parseErrors}`);
159
+ }
160
+ return;
161
+ }
162
+
163
+ const collectors = detectCollectors();
164
+ if (args.json) {
165
+ console.log(JSON.stringify({ collectors }, null, 2));
166
+ return;
167
+ }
168
+
169
+ console.log('Token Studio Collectors');
170
+ for (const item of collectors) {
171
+ console.log(`- ${item.id}: ${item.label}`);
172
+ console.log(` status=${item.supportStatus}, default=${item.defaultEnabled ? 'yes' : 'no'}, detected=${item.detected ? 'yes' : 'no'}`);
173
+ console.log(` privacy=${item.privacyLevel}, readsConversationContent=${item.readsConversationContent ? 'yes' : 'no'}, tokenReliability=${item.tokenReliability}`);
174
+ console.log(` fields=${item.dataFields.join(',') || 'none'}`);
175
+ if (item.note) console.log(` note=${item.note}`);
176
+ }
177
+ }
178
+
179
+ async function importUsageCommand() {
180
+ if (args.help) {
181
+ printImportUsageHelp();
182
+ return;
183
+ }
184
+ const format = args.format || 'ccusage-json';
185
+ if (args.apply && args.dryRun) {
186
+ throw new Error('Choose either --apply or --dry-run, not both.');
187
+ }
188
+ const { plan, bridge } = await buildImportUsagePlan(format);
189
+ const summary = {
190
+ ok: true,
191
+ format,
192
+ mode: args.apply ? 'apply' : 'dry-run',
193
+ detectedShape: plan.detectedShape,
194
+ daily: plan.daily.length,
195
+ sessions: plan.sessions.length,
196
+ tokenEvents: plan.tokenEvents.length,
197
+ warnings: plan.warnings,
198
+ bridge: bridge || null
199
+ };
200
+
201
+ if (args.apply) {
202
+ const dbPath = cliDbPath();
203
+ const db = openDb(dbPath);
204
+ try {
205
+ summary.backup = createSqliteBackup(db, dbPath, { reason: format === 'ccusage-cli' ? 'ccusage-cli-import' : 'ccusage-json-import' });
206
+ summary.applied = applyCcusageImport(db, plan);
207
+ } finally {
208
+ db.close();
209
+ }
210
+ }
211
+
212
+ if (args.json) {
213
+ console.log(JSON.stringify(summary, null, 2));
214
+ return;
215
+ }
216
+ const source = bridge ? `ccusage CLI ${bridge.report}` : 'ccusage JSON';
217
+ console.log(`${source} ${summary.mode}: shape=${summary.detectedShape}, daily=${summary.daily}, sessions=${summary.sessions}, token_events=${summary.tokenEvents}`);
218
+ if (summary.backup) console.log(`backup=${summary.backup.path}`);
219
+ for (const warning of summary.warnings.slice(0, 5)) {
220
+ console.log(`warning: ${warning.model || 'unknown'} — ${warning.reason}`);
221
+ }
222
+ }
223
+
224
+ async function buildImportUsagePlan(format) {
225
+ if (format === 'ccusage-json') {
226
+ if (!args.file) {
227
+ throw new Error('import-usage requires --file <path|-> for --format=ccusage-json.');
228
+ }
229
+ const payload = parseCcusageJsonText(readCcusageImportInput(args.file));
230
+ return {
231
+ plan: planCcusageImport(payload, {
232
+ device: args.device || hostname()
233
+ }),
234
+ bridge: null
235
+ };
236
+ }
237
+
238
+ if (format === 'ccusage-cli') {
239
+ const report = String(args.report || 'session').toLowerCase();
240
+ const invocation = ccusageInvocation({ report, ccusageBin: args.ccusageBin });
241
+ await ensureCcusageBridgeConfirmed({ report, commandLabel: invocation.commandLabel });
242
+ const { plan } = await runCcusageCliImportPlan({
243
+ report,
244
+ ccusageBin: args.ccusageBin,
245
+ device: args.device || hostname()
246
+ });
247
+ return {
248
+ plan,
249
+ bridge: {
250
+ report,
251
+ command: invocation.commandLabel
252
+ }
253
+ };
254
+ }
255
+
256
+ throw new Error('import-usage supports --format=ccusage-json or --format=ccusage-cli.');
257
+ }
258
+
259
+ async function ensureCcusageBridgeConfirmed({ report, commandLabel }) {
260
+ if (args.yes || process.env.TOKEN_STUDIO_CCUSAGE_BRIDGE_CONFIRMED === '1') return;
261
+ if (!process.stdin.isTTY) {
262
+ throw new Error('ccusage CLI bridge requires --yes in non-interactive shells because it runs an external local scanner.');
263
+ }
264
+ const confirmed = await confirmCcusageBridge({ report, commandLabel });
265
+ if (!confirmed) {
266
+ throw new Error('ccusage CLI bridge cancelled. No external scanner was run.');
267
+ }
268
+ }
269
+
270
+ async function budgetCommand() {
271
+ if (args.help) {
272
+ printBudgetHelp();
273
+ return;
274
+ }
275
+ const action = args._[0] || 'list';
276
+ const db = openCliDb();
277
+ try {
278
+ if (action === 'list') {
279
+ const profiles = listBudgetProfiles(db);
280
+ if (args.json) {
281
+ console.log(JSON.stringify({ profiles }, null, 2));
282
+ return;
283
+ }
284
+ console.log('Token Studio Budget Profiles');
285
+ if (!profiles.length) {
286
+ console.log('- none');
287
+ return;
288
+ }
289
+ for (const profile of profiles) {
290
+ console.log(`- #${profile.id} ${profile.label}: source=${profile.source || '*'}, window=${profile.windowType || 'rolling'}:${profile.windowMinutes}m, reset=${profile.resetAnchor || '-'}, warn=${Math.round(Number(profile.warningThreshold || 0.75) * 100)}%, tokenBudget=${profile.tokenBudget || '-'}, costBudgetUSD=${profile.costBudgetUSD || '-'}, enabled=${profile.enabled ? 'yes' : 'no'}`);
291
+ }
292
+ return;
293
+ }
294
+ if (action === 'set') {
295
+ const profile = upsertBudgetProfile(db, {
296
+ id: args.id,
297
+ source: args.source || '',
298
+ label: args.label,
299
+ windowType: args.windowType || 'rolling',
300
+ windowMinutes: args.windowMinutes,
301
+ resetAnchor: args.resetAnchor || null,
302
+ warningThreshold: args.warningThreshold ?? 0.75,
303
+ tokenBudget: args.tokenBudget || 0,
304
+ costBudgetUSD: args.costBudgetUsd ?? args.costBudgetUSD ?? 0,
305
+ enabled: args.enabled ?? true
306
+ });
307
+ console.log(args.json ? JSON.stringify({ ok: true, profile }, null, 2) : `saved budget #${profile.id}: ${profile.label}`);
308
+ return;
309
+ }
310
+ if (action === 'delete') {
311
+ const deleted = deleteBudgetProfile(db, { id: args.id });
312
+ console.log(args.json ? JSON.stringify({ ok: true, deleted }, null, 2) : `deleted=${deleted}`);
313
+ return;
314
+ }
315
+ throw new Error('Unknown budget command. Use budget list, budget set, or budget delete.');
316
+ } finally {
317
+ db.close();
318
+ }
319
+ }
320
+
321
+ async function reportCommand() {
322
+ const format = args.format || 'table';
323
+ if (!['table', 'markdown', 'json'].includes(format)) {
324
+ throw new Error('report --format must be table, markdown, or json.');
325
+ }
326
+ const db = openCliDb();
327
+ try {
328
+ const report = buildTerminalReport(db, { period: args.period || 'week' });
329
+ console.log(formatTerminalReport(report, format));
330
+ } finally {
331
+ db.close();
332
+ }
333
+ }
334
+
335
+ async function statuslineCommand() {
336
+ if (args.help) {
337
+ printStatuslineHelp();
338
+ return;
339
+ }
340
+ const format = args.format || 'text';
341
+ if (!['text', 'json'].includes(format)) {
342
+ throw new Error('statusline --format must be text or json.');
343
+ }
344
+ const windowMinutes = Number(args.windowMinutes || 15);
345
+ if (!Number.isFinite(windowMinutes) || windowMinutes <= 0) {
346
+ throw new Error('statusline --window-minutes must be a positive number.');
347
+ }
348
+ const snapshotOptions = {
349
+ windowMinutes,
350
+ source: args.source || 'all'
351
+ };
352
+ let db;
353
+ let snapshot;
354
+ try {
355
+ db = openCliReadOnlyDb();
356
+ snapshot = buildStatuslineSnapshot(db, snapshotOptions);
357
+ } catch (error) {
358
+ if (!/SQLite database not found/i.test(error.message)) throw error;
359
+ snapshot = buildEmptyStatuslineSnapshot({
360
+ ...snapshotOptions,
361
+ warning: 'Local SQLite database not found.'
362
+ });
363
+ } finally {
364
+ db?.close();
365
+ }
366
+ if (format === 'json') {
367
+ console.log(JSON.stringify(snapshot, null, 2));
368
+ return;
369
+ }
370
+ console.log(formatStatuslineText(snapshot, {
371
+ maxWidth: args.maxWidth || 100
372
+ }));
373
+ }
374
+
375
+ async function privacyCheckCommand() {
376
+ const result = runPrivacyCheck({ includeUntracked: Boolean(args.includeUntracked) });
377
+ console.log(formatPrivacyCheckReport(result));
378
+ if (!result.ok) process.exitCode = 2;
379
+ }
380
+
381
+ async function policyCommand() {
382
+ const format = args.format || 'markdown';
383
+ if (!['markdown', 'claude-md', 'agents-md'].includes(format)) {
384
+ throw new Error('policy --format must be markdown, claude-md, or agents-md.');
385
+ }
386
+ let db;
387
+ let sessions = [];
388
+ try {
389
+ db = openCliReadOnlyDb();
390
+ sessions = loadPolicySessions(db);
391
+ } catch (error) {
392
+ if (!/SQLite database not found/i.test(error.message)) throw error;
393
+ } finally {
394
+ db?.close();
395
+ }
396
+ const policy = buildModelPolicy({ sessions });
397
+ console.log(formatModelPolicy(policy, format));
398
+ }
399
+
400
+ async function confirmCollect(sources) {
401
+ if (!process.stdin.isTTY) return false;
402
+ const rl = createInterface({ input, output });
403
+ try {
404
+ console.log('This will scan local AI coding logs for structured token usage only.');
405
+ console.log(`Sources: ${sources}`);
406
+ console.log('It will not read or display conversation content, but it may access local metadata directories.');
407
+ const answer = await rl.question('Type COLLECT to continue: ');
408
+ return answer.trim() === 'COLLECT';
409
+ } finally {
410
+ rl.close();
411
+ }
412
+ }
413
+
414
+ async function confirmCcusageBridge({ report, commandLabel }) {
415
+ const rl = createInterface({ input, output });
416
+ try {
417
+ console.log('This will run ccusage as an external local scanner and pass structured JSON to Token Studio.');
418
+ console.log(`Report: ${report}`);
419
+ console.log(`Command: ${commandLabel}`);
420
+ console.log('Token Studio rejects conversation-like fields and recomputes cost with its official-price table.');
421
+ const answer = await rl.question('Type CCUSAGE to continue: ');
422
+ return answer.trim() === 'CCUSAGE';
423
+ } finally {
424
+ rl.close();
425
+ }
426
+ }
427
+
428
+ async function freePort(start) {
429
+ for (let port = Number(start); port < Number(start) + 80; port += 1) {
430
+ if (await canListen(port)) return port;
431
+ }
432
+ throw new Error(`No free port found near ${start}`);
433
+ }
434
+
435
+ function openCliDb() {
436
+ return openDb(cliDbPath());
437
+ }
438
+
439
+ function openCliReadOnlyDb() {
440
+ return openReadOnlyDb(cliDbPath());
441
+ }
442
+
443
+ function cliDbPath() {
444
+ return resolve(USER_CWD, args.db || process.env.DB_PATH || defaultDbPath);
445
+ }
446
+
447
+ function canListen(port) {
448
+ return new Promise(resolvePort => {
449
+ const server = createServer();
450
+ server.once('error', () => resolvePort(false));
451
+ server.once('listening', () => server.close(() => resolvePort(true)));
452
+ server.listen(port, '127.0.0.1');
453
+ });
454
+ }
455
+
456
+ function waitForChildren(children) {
457
+ return new Promise(resolveRun => {
458
+ let done = false;
459
+ const stop = (code = 0) => {
460
+ if (done) return;
461
+ done = true;
462
+ for (const child of children) {
463
+ if (!child.killed) child.kill();
464
+ }
465
+ resolveRun(code);
466
+ };
467
+ for (const child of children) {
468
+ child.on('exit', code => stop(code ?? 0));
469
+ child.on('error', error => {
470
+ console.error(error.message);
471
+ stop(1);
472
+ });
473
+ }
474
+ process.on('SIGINT', () => stop(0));
475
+ process.on('SIGTERM', () => stop(0));
476
+ });
477
+ }
478
+
479
+ function childExitCode(child) {
480
+ return new Promise(resolveRun => {
481
+ child.on('exit', code => resolveRun(code ?? 0));
482
+ child.on('error', () => resolveRun(1));
483
+ });
484
+ }
485
+
486
+ function openUrl(url) {
487
+ let launcher;
488
+ let launcherArgs;
489
+ if (process.platform === 'win32') {
490
+ launcher = 'cmd';
491
+ launcherArgs = ['/c', 'start', '""', url];
492
+ } else if (process.platform === 'darwin') {
493
+ launcher = 'open';
494
+ launcherArgs = [url];
495
+ } else {
496
+ launcher = 'xdg-open';
497
+ launcherArgs = [url];
498
+ }
499
+ const child = spawn(launcher, launcherArgs, {
500
+ stdio: 'ignore',
501
+ detached: true,
502
+ windowsHide: true
503
+ });
504
+ child.unref();
505
+ }
506
+
507
+ function parseArgs(argv) {
508
+ const parsed = { _: [] };
509
+ for (let i = 0; i < argv.length; i += 1) {
510
+ const arg = argv[i];
511
+ if (arg.startsWith('--') && arg.includes('=')) {
512
+ const [key, value] = arg.slice(2).split(/=(.*)/s);
513
+ parsed[toCamel(key)] = value;
514
+ } else if (arg.startsWith('--')) {
515
+ const key = toCamel(arg.slice(2));
516
+ const next = argv[i + 1];
517
+ if (!next || next.startsWith('--')) {
518
+ parsed[key] = true;
519
+ } else {
520
+ parsed[key] = next;
521
+ i += 1;
522
+ }
523
+ } else {
524
+ parsed._.push(arg);
525
+ }
526
+ }
527
+ return parsed;
528
+ }
529
+
530
+ function toCamel(value) {
531
+ return value.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
532
+ }
533
+
534
+ function printHelp() {
535
+ console.log([
536
+ 'Token Studio ROI',
537
+ '',
538
+ 'Commands:',
539
+ ' token-studio demo [--seed-only] [--db data/demo.sqlite]',
540
+ ' token-studio start [--db data/usage.sqlite] [--api-port 4173] [--ui-port 5173]',
541
+ ' token-studio open [--db data/usage.sqlite] [--api-port 4173] [--ui-port 5173]',
542
+ ' token-studio live [--db data/usage.sqlite]',
543
+ ' token-studio statusline [--db data/usage.sqlite] [--window-minutes 15] [--format text|json]',
544
+ ' token-studio collectors [--json]',
545
+ ' token-studio collectors --audit [--json]',
546
+ ' token-studio import-usage --format=ccusage-json --file <path|-> [--dry-run|--apply]',
547
+ ' token-studio import-usage --format=ccusage-cli --report=<daily|weekly|monthly|session|blocks> [--dry-run|--apply] [--yes]',
548
+ ' token-studio budget list|set|delete',
549
+ ' token-studio report --period=week --format=table|markdown|json',
550
+ ' token-studio policy --format=markdown|claude-md|agents-md',
551
+ ' token-studio collect --sources claude,codex [--yes]',
552
+ ' token-studio doctor',
553
+ ' token-studio privacy-check [--include-untracked]'
554
+ ].join('\n'));
555
+ }
556
+
557
+ function printBudgetHelp() {
558
+ console.log([
559
+ 'Token Studio Budget Profiles',
560
+ '',
561
+ 'Budgets are local custom guardrails. They are not provider subscription quotas.',
562
+ '',
563
+ 'Examples:',
564
+ ' token-studio budget list',
565
+ ' token-studio budget set --source "Codex CLI" --label "Codex 15m" --window-minutes 15 --token-budget 50000',
566
+ ' token-studio budget set --source "Claude Code" --label "Claude 5h" --window-type fixed --window-minutes 300 --reset-anchor 2026-06-17T09:00:00Z --warning-threshold 0.75 --token-budget 500000',
567
+ ' token-studio budget delete --id 1',
568
+ '',
569
+ 'Options:',
570
+ ' --window-type rolling|fixed',
571
+ ' --reset-anchor <ISO datetime> fixed windows only',
572
+ ' --warning-threshold <0-1> default 0.75'
573
+ ].join('\n'));
574
+ }
575
+
576
+ function printStatuslineHelp() {
577
+ console.log([
578
+ 'Token Studio Statusline Guardrails',
579
+ '',
580
+ 'Read-only SQLite statusline for terminal prompts, tmux, scripts, or Claude Code statusline.',
581
+ '',
582
+ 'Examples:',
583
+ ' token-studio statusline --format=text --window-minutes=15 --max-width=100',
584
+ ' token-studio statusline --format=json --window-minutes=15',
585
+ '',
586
+ 'Claude Code statusline command:',
587
+ ' npx token-studio statusline --format=text --window-minutes=15 --max-width=100',
588
+ '',
589
+ 'tmux:',
590
+ ' set -g status-right "#(npx token-studio statusline --format=text --max-width=80)"',
591
+ '',
592
+ 'PowerShell prompt:',
593
+ ' function prompt { "$(npx token-studio statusline --format=text --max-width=80) PS $($PWD)> " }',
594
+ '',
595
+ 'Privacy:',
596
+ ' statusline only reads local SQLite. It does not scan logs, run ccusage, or start a background process.'
597
+ ].join('\n'));
598
+ }
599
+
600
+ function printImportUsageHelp() {
601
+ console.log([
602
+ 'Token Studio ccusage Import',
603
+ '',
604
+ 'Default mode is dry-run. It validates shape and counts rows without writing SQLite.',
605
+ '',
606
+ 'Examples:',
607
+ ' token-studio import-usage --format=ccusage-json --file ccusage.json --dry-run',
608
+ ' token-studio import-usage --format=ccusage-json --file ccusage.json --apply',
609
+ ' ccusage daily --json | token-studio import-usage --format=ccusage-json --file - --dry-run',
610
+ ' token-studio import-usage --format=ccusage-cli --report=session --dry-run --yes',
611
+ ' token-studio import-usage --format=ccusage-cli --report=blocks --apply --yes',
612
+ ' token-studio import-usage --format=ccusage-cli --report=daily --ccusage-bin ccusage --dry-run',
613
+ '',
614
+ 'Supported shapes:',
615
+ ' daily, project daily, weekly, session, blocks, monthly',
616
+ '',
617
+ 'ccusage CLI bridge reports:',
618
+ ` ${CCUSAGE_CLI_REPORTS.join(', ')}`,
619
+ '',
620
+ 'Privacy:',
621
+ ' prompt, response, messages, transcript, diff, content, and text fields are rejected.',
622
+ ' ccusage-cli runs an external local scanner only after interactive confirmation or --yes.',
623
+ ' Imported cost fields are ignored; Token Studio recomputes official-price conversion.'
624
+ ].join('\n'));
625
+ }
626
+
627
+ function loadPolicySessions(db) {
628
+ return db.prepare(`
629
+ SELECT
630
+ COALESCE(a.work_purpose, '未说明') AS workPurpose,
631
+ COALESCE(a.work_stage, '未说明') AS workStage,
632
+ COALESCE(a.value_level, '未评估') AS valueLevel,
633
+ COALESCE(a.output_status, '未标注') AS outputStatus,
634
+ s.total_tokens AS totalTokens,
635
+ s.cost_usd AS costUSD
636
+ FROM session_usage s
637
+ LEFT JOIN session_annotations a
638
+ ON a.device = s.device
639
+ AND a.source = s.source
640
+ AND a.session_id = s.session_id
641
+ ORDER BY s.total_tokens DESC
642
+ `).all();
643
+ }