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.
- package/LICENSE +21 -0
- package/README.ko.md +637 -0
- package/README.md +758 -0
- package/bin/cli.js +801 -0
- package/examples/statusline-command.ps1 +43 -0
- package/examples/statusline-command.sh +36 -0
- package/package.json +62 -0
- package/presets/cohesion/cohesion-en.md +26 -0
- package/presets/doc2md/convert.py +363 -0
- package/presets/korean-style/LICENSE-fluent-korean +21 -0
- package/presets/korean-style/fluent-korean.md +52 -0
- package/presets/korean-style/supplement.md +93 -0
- package/presets/model-rules.json +115 -0
- package/presets/ratchet-rules.json +38 -0
- package/src/advice.js +564 -0
- package/src/agents.js +52 -0
- package/src/brief.js +264 -0
- package/src/caps-cache.js +84 -0
- package/src/cli-args.js +51 -0
- package/src/cohesion.js +70 -0
- package/src/commands/brief.js +31 -0
- package/src/commands/cohesion.js +59 -0
- package/src/commands/compact-window.js +93 -0
- package/src/commands/doc2md.js +166 -0
- package/src/commands/feedback.js +132 -0
- package/src/commands/handoff.js +33 -0
- package/src/commands/harness.js +459 -0
- package/src/commands/history.js +46 -0
- package/src/commands/install.js +358 -0
- package/src/commands/korean.js +220 -0
- package/src/commands/last.js +151 -0
- package/src/commands/mode.js +46 -0
- package/src/commands/route-scan.js +454 -0
- package/src/commands/seed.js +105 -0
- package/src/commands/uninstall.js +42 -0
- package/src/commands/update-check.js +77 -0
- package/src/commands/upgrade.js +68 -0
- package/src/compact-window.js +205 -0
- package/src/config.js +232 -0
- package/src/cost.js +253 -0
- package/src/debug.js +29 -0
- package/src/demo.js +331 -0
- package/src/doc2md-ledger.cjs +227 -0
- package/src/doc2md.cjs +997 -0
- package/src/fig2md-runner.cjs +21 -0
- package/src/fig2md.cjs +191 -0
- package/src/first-run-note.js +63 -0
- package/src/format-time.js +44 -0
- package/src/formatters/csv.js +8 -0
- package/src/formatters/json.js +3 -0
- package/src/formatters/statusline.js +750 -0
- package/src/formatters/table.js +299 -0
- package/src/handoff.js +161 -0
- package/src/harness-analyzer.cjs +264 -0
- package/src/harness-templates.js +153 -0
- package/src/harness.js +613 -0
- package/src/history.js +383 -0
- package/src/hook-manager.js +96 -0
- package/src/hook.cjs +196 -0
- package/src/installer.js +614 -0
- package/src/korean-lint.cjs +303 -0
- package/src/korean-style.js +187 -0
- package/src/litellm-budget.js +223 -0
- package/src/model-alias.js +484 -0
- package/src/model-rules.js +527 -0
- package/src/month-spend.js +47 -0
- package/src/parser.js +330 -0
- package/src/paths.js +41 -0
- package/src/prompt.js +52 -0
- package/src/route-scan.js +832 -0
- package/src/savings-ledger.js +137 -0
- package/src/seed-rules.js +280 -0
- package/src/session-cache.js +160 -0
- package/src/session-records.js +188 -0
- package/src/stats.js +380 -0
- package/src/stdin-payload.js +122 -0
- package/src/subagent-records.js +214 -0
- package/src/update-check.js +201 -0
- 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
|
+
});
|