sprag-cli 3.40.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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +637 -0
  3. package/README.md +758 -0
  4. package/bin/cli.js +801 -0
  5. package/examples/statusline-command.ps1 +43 -0
  6. package/examples/statusline-command.sh +36 -0
  7. package/package.json +62 -0
  8. package/presets/cohesion/cohesion-en.md +26 -0
  9. package/presets/doc2md/convert.py +363 -0
  10. package/presets/korean-style/LICENSE-fluent-korean +21 -0
  11. package/presets/korean-style/fluent-korean.md +52 -0
  12. package/presets/korean-style/supplement.md +93 -0
  13. package/presets/model-rules.json +115 -0
  14. package/presets/ratchet-rules.json +38 -0
  15. package/src/advice.js +564 -0
  16. package/src/agents.js +52 -0
  17. package/src/brief.js +264 -0
  18. package/src/caps-cache.js +84 -0
  19. package/src/cli-args.js +51 -0
  20. package/src/cohesion.js +70 -0
  21. package/src/commands/brief.js +31 -0
  22. package/src/commands/cohesion.js +59 -0
  23. package/src/commands/compact-window.js +93 -0
  24. package/src/commands/doc2md.js +166 -0
  25. package/src/commands/feedback.js +132 -0
  26. package/src/commands/handoff.js +33 -0
  27. package/src/commands/harness.js +459 -0
  28. package/src/commands/history.js +46 -0
  29. package/src/commands/install.js +358 -0
  30. package/src/commands/korean.js +220 -0
  31. package/src/commands/last.js +151 -0
  32. package/src/commands/mode.js +46 -0
  33. package/src/commands/route-scan.js +454 -0
  34. package/src/commands/seed.js +105 -0
  35. package/src/commands/uninstall.js +42 -0
  36. package/src/commands/update-check.js +77 -0
  37. package/src/commands/upgrade.js +68 -0
  38. package/src/compact-window.js +205 -0
  39. package/src/config.js +232 -0
  40. package/src/cost.js +253 -0
  41. package/src/debug.js +29 -0
  42. package/src/demo.js +331 -0
  43. package/src/doc2md-ledger.cjs +227 -0
  44. package/src/doc2md.cjs +997 -0
  45. package/src/fig2md-runner.cjs +21 -0
  46. package/src/fig2md.cjs +191 -0
  47. package/src/first-run-note.js +63 -0
  48. package/src/format-time.js +44 -0
  49. package/src/formatters/csv.js +8 -0
  50. package/src/formatters/json.js +3 -0
  51. package/src/formatters/statusline.js +750 -0
  52. package/src/formatters/table.js +299 -0
  53. package/src/handoff.js +161 -0
  54. package/src/harness-analyzer.cjs +264 -0
  55. package/src/harness-templates.js +153 -0
  56. package/src/harness.js +613 -0
  57. package/src/history.js +383 -0
  58. package/src/hook-manager.js +96 -0
  59. package/src/hook.cjs +196 -0
  60. package/src/installer.js +614 -0
  61. package/src/korean-lint.cjs +303 -0
  62. package/src/korean-style.js +187 -0
  63. package/src/litellm-budget.js +223 -0
  64. package/src/model-alias.js +484 -0
  65. package/src/model-rules.js +527 -0
  66. package/src/month-spend.js +47 -0
  67. package/src/parser.js +330 -0
  68. package/src/paths.js +41 -0
  69. package/src/prompt.js +52 -0
  70. package/src/route-scan.js +832 -0
  71. package/src/savings-ledger.js +137 -0
  72. package/src/seed-rules.js +280 -0
  73. package/src/session-cache.js +160 -0
  74. package/src/session-records.js +188 -0
  75. package/src/stats.js +380 -0
  76. package/src/stdin-payload.js +122 -0
  77. package/src/subagent-records.js +214 -0
  78. package/src/update-check.js +201 -0
  79. package/src/window-labels.js +64 -0
package/bin/cli.js ADDED
@@ -0,0 +1,801 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * claude-token-saver CLI (formerly claude-cache-monitor)
5
+ *
6
+ * Usage:
7
+ * npx claude-token-saver # default report (last 30 days)
8
+ * npx claude-token-saver --days 7 # last 7 days
9
+ * npx claude-token-saver --format json # JSON output
10
+ * npx claude-token-saver --format csv # CSV output
11
+ * npx claude-token-saver --project myproj # filter by project
12
+ * npx claude-token-saver route-scan # detect recurring easy work → haiku-delegation candidates
13
+ * npx claude-token-saver install # set up skill/hooks/statusline; asks about harness + Korean guidance
14
+ * npx claude-token-saver install --yes # take the defaults without asking (same as --no-input)
15
+ * npx claude-token-saver --install-hook # install PostToolUse hook
16
+ * npx claude-token-saver --uninstall-hook # remove hook
17
+ * npx claude-token-saver --hook-run # internal: called by hook
18
+ * npx claude-token-saver --statusline # one-line output for Claude Code statusline API
19
+ * npx claude-token-saver --statusline --verbose # longer labels
20
+ * npx claude-token-saver --statusline --no-color # strip ANSI colors
21
+ * npx claude-token-saver --statusline --icon # use 🧠 ⏳ 💰 icons
22
+ * npx claude-token-saver --statusline --no-timer # hide the TTL countdown
23
+ * npx claude-token-saver --statusline --single-line # legacy 1-line layout (no routing-totals headline)
24
+ * npx claude-token-saver --statusline --exclude-session <path>
25
+ * # exclude a JSONL path from lastActivity
26
+ * # (or set CACHE_MONITOR_EXCLUDE_SESSION env var)
27
+ */
28
+
29
+ import { readFileSync } from 'node:fs';
30
+ import { fileURLToPath } from 'node:url';
31
+ import { dirname, isAbsolute, join } from 'node:path';
32
+
33
+ import {
34
+ readStdinJson,
35
+ extractCaps,
36
+ extractContextUsage,
37
+ extractModel,
38
+ } from '../src/stdin-payload.js';
39
+
40
+ import { parseAllSessions, getLastUserMessageTime } from '../src/parser.js';
41
+ import {
42
+ dailyTrend,
43
+ ttlBreakdown,
44
+ detectAnomalies,
45
+ summary,
46
+ detectSpikes,
47
+ detectContextWindow,
48
+ sessionMetrics,
49
+ diagnoseSession,
50
+ } from '../src/stats.js';
51
+ import { estimateCost } from '../src/cost.js';
52
+ import { chipForIssues } from '../src/advice.js';
53
+ import { debug } from '../src/debug.js';
54
+ import { createArgs } from '../src/cli-args.js';
55
+ import { updateStatus, maybeSpawnUpdateCheck } from '../src/update-check.js';
56
+
57
+ const args = process.argv.slice(2);
58
+
59
+ const PKG_VERSION = (() => {
60
+ try {
61
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
62
+ return JSON.parse(readFileSync(pkgPath, 'utf8')).version || '';
63
+ } catch {
64
+ return '';
65
+ }
66
+ })();
67
+
68
+ const { getArg, hasFlag, numArg } = createArgs(args);
69
+
70
+ /**
71
+ * Version/update state for the statusline chip. Reads a cache file and, when
72
+ * that cache has aged past the check interval, kicks a detached child to
73
+ * refresh it for a later render. Never awaits the network, never throws into
74
+ * the render: a registry outage must not cost the statusline its other chips.
75
+ */
76
+ function readUpdateChip() {
77
+ try {
78
+ maybeSpawnUpdateCheck(PKG_VERSION);
79
+ return updateStatus(PKG_VERSION);
80
+ } catch (e) {
81
+ debug('update-check:chip', e);
82
+ return null;
83
+ }
84
+ }
85
+
86
+ // Subcommands this build knows how to run. Used only by the guard below.
87
+ const KNOWN_SUBCOMMANDS = new Set([
88
+ 'last', 'brief', 'history', 'handoff', 'install', 'uninstall', 'mode', 'korean', 'cohesion',
89
+ 'doc2md', 'harness', 'route-scan', 'compact-window', 'update-check', 'upgrade',
90
+ 'seed', 'litellm-budget', 'feedback',
91
+ ]);
92
+
93
+ const USAGE = `claude-token-saver — Claude Code token usage, cache health, and model routing
94
+
95
+ Usage:
96
+ claude-token-saver default report (last 30 days)
97
+ claude-token-saver --days 7 last 7 days
98
+ claude-token-saver --format json JSON output
99
+ claude-token-saver --format csv CSV output
100
+ claude-token-saver --project myproj filter by project
101
+ claude-token-saver route-scan detect recurring easy work → delegation candidates
102
+ claude-token-saver install set up skill/hooks/statusline
103
+ claude-token-saver install --yes take the defaults without asking
104
+ claude-token-saver uninstall remove everything install added
105
+ claude-token-saver harness check score the harness setup in CLAUDE.md
106
+ claude-token-saver harness analyze run the harness transcript analysis manually
107
+ claude-token-saver last most recent warning + how to handle it
108
+ claude-token-saver history recent warning transitions
109
+ claude-token-saver handoff write a session handoff file
110
+ claude-token-saver feedback "<msg>" file a bug report / feature request (no browser needed)
111
+ claude-token-saver upgrade install the latest release
112
+ claude-token-saver --install-hook install cache-monitor PostToolUse hook
113
+ claude-token-saver --uninstall-hook remove that hook
114
+ claude-token-saver --statusline one-line output for Claude Code statusline
115
+ --verbose / --no-color / --icon / --no-timer / --single-line
116
+
117
+ Run any subcommand with --help for its own options where available.
118
+ Bug reports & feature requests: https://github.com/rootstudioyaml/claude-token-saver/issues
119
+ `;
120
+
121
+ async function main() {
122
+ // Help must never fall through to the default report — that runs a full
123
+ // 30-day scan, which is the opposite of what someone asking for help wants.
124
+ if (hasFlag('--help') || hasFlag('-h') || args[0] === 'help') {
125
+ process.stdout.write(USAGE);
126
+ return;
127
+ }
128
+
129
+ // A hook invocation names a subcommand and expects either silence or that
130
+ // subcommand's own protocol on stdout. If this build does not have the
131
+ // subcommand — an older global install against a newer settings.json, which
132
+ // is exactly what a mid-upgrade machine looks like — falling through to the
133
+ // default report would push a full table into the hook stream on every
134
+ // matching tool call. Say nothing instead.
135
+ if (hasFlag('--hook') && args[0] && !KNOWN_SUBCOMMANDS.has(args[0])) {
136
+ return;
137
+ }
138
+
139
+ // Subcommand: last — print the most recent warning + how to handle it.
140
+ // Designed for the auto-trigger skill so the user immediately sees
141
+ // "what just fired and how to fix it" without having to read the whole
142
+ // history file.
143
+ // claude-token-saver last # search last 1 day
144
+ // claude-token-saver last --days 7 # widen the lookback
145
+ if (args[0] === 'last') {
146
+ return (await import('../src/commands/last.js')).run({ numArg });
147
+ }
148
+
149
+ // Subcommand: history — print recent warning transitions captured by the
150
+ // statusline. One markdown file per day, persisted under the platform-
151
+ // specific user-data dir.
152
+ // claude-token-saver history # last 7 days
153
+ // claude-token-saver history --days 30 # custom window
154
+ // claude-token-saver history --list # just list available dates
155
+ if (args[0] === 'history') {
156
+ return (await import('../src/commands/history.js')).run({ hasFlag, numArg });
157
+ }
158
+
159
+ // Subcommand: handoff — write a HANDOFF-YYYY-MM-DD-HHMM.md template in cwd
160
+ // capturing git status + the latest cap snapshot, so a fresh Claude Code
161
+ // session can pick up where this one stopped. Pairs with the cap-warn chip:
162
+ // when statusline shows 🚨 5H 90%+, run this to back up state before the cap
163
+ // hits.
164
+ // claude-token-saver handoff # write to cwd
165
+ // claude-token-saver handoff --cwd PATH # custom directory
166
+ // Subcommand: feedback — file a bug report / feature request without a
167
+ // browser. Tries gh CLI, then an anonymous form POST, then a local save
168
+ // with a prefilled GitHub issue URL. See src/commands/feedback.js.
169
+ if (args[0] === 'feedback') {
170
+ const { userDataDir } = await import('../src/paths.js');
171
+ return (await import('../src/commands/feedback.js')).run({ args, getArg, version: PKG_VERSION, dataDir: userDataDir() });
172
+ }
173
+
174
+ if (args[0] === 'handoff') {
175
+ return (await import('../src/commands/handoff.js')).run({ getArg });
176
+ }
177
+
178
+ // Subcommand: install — write the Claude Code auto-trigger skill so the
179
+ // user can just mention chip wording and Claude responds. v2.6.0 dropped
180
+ // the redundant /token-monitor slash command in favor of the skill alone;
181
+ // a legacy command file is removed automatically. Cross-platform.
182
+ // claude-token-saver install # install/update the skill
183
+ // claude-token-saver install --force # overwrite existing skill file
184
+ if (args[0] === 'install') {
185
+ return (await import('../src/commands/install.js')).run({ hasFlag });
186
+ }
187
+
188
+ // The counterpart to `install`. It was in the known-subcommand list from the
189
+ // start but had no dispatch, so it fell through to the usage report and
190
+ // exited non-zero — an unhelpful answer to "remove this".
191
+ if (args[0] === 'uninstall') {
192
+ return (await import('../src/commands/uninstall.js')).run({ hasFlag, args });
193
+ }
194
+
195
+ // Subcommand: mode — persist statusline preferences so future runs pick
196
+ // them up without flags or wrapper edits.
197
+ // claude-token-saver mode # show current config
198
+ // claude-token-saver mode icon verbose # set icon + verbose
199
+ // claude-token-saver mode reset # clear back to defaults
200
+ if (args[0] === 'mode') {
201
+ return (await import('../src/commands/mode.js')).run({ args });
202
+ }
203
+
204
+ // Subcommand: route-scan — detect recurring easy work on expensive models
205
+ // and propose model-delegation ratchet rules. Zero token cost, fully local.
206
+ // claude-token-saver route-scan # scan (24h cache) + print candidates
207
+ // claude-token-saver route-scan --refresh # force rescan
208
+ // claude-token-saver route-scan --days 30 # wider lookback
209
+ // claude-token-saver route-scan --hook # SessionStart hook mode (context injection)
210
+ // claude-token-saver route-scan dismiss <N> # mute candidate R<N>
211
+ // Promote a candidate to a ratchet rule (scope is always explicit):
212
+ // claude-token-saver harness promote R<N> --project|--global
213
+ // brief --hook — UserPromptSubmit hook mode: per-session, change-triggered
214
+ // briefing of state the statusline can only chip (ctx tier crossings,
215
+ // mid-session route/rule-health changes). Silent when nothing changed.
216
+ if (args[0] === 'brief') {
217
+ return (await import('../src/commands/brief.js')).run({ hasFlag });
218
+ }
219
+
220
+ if (args[0] === 'route-scan') {
221
+ return (await import('../src/commands/route-scan.js')).run({ args, hasFlag, numArg });
222
+ }
223
+
224
+ // Subcommand: korean — Korean writing guidance injected at session start,
225
+ // so the rules apply in every project without an output-style switch.
226
+ // claude-token-saver korean on | off | status | show
227
+ if (args[0] === 'korean') {
228
+ return (await import('../src/commands/korean.js')).run({ args, hasFlag });
229
+ }
230
+
231
+ // Subcommand: cohesion — English sentence-connection guidance injected at
232
+ // session start; the language-neutral half of the Korean supplement.
233
+ // claude-token-saver cohesion on | off | status | show
234
+ if (args[0] === 'cohesion') {
235
+ return (await import('../src/commands/cohesion.js')).run({ args, hasFlag });
236
+ }
237
+
238
+ // Subcommand: doc2md — convert pptx/xlsx/pdf/docx to Markdown before the
239
+ // model reads them, so an unreadable binary never enters the context window.
240
+ // claude-token-saver doc2md on | off | <file> | --clean
241
+ if (args[0] === 'doc2md') {
242
+ return (await import('../src/commands/doc2md.js')).run({ args, hasFlag });
243
+ }
244
+
245
+ // Subcommand: seed — register the bundled starter rules (model-fitting
246
+ // presets + curated ratchet rules), one answer at a time.
247
+ // claude-token-saver seed | seed accept <id> --global|--project | seed skip <id>
248
+ if (args[0] === 'seed') {
249
+ return (await import('../src/commands/seed.js')).run({ args, hasFlag });
250
+ }
251
+
252
+ // Subcommand: harness — manage the project's CLAUDE.md harness rules.
253
+ // claude-token-saver harness init # write CLAUDE.md (5 sections) + ratchet.md
254
+ // claude-token-saver harness uninit # remove harness block from CLAUDE.md (backup kept)
255
+ // claude-token-saver harness check # show 🅷 N/5 + which sections are missing
256
+ // claude-token-saver harness promote "<rule>" # append a rule to ratchet.md
257
+ // claude-token-saver harness pull [--global|--project] # register the package's curated preset rules (default global)
258
+ // claude-token-saver harness off | on # toggle the statusline 🅷 segment
259
+ if (args[0] === 'harness') {
260
+ return (await import('../src/commands/harness.js')).run({ args, hasFlag });
261
+ }
262
+
263
+ // Subcommand: compact-window — audit / pin Claude Code's autoCompactWindow.
264
+ // On a 1M-context model, compaction only fires near 800k unless the window is
265
+ // capped; 200k sessions are exempt.
266
+ // claude-token-saver compact-window # status
267
+ // claude-token-saver compact-window set --global # pin 200k (~/.claude/settings.json)
268
+ // claude-token-saver compact-window set --project # pin 200k (<root>/.claude/settings.json)
269
+ // claude-token-saver compact-window off | on # toggle the statusline warning
270
+ if (args[0] === 'compact-window') {
271
+ return (await import('../src/commands/compact-window.js')).run({ args, hasFlag });
272
+ }
273
+
274
+ // `--version` / `-v` — the flag every CLI is expected to answer. Until now
275
+ // the version was only visible in the table view's footer, which meant
276
+ // "which version am I on" required running a full report.
277
+ if (hasFlag('--version') || hasFlag('-v')) {
278
+ console.log(PKG_VERSION);
279
+ return;
280
+ }
281
+
282
+ // Subcommand: update-check — the registry lookup behind the ⬆ statusline
283
+ // chip and the session-start upgrade offer.
284
+ // claude-token-saver update-check # print cached status
285
+ // claude-token-saver update-check --refresh # hit the registry now (detached child uses this)
286
+ // claude-token-saver update-check --dismiss # stop offering THIS version at session start
287
+ if (args[0] === 'update-check') {
288
+ return (await import('../src/commands/update-check.js')).run({
289
+ hasFlag,
290
+ version: PKG_VERSION,
291
+ });
292
+ }
293
+
294
+ // Subcommand: litellm-budget · LiteLLM 게이트웨이 키의 max_budget/spend 조회.
295
+ // claude-token-saver litellm-budget # 캐시된 예산 상태 출력
296
+ // claude-token-saver litellm-budget --refresh # 지금 프록시에 물어봄 (detached 자식이 사용)
297
+ if (args[0] === 'litellm-budget') {
298
+ const { gatewayEnv, readBudgetState, refreshBudgetState } = await import('../src/litellm-budget.js');
299
+ const quiet = hasFlag('--quiet');
300
+ if (hasFlag('--refresh')) {
301
+ try {
302
+ const next = await refreshBudgetState();
303
+ if (!quiet) console.log(JSON.stringify(next, null, 2));
304
+ } catch (e) {
305
+ debug('litellm-budget:refresh', e);
306
+ if (!quiet) console.error(`litellm-budget refresh failed: ${e.message}`);
307
+ process.exitCode = 1;
308
+ }
309
+ return;
310
+ }
311
+ const gw = gatewayEnv();
312
+ if (!gw) {
313
+ console.log('게이트웨이가 감지되지 않았습니다 (ANTHROPIC_BASE_URL + 키 필요).');
314
+ return;
315
+ }
316
+ console.log(JSON.stringify(readBudgetState(), null, 2));
317
+ return;
318
+ }
319
+
320
+ // Subcommand: upgrade — run the install command that matches how this copy
321
+ // got here, then confirm the new version.
322
+ // claude-token-saver upgrade # install the latest release
323
+ // claude-token-saver upgrade --print # just show the command, run nothing
324
+ if (args[0] === 'upgrade') {
325
+ return (await import('../src/commands/upgrade.js')).run({
326
+ hasFlag,
327
+ version: PKG_VERSION,
328
+ });
329
+ }
330
+
331
+ // Hook management
332
+ if (hasFlag('--install-hook')) {
333
+ const { installHook } = await import('../src/hook-manager.js');
334
+ const threshold = numArg('--threshold', { dflt: 0.7, min: 0, max: 1 });
335
+ await installHook({ threshold });
336
+ return;
337
+ }
338
+
339
+ if (hasFlag('--uninstall-hook')) {
340
+ const { uninstallHook } = await import('../src/hook-manager.js');
341
+ await uninstallHook();
342
+ return;
343
+ }
344
+
345
+ // Hook internal execution
346
+ if (hasFlag('--hook-run')) {
347
+ await import('../src/hook.cjs');
348
+ return;
349
+ }
350
+
351
+ // Statusline mode shortcut
352
+ const isStatusline = hasFlag('--statusline') || getArg('--format') === 'statusline';
353
+
354
+ // Demo mode — render synthetic warning-case data through the real
355
+ // formatter for screencasts/marketing GIFs. `--demo cycle` rotates through
356
+ // every scenario based on wall clock so a screen recorder picks them up.
357
+ const demoArg = getArg('--demo');
358
+
359
+ // `claude-token-saver --demo table` (no --statusline) — full table view
360
+ // with all six issue drill-downs at once, for marketing screencasts.
361
+ if (!isStatusline && demoArg === 'table') {
362
+ const { buildTableDemoData } = await import('../src/demo.js');
363
+ const { formatReport } = await import('../src/formatters/table.js');
364
+ const data = buildTableDemoData({ version: PKG_VERSION });
365
+ console.log(formatReport(data));
366
+ return;
367
+ }
368
+
369
+ if (isStatusline && demoArg) {
370
+ const { buildScenarioData, listScenarios } = await import('../src/demo.js');
371
+ const { statuslineDefaults } = await import('../src/config.js');
372
+ const cfg = statuslineDefaults();
373
+ const cycleSeconds = numArg('--demo-cycle-sec', { dflt: 3, min: 0.1 });
374
+ const data = buildScenarioData(demoArg, {
375
+ cycleSeconds,
376
+ windowHours: cfg.windowHours,
377
+ windowLabel: cfg.windowLabel,
378
+ days: cfg.windowHours / 24,
379
+ version: PKG_VERSION,
380
+ });
381
+ if (!data) {
382
+ const known = listScenarios().map((s) => s.name).concat(['cycle']).join(', ');
383
+ console.error(`Unknown demo scenario: ${demoArg}`);
384
+ console.error(`Valid: ${known}`);
385
+ process.exit(1);
386
+ }
387
+ const { formatReport } = await import('../src/formatters/statusline.js');
388
+ const isIcon = hasFlag('--icon')
389
+ ? true
390
+ : (hasFlag('--no-icon') || hasFlag('--text') ? false : cfg.icon);
391
+ const isVerbose = hasFlag('--verbose')
392
+ ? true
393
+ : (hasFlag('--no-verbose') || hasFlag('--compact') ? false : cfg.verbose);
394
+ const showTimer = hasFlag('--no-timer') ? false : cfg.timer;
395
+ const colorOk = !hasFlag('--no-color') && !process.env.NO_COLOR && cfg.color;
396
+ const segmentsArg = getArg('--segments');
397
+ const segments = segmentsArg
398
+ ? segmentsArg.split(',').map((s) => s.trim()).filter(Boolean)
399
+ : null;
400
+ const out = formatReport(data, {
401
+ color: colorOk,
402
+ verbose: isVerbose,
403
+ timer: showTimer,
404
+ mode: isIcon ? 'icon' : 'text',
405
+ segments,
406
+ singleLine: hasFlag('--single-line'),
407
+ });
408
+ // For `cycle` mode, prefix with the scenario label so the screen recorder
409
+ // shows what the viewer is looking at (only when explicitly requested).
410
+ if (demoArg === 'cycle' && hasFlag('--demo-label')) {
411
+ const gray = colorOk ? '\x1b[90m' : '';
412
+ const reset = colorOk ? '\x1b[0m' : '';
413
+ console.log(`${gray}[${data._demoLabel}]${reset} ${out}`);
414
+ } else {
415
+ console.log(out);
416
+ }
417
+ return;
418
+ }
419
+
420
+ // Report generation
421
+ // Statusline window comes from persisted config — hours-precise so users
422
+ // can pick `1h` / `6h` etc, not just whole days. Other formats default to
423
+ // 30 days as before.
424
+ let windowHours = 30 * 24;
425
+ let windowLabel = '30d';
426
+ if (isStatusline) {
427
+ const { statuslineDefaults } = await import('../src/config.js');
428
+ const d = statuslineDefaults();
429
+ windowHours = d.windowHours;
430
+ windowLabel = d.windowLabel;
431
+ }
432
+ // CLI overrides: --hours wins over --days; both win over config.
433
+ const hoursArg = numArg('--hours', { min: 0 });
434
+ const daysArg = numArg('--days', { min: 0 }) ?? numArg('-d', { min: 0 });
435
+ if (hoursArg !== undefined) {
436
+ windowHours = hoursArg;
437
+ windowLabel = `${windowHours}h`;
438
+ } else if (daysArg !== undefined) {
439
+ windowHours = daysArg * 24;
440
+ windowLabel = `${daysArg}d`;
441
+ }
442
+ const days = windowHours / 24;
443
+ const format = isStatusline ? 'statusline' : (getArg('--format') || getArg('-f') || 'table');
444
+ const projectFilter = getArg('--project') || getArg('-p');
445
+
446
+ if (format === 'table') {
447
+ process.stderr.write('Scanning session files...\n');
448
+ }
449
+
450
+ // The current Claude Code session is only excluded from the lastActivity
451
+ // timer (so the agent's own tool calls don't reset the countdown). It MUST
452
+ // still feed ttlBreakdown — otherwise when the user's only recent traffic
453
+ // lives in the current session, the bucket signal collapses to empty and
454
+ // the statusline falsely flips to the 5m default. (See issue: Max users
455
+ // seeing "Cache expires 5:00" on idle even though their plan is 1h.)
456
+ const excludeSessionPath =
457
+ getArg('--exclude-session') || process.env.CACHE_MONITOR_EXCLUDE_SESSION || undefined;
458
+
459
+ const sessions = await parseAllSessions({ days, projectFilter });
460
+
461
+ if (sessions.length === 0) {
462
+ // Statusline must always emit a single line (no multi-line help spam every
463
+ // 300ms) — but the stdin payload (rate limits, model) is still live even
464
+ // with an empty analysis window (e.g. `mode 1h` + idle), and cap-warn /
465
+ // harness are exactly the signals that must not vanish then.
466
+ if (format === 'statusline') {
467
+ const { formatNoSession } = await import('../src/formatters/statusline.js');
468
+ const { statuslineDefaults } = await import('../src/config.js');
469
+ const cfg = statuslineDefaults();
470
+ const colorOk = !hasFlag('--no-color') && !process.env.NO_COLOR && cfg.color;
471
+ const isIcon = hasFlag('--icon')
472
+ ? true
473
+ : (hasFlag('--no-icon') || hasFlag('--text') ? false : cfg.icon);
474
+ const stdinJson = readStdinJson();
475
+ const caps = extractCaps(stdinJson);
476
+ const model = extractModel(stdinJson);
477
+ if (caps || model) {
478
+ try {
479
+ const { persistSnapshot } = await import('../src/caps-cache.js');
480
+ persistSnapshot({ caps, model });
481
+ } catch (e) { debug('caps-cache:persist', e); }
482
+ }
483
+ console.log(formatNoSession(
484
+ { caps, model, windowLabel, version: PKG_VERSION, update: readUpdateChip() },
485
+ { color: colorOk, mode: isIcon ? 'icon' : 'text' },
486
+ ));
487
+ return;
488
+ }
489
+ // JSON mode stays JSON. A caller that asked for machine-readable output
490
+ // and got a paragraph of advice has to parse prose to find out nothing
491
+ // was found, which is exactly the failure this format exists to avoid.
492
+ if (getArg('--format') === 'json') {
493
+ console.log(JSON.stringify({
494
+ sessions: 0,
495
+ days,
496
+ error: 'no-session-data',
497
+ message: 'No Claude Code session logs found for the given period.',
498
+ }, null, 2));
499
+ process.exit(1);
500
+ }
501
+ console.log('No session data found for the given period.');
502
+ console.log('');
503
+ console.log('This tool analyzes Claude Code session logs (~/.claude/projects/).');
504
+ console.log('');
505
+ console.log('Possible causes:');
506
+ console.log(' - You haven\'t used Claude Code in the last ' + days + ' days');
507
+ console.log(' - You\'re using the Claude API directly (SDK/curl) without Claude Code');
508
+ console.log(' → This tool requires Claude Code. API-only usage does not generate session logs.');
509
+ console.log(' - Try increasing the period: --days 90');
510
+ process.exit(1);
511
+ }
512
+
513
+ const trend = dailyTrend(sessions);
514
+ const ttl = ttlBreakdown(sessions);
515
+ const sum = summary(sessions);
516
+ const anomalies = detectAnomalies(trend);
517
+ const cost = estimateCost(sum, sessions[0]?.model);
518
+ const spikeReport = detectSpikes(sessions, { recentHours: 24, multiplier: 3 });
519
+ const contextWindow = detectContextWindow(sessions, { recentHours: 24 });
520
+
521
+ // Claude Code feeds the statusline command a JSON blob on stdin every
522
+ // refresh. Pull rate_limits + model out of it so we can surface cap-warn
523
+ // (>=90%) chips, always-on usage segments, the model chip, record cap
524
+ // transitions, and seed the table view's warning box. The table path falls
525
+ // back to the most-recent cached snapshot so the table view (which
526
+ // doesn't pipe stdin) still has the data.
527
+ const stdinJson = readStdinJson();
528
+ let caps = extractCaps(stdinJson);
529
+ let model = extractModel(stdinJson);
530
+ const ctxLive = extractContextUsage(stdinJson);
531
+ if (isStatusline && (caps || model)) {
532
+ try {
533
+ const { persistSnapshot } = await import('../src/caps-cache.js');
534
+ persistSnapshot({ caps, model });
535
+ } catch (e) {
536
+ debug('caps-cache:persist', e);
537
+ }
538
+ }
539
+ // LiteLLM 게이트웨이(Bedrock 등): stdin에 rate_limits가 아예 오지 않으므로
540
+ // 키의 max_budget/spend 를 cap 게이지로 대신 보여 준다. 조회는 캐시만 읽고,
541
+ // 갱신은 detached 자식에게 맡겨 렌더가 네트워크를 기다리지 않게 한다.
542
+ if (isStatusline) {
543
+ try {
544
+ const { budgetWindow, maybeSpawnBudgetCheck } = await import('../src/litellm-budget.js');
545
+ maybeSpawnBudgetCheck();
546
+ if (!caps || !Array.isArray(caps.windows) || caps.windows.length === 0) {
547
+ const bw = budgetWindow();
548
+ if (bw) caps = { windows: [bw] };
549
+ }
550
+ } catch (e) {
551
+ debug('litellm-budget:window', e);
552
+ }
553
+ }
554
+ if (!isStatusline && (!caps || !model)) {
555
+ try {
556
+ const { loadRecentSnapshot } = await import('../src/caps-cache.js');
557
+ const snap = loadRecentSnapshot();
558
+ if (snap) {
559
+ if (!caps && snap.caps) caps = snap.caps;
560
+ if (!model && snap.model) model = snap.model;
561
+ }
562
+ } catch (e) {
563
+ debug('caps-cache:load', e);
564
+ }
565
+ }
566
+
567
+ // For statusline: attach a single-word chip only when there's something
568
+ // actionable right now. A context over the 500k warn line is always shown;
569
+ // otherwise only fire if the most recent session appears in the spike list.
570
+ let spikeChip = null;
571
+ let chipDetail = null;
572
+ if (format === 'statusline') {
573
+ if (contextWindow.overWarn) {
574
+ spikeChip = chipForIssues([], contextWindow);
575
+ chipDetail = `Single-request context exceeded 500k (max ${Math.round(contextWindow.maxContext / 1000)}k tokens)`;
576
+ } else {
577
+ const recentSession = sessions
578
+ .slice()
579
+ .sort((a, b) => (b.endTime?.getTime() || 0) - (a.endTime?.getTime() || 0))[0];
580
+ const recentIsSpiking = recentSession && spikeReport.spikes.some(
581
+ (sp) => sp.metrics.sessionId === recentSession.sessionId,
582
+ );
583
+ if (recentIsSpiking) {
584
+ const m = sessionMetrics(recentSession);
585
+ const issues = diagnoseSession(m, spikeReport.baseline);
586
+ spikeChip = chipForIssues(issues, contextWindow);
587
+ const titles = issues
588
+ .map((i) => i.code)
589
+ .slice(0, 2)
590
+ .join(', ');
591
+ chipDetail = `session ${recentSession.sessionId?.slice(0, 8) || ''}: ${titles}`;
592
+ }
593
+ }
594
+ // Persist transitions to ~/.config/claude-token-saver/history/YYYY-MM-DD.md
595
+ // so `claude-token-saver history` and the auto-skill can replay them.
596
+ try {
597
+ const { recordChip, recordCapTransition } = await import('../src/history.js');
598
+ recordChip(spikeChip, { detail: chipDetail });
599
+ // Cap-warn transitions are tracked independently per window — a session
600
+ // can hit 90% on the 5h window even when no spike chip is firing.
601
+ if (caps && Array.isArray(caps.windows)) {
602
+ for (const win of caps.windows) recordCapTransition(win);
603
+ }
604
+ } catch (e) {
605
+ debug('history:record', e); // never let history break the statusline render
606
+ }
607
+ }
608
+
609
+ // Last API activity feeds the statusline TTL countdown.
610
+ // For every session OTHER than the excluded (current) one, take the full
611
+ // endTime (any API call keeps the prefix cache warm — it doesn't matter
612
+ // whether it's user- or agent-driven because the cache is shared across
613
+ // sessions by prefix content). The current session is filtered here rather
614
+ // than at the parser, so its writes still inform ttlBreakdown above.
615
+ const excludeAbs = excludeSessionPath
616
+ ? (isAbsolute(excludeSessionPath) ? excludeSessionPath : join(process.cwd(), excludeSessionPath))
617
+ : null;
618
+ const otherLastActivity = sessions
619
+ .filter((s) => !excludeAbs || s.filePath !== excludeAbs)
620
+ .map((s) => (s.endTime ? s.endTime.getTime() : 0))
621
+ .reduce((a, b) => Math.max(a, b), 0);
622
+ // For the excluded (current) session, only the user's prompts count — the
623
+ // agent's tool calls would otherwise reset the countdown every few seconds
624
+ // as long as Claude Code is streaming a response.
625
+ let currentSessionLastUser = 0;
626
+ if (excludeSessionPath) {
627
+ try {
628
+ const t = await getLastUserMessageTime(excludeSessionPath);
629
+ if (t) currentSessionLastUser = t.getTime();
630
+ } catch (e) {
631
+ debug('parser:last-user-message', e); // keep 0 so it doesn't raise the max
632
+ }
633
+ }
634
+ const lastActivity = Math.max(otherLastActivity, currentSessionLastUser);
635
+
636
+ // 이번 달 1일 00시 이후 지출 추정치. 통계선 기본 창(30d)이 월초를 덮으면
637
+ // 이미 파싱한 세션을 재사용하고, 사용자가 창을 줄여 둔 경우(mode 1d 등)에만
638
+ // 월초까지 다시 파싱한다. 파싱 결과는 세션 캐시가 받아 주므로 싸다.
639
+ let monthSpendInfo = null;
640
+ if (isStatusline) {
641
+ try {
642
+ const { monthSpend, monthStartMs } = await import('../src/month-spend.js');
643
+ const daysNeeded = (Date.now() - monthStartMs()) / 86400000;
644
+ const monthSessions = days >= daysNeeded
645
+ ? sessions
646
+ : await parseAllSessions({ days: Math.max(1, Math.ceil(daysNeeded)), projectFilter });
647
+ monthSpendInfo = monthSpend(monthSessions);
648
+ } catch (e) {
649
+ debug('month-spend', e);
650
+ }
651
+ }
652
+
653
+ // What delegation has measurably saved, read straight from the registry
654
+ // route-scan maintains. A lookup, never a scan: the statusline re-renders
655
+ // every few seconds and a scan parses tens of MB of transcripts.
656
+ let delegationSaved = 0;
657
+ try {
658
+ const { delegationSavedUsd } = await import('../src/model-rules.js');
659
+ delegationSaved = delegationSavedUsd();
660
+ } catch (e) {
661
+ debug('model-rules:saved', e); // an unreadable registry just hides the chip
662
+ }
663
+ // Rolling week/month/lifetime totals from the delegation ledger — the
664
+ // statusline's headline line. Falls back to null (chip hidden) on any error.
665
+ let delegationTotals = null;
666
+ try {
667
+ const { delegationSavedTotals } = await import('../src/savings-ledger.js');
668
+ delegationTotals = delegationSavedTotals();
669
+ } catch (e) {
670
+ debug('savings-ledger:totals', e);
671
+ }
672
+ // Document conversions, same shape as the delegation totals: a lifetime sum
673
+ // plus a document count. A lookup of a small JSON file, never a scan.
674
+ let doc2mdTotals = null;
675
+ try {
676
+ const { doc2mdSavedTotals } = await import('../src/doc2md-ledger.cjs');
677
+ const { userDataDir } = await import('../src/paths.js');
678
+ doc2mdTotals = doc2mdSavedTotals(userDataDir());
679
+ } catch (e) {
680
+ debug('doc2md-ledger:totals', e);
681
+ }
682
+ // Delegated runs route-scan had to throw away because their model id could
683
+ // not be priced. Also a lookup of the cached scan, never a scan. Without it
684
+ // the statusline shows the same blank for "no delegation happened" and for
685
+ // "delegation happened and was silently discarded".
686
+ let unresolvedRuns = 0;
687
+ try {
688
+ const { readRouteScan } = await import('../src/route-scan.js');
689
+ unresolvedRuns = Number(readRouteScan()?.unresolvedRuns) || 0;
690
+ } catch (e) {
691
+ debug('route-scan:unresolved', e);
692
+ }
693
+ // 'auto' unless the user pinned a bucket. Read here rather than in the
694
+ // statusline branch below because the table and JSON formatters want the
695
+ // same answer.
696
+ let ttlBucket = 'auto';
697
+ try {
698
+ const { statuslineDefaults } = await import('../src/config.js');
699
+ ttlBucket = statuslineDefaults().ttlBucket;
700
+ } catch (e) {
701
+ debug('config:ttlBucket', e);
702
+ }
703
+
704
+ const data = {
705
+ summary: sum,
706
+ trend,
707
+ ttl,
708
+ anomalies,
709
+ cost,
710
+ options: { days, windowHours, windowLabel, version: PKG_VERSION },
711
+ // Cached-only; the background refresh it may trigger lands on a later render.
712
+ update: format === 'statusline' ? readUpdateChip() : null,
713
+ lastActivity,
714
+ monthSpend: monthSpendInfo,
715
+ spikeReport,
716
+ contextWindow,
717
+ ctxLive,
718
+ spikeChip,
719
+ caps,
720
+ model,
721
+ delegationSaved,
722
+ delegationTotals,
723
+ doc2mdTotals,
724
+ unresolvedRuns,
725
+ ttlBucket,
726
+ };
727
+
728
+ let output;
729
+ if (format === 'json') {
730
+ const { formatReport } = await import('../src/formatters/json.js');
731
+ output = formatReport(data);
732
+ } else if (format === 'csv') {
733
+ const { formatReport } = await import('../src/formatters/csv.js');
734
+ output = formatReport(data);
735
+ } else if (format === 'statusline') {
736
+ const { formatReport } = await import('../src/formatters/statusline.js');
737
+ const { statuslineDefaults } = await import('../src/config.js');
738
+ const cfg = statuslineDefaults();
739
+
740
+ // IntelliJ's Claude Code plugin renders the statusline through a custom
741
+ // widget that fuses prior frames with the new one when emoji are present,
742
+ // producing garbage like "59:548" that no ANSI escape can clean up
743
+ // (verified: emitting the same output directly into JediTerm renders
744
+ // cleanly, so the bug is in the plugin's render path, not the terminal).
745
+ // Force text mode unconditionally inside IntelliJ — even past an explicit
746
+ // `--icon` flag, since wrappers commonly hardcode `--icon` and the user
747
+ // can't easily edit them; icon mode is just broken there.
748
+ const isIntelliJ = process.env.TERMINAL_EMULATOR === 'JetBrains-JediTerm';
749
+ // CLI flags take precedence; otherwise fall back to persisted config.
750
+ const isIcon = isIntelliJ
751
+ ? false
752
+ : (hasFlag('--icon')
753
+ ? true
754
+ : (hasFlag('--no-icon') || hasFlag('--text') ? false : cfg.icon));
755
+ const isVerbose = hasFlag('--verbose')
756
+ ? true
757
+ : (hasFlag('--no-verbose') || hasFlag('--compact') ? false : cfg.verbose);
758
+ const showTimer = hasFlag('--no-timer') ? false : cfg.timer;
759
+ const colorOk =
760
+ !hasFlag('--no-color') && !process.env.NO_COLOR && cfg.color;
761
+
762
+ const segmentsArg = getArg('--segments');
763
+ const segments = segmentsArg
764
+ ? segmentsArg.split(',').map((s) => s.trim()).filter(Boolean)
765
+ : null;
766
+ output = formatReport(data, {
767
+ color: colorOk,
768
+ verbose: isVerbose,
769
+ timer: showTimer,
770
+ mode: isIcon ? 'icon' : 'text',
771
+ segments,
772
+ // macOS builds of Claude Code have rendered only the first line of a
773
+ // multi-line statusline in some versions (anthropics/claude-code#35176)
774
+ // — --single-line restores the legacy one-line layout in that case.
775
+ singleLine: hasFlag('--single-line'),
776
+ });
777
+ } else {
778
+ const { formatReport } = await import('../src/formatters/table.js');
779
+ output = formatReport(data);
780
+ }
781
+
782
+ console.log(output);
783
+ }
784
+
785
+ main().catch((err) => {
786
+ // Statusline mode must never spam multi-line errors (called every ~300ms)
787
+ const isStatusline = process.argv.includes('--statusline') || process.argv.includes('statusline');
788
+ if (isStatusline) {
789
+ const colorOk = !process.argv.includes('--no-color') && !process.env.NO_COLOR;
790
+ const red = colorOk ? '\x1b[31m' : '';
791
+ const reset = colorOk ? '\x1b[0m' : '';
792
+ // Still exactly one line, but name the problem — a bad flag in a
793
+ // statusline wrapper is otherwise invisible ("🧠 error" for a typo the
794
+ // user cannot see the source of). Collapse newlines and cap the length.
795
+ const msg = String(err?.message || 'error').split('\n')[0].slice(0, 80);
796
+ console.log(`${red}🧠 ${msg}${reset}`);
797
+ process.exit(0);
798
+ }
799
+ console.error('Error:', err.message);
800
+ process.exit(1);
801
+ });