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
@@ -0,0 +1,614 @@
1
+ /**
2
+ * Installs the Claude Code integration asset:
3
+ * - Skill: ~/.claude/skills/claude-token-saver/SKILL.md
4
+ *
5
+ * v2.6.0 consolidates `/token-monitor` into the skill (was redundant with the
6
+ * auto-trigger). On install we actively remove a legacy
7
+ * ~/.claude/commands/token-monitor.md if present so users don't see two
8
+ * overlapping entry points.
9
+ *
10
+ * All paths are resolved with node:path so Windows backslashes and POSIX
11
+ * forward-slashes are both handled. Directories are created with
12
+ * `mkdirSync(..., { recursive: true })` which is a no-op if they already
13
+ * exist on every platform.
14
+ */
15
+
16
+ import { writeFileSync, mkdirSync, existsSync, unlinkSync, readFileSync, rmSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { claudeUserDir, userDataDir } from './paths.js';
19
+
20
+ const STATUSLINE_COMMAND = 'claude-token-saver --statusline --icon';
21
+ const STATUSLINE_REFRESH_INTERVAL = 5;
22
+
23
+ const SKILL_BODY = `---
24
+ name: claude-token-saver
25
+ description: Use when the user mentions Claude Code token usage, prompt cache hit rate, TTL/expiry, the 1M context window, cache misses, output spikes, rate-limit caps (5h/7d), or anything in the statusline produced by claude-token-saver (chips like "🚨 5H 94%", "🚨 7D 92%", "⚠ Ctx 500k+", "⚠ Input spike", "⚠ Cache miss", "⚠ 5m TTL", "⚠ Rebuild churn", "⚠ Output heavy", "⚠ Call surge", "⏳ Cache expires", "💰 Cache saved", "🧠 Cache hit"). Also use when they ask to view token-usage history, want to understand a warning they just saw, or want to back up work before a session cap with \`claude-token-saver handoff\`.
26
+ ---
27
+
28
+ # claude-token-saver — Claude Code Token Monitor
29
+
30
+ This skill helps users interpret and act on the \`claude-token-saver\` statusline
31
+ in Claude Code. The statusline updates every ~1s and shows cache health, TTL
32
+ countdown, savings, and (when relevant) a leading warning chip.
33
+
34
+ ## Response language
35
+
36
+ Respond in the user's configured output language. Run
37
+ \`claude-token-saver mode\` once at the start of the session and read the
38
+ \`Output language\` field — if it shows \`language: ko\`, write **all of your
39
+ prose to the user in Korean** (summary, "All clear" status lines, headings,
40
+ recommendations). If it shows \`language: en\` (the default), write in
41
+ English. Tool output (the body of \`last\`/\`history\`/the table report) is
42
+ already localized by the CLI — do not retranslate it; just mirror your
43
+ own narration to match. If the user asks for a different language inline,
44
+ honor that for the rest of the turn without changing the saved setting.
45
+
46
+ ## When this skill should activate
47
+
48
+ - The user references any chip wording: \`🚨 5H NN%\`, \`🚨 7D NN%\`,
49
+ \`⚠ Ctx 500k+\`, \`⚠ Input spike\`, \`⚠ Cache miss\`, \`⚠ 5m TTL\`,
50
+ \`⚠ Rebuild churn\`, \`⚠ Output heavy\`, \`⚠ Call surge\`.
51
+ - The user asks "why is my cache hit rate low", "what does this warning mean",
52
+ "when did this start happening", or similar.
53
+ - The user is approaching a rate-limit cap and wants to back up the current
54
+ work so a fresh session can continue (point them at
55
+ \`claude-token-saver handoff\`).
56
+ - The user wants to see the token-usage history file or asks for a summary
57
+ of recent warnings.
58
+ - The user asks for a quick token report or "current state" check (the
59
+ skill replaces the legacy \`/token-monitor\` slash command — same workflow,
60
+ triggered by intent rather than a typed slash).
61
+
62
+ ## What to do
63
+
64
+ 1. **Lead with the most recent warning + how to handle it.** Run
65
+ \`claude-token-saver last\` first. It returns the most recent warning event
66
+ (chip + detail + timestamp) plus the full advice block for it. Surface that
67
+ to the user before anything else — this is what they came for.
68
+ 2. **Identify the chip.** If the user pasted a statusline (instead of relying
69
+ on \`last\`), pull out the leading \`⚠ ...\` chip. That maps to a specific
70
+ issue category.
71
+ 3. **Show recent history.** Run \`claude-token-saver history\` (default last 7
72
+ days) to see the chronology of warning transitions. Each entry is timestamped,
73
+ bilingual (English line + 한국어), and includes a \`💡\` action tip inline.
74
+ 4. **Drill down on the live state.** Run \`claude-token-saver --days 1\` (or
75
+ another window) to render the full table view, which lists per-session
76
+ spikes and recommended actions.
77
+ 5. **Explain the warning** in plain language. Use the chip → cause table:
78
+
79
+ | Chip | Likely cause |
80
+ | ------------------ | ----------------------------------------------------- |
81
+ | \`🚨 5H NN%\` | 5-hour rate-limit window at NN% (>=90%). Cap is imminent. |
82
+ | \`🚨 7D NN%\` | 7-day rate-limit window at NN% (>=90%). Pace yourself. |
83
+ | \`⚠ Ctx 500k+\` | A single recent request carried more than 500k input tokens. |
84
+ | \`⚠ Input spike\` | One request consumed >250k or >3× the recent p95. |
85
+ | \`⚠ Cache miss\` | Cache hit rate dropped below ~70%. |
86
+ | \`⚠ 5m TTL\` | Most cache writes are 5-min ephemeral (Pro plan default). |
87
+ | \`⚠ Rebuild churn\` | Cache being re-written rapidly — prefix is unstable. |
88
+ | \`⚠ Output heavy\` | Output ratio dominates input — inspect long generations. |
89
+ | \`⚠ Call surge\` | Request count is well above baseline. |
90
+
91
+ Note: the \`🚨 5H/7D\` cap chips come from Claude Code's own
92
+ \`rate_limits\` payload, which gateway backends (Bedrock, Vertex,
93
+ LiteLLM) do not provide — on those setups the chips never appear, and
94
+ that is expected, not a failure of this tool.
95
+
96
+ 6. **Suggest the next action.** For \`🚨 5H/7D\` chips, recommend running
97
+ \`claude-token-saver handoff\` to back up the current work to a
98
+ \`HANDOFF-*.md\` file before the cap hits, then continue in a fresh
99
+ session. For \`⚠ Ctx 500k+\`, recommend \`/compact\` or \`/clear\` and
100
+ checking what is pinned into context. For
101
+ 5m TTL, point at the Max plan's 1h bucket. For input spike, suggest
102
+ splitting the conversation or compacting context.
103
+
104
+ ## Useful commands
105
+
106
+ - \`claude-token-saver last\` — most recent warning + full advice (start here).
107
+ - \`claude-token-saver last --days 7\` — widen the lookback window.
108
+ - \`claude-token-saver\` — full table report (default last 1 day).
109
+ - \`claude-token-saver --days 7\` — wider window.
110
+ - \`claude-token-saver history\` — recent warning transitions per day, with
111
+ inline \`💡\` action tips.
112
+ - \`claude-token-saver history --days 30\` — longer history.
113
+ - \`claude-token-saver handoff\` — write a HANDOFF-*.md template in cwd
114
+ capturing git status + cap snapshot, so a fresh session can resume cleanly.
115
+ - \`claude-token-saver mode\` — show statusline preferences.
116
+ - \`claude-token-saver mode icon verbose 1d\` — change preferences.
117
+ - \`claude-token-saver feedback "<message>"\` — file a bug report or feature
118
+ request for this tool right from the session (tries the gh CLI, then an
119
+ anonymous no-login submission, then saves locally with a prefilled GitHub
120
+ issue URL; \`--anonymous\` skips the gh path).
121
+ Use it whenever the user says the tool itself misbehaves or wishes it did
122
+ something it does not — offer to submit the report for them.
123
+
124
+ ## Storage layout (for reference)
125
+
126
+ History files live under the OS-appropriate user-data dir:
127
+ - Windows: \`%APPDATA%\\claude-token-saver\\history\\YYYY-MM-DD.md\`
128
+ - macOS: \`~/Library/Application Support/claude-token-saver/history/YYYY-MM-DD.md\`
129
+ - Linux: \`~/.config/claude-token-saver/history/YYYY-MM-DD.md\`
130
+
131
+ Each day's file is plain Markdown — safe to open in any editor.
132
+ `;
133
+
134
+ function writeIfNeeded(file, body, force) {
135
+ const existed = existsSync(file);
136
+ if (existed && !force) return { path: file, action: 'exists' };
137
+ writeFileSync(file, body);
138
+ return { path: file, action: existed ? 'updated' : 'created' };
139
+ }
140
+
141
+ export function installSkill({ force = false } = {}) {
142
+ const dir = join(claudeUserDir(), 'skills', 'claude-token-saver');
143
+ const file = join(dir, 'SKILL.md');
144
+ mkdirSync(dir, { recursive: true });
145
+ // SKILL.md is fully package-controlled — overwrite whenever the bundled
146
+ // body differs from what's on disk so postinstall picks up new instructions
147
+ // (e.g. the response-language directive added in v2.9.3) without forcing
148
+ // users to re-run \`install --force\`.
149
+ if (existsSync(file) && readFileSync(file, 'utf8') === SKILL_BODY) {
150
+ return { path: file, action: 'exists' };
151
+ }
152
+ return writeIfNeeded(file, SKILL_BODY, true);
153
+ }
154
+
155
+ // Removes the legacy /token-monitor slash command from prior versions.
156
+ // v2.6.0 consolidated it into the skill — the file would otherwise linger.
157
+ export function removeLegacyCommand() {
158
+ const file = join(claudeUserDir(), 'commands', 'token-monitor.md');
159
+ if (!existsSync(file)) return { path: file, action: 'absent' };
160
+ unlinkSync(file);
161
+ return { path: file, action: 'removed' };
162
+ }
163
+
164
+ // Registers/repairs the Claude Code statusLine entry in ~/.claude/settings.json.
165
+ // - No statusLine yet: insert ours with refreshInterval:STATUSLINE_REFRESH_INTERVAL.
166
+ // - statusLine already points at claude-token-saver: ensure that refreshInterval
167
+ // (this is the bit that makes the TTL countdown tick every second while idle).
168
+ // - statusLine points at a different command: leave it alone unless --force.
169
+ export function installStatusline({ force = false } = {}) {
170
+ const dir = claudeUserDir();
171
+ const file = join(dir, 'settings.json');
172
+ mkdirSync(dir, { recursive: true });
173
+
174
+ let settings = {};
175
+ if (existsSync(file)) {
176
+ try {
177
+ settings = JSON.parse(readFileSync(file, 'utf8'));
178
+ } catch (e) {
179
+ return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
180
+ }
181
+ }
182
+
183
+ const cur = settings.statusLine;
184
+ const targetsUs = cur && typeof cur.command === 'string' && cur.command.includes('claude-token-saver');
185
+
186
+ if (!cur) {
187
+ settings.statusLine = {
188
+ type: 'command',
189
+ command: STATUSLINE_COMMAND,
190
+ refreshInterval: STATUSLINE_REFRESH_INTERVAL,
191
+ };
192
+ writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
193
+ return { path: file, action: 'created' };
194
+ }
195
+
196
+ if (targetsUs) {
197
+ if (cur.refreshInterval === STATUSLINE_REFRESH_INTERVAL) {
198
+ return { path: file, action: 'exists' };
199
+ }
200
+ cur.refreshInterval = STATUSLINE_REFRESH_INTERVAL;
201
+ writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
202
+ return { path: file, action: 'updated', reason: `set refreshInterval=${STATUSLINE_REFRESH_INTERVAL}` };
203
+ }
204
+
205
+ if (!force) {
206
+ // `conflict` lets the interactive install distinguish "someone else's
207
+ // statusline is here" (worth asking about) from other skip reasons
208
+ // (unreadable JSON), which a prompt cannot fix.
209
+ return { path: file, action: 'skipped', conflict: true, existingCommand: cur.command, reason: `existing statusLine command (${cur.command}) — re-run with --force to overwrite` };
210
+ }
211
+ settings.statusLine = {
212
+ type: 'command',
213
+ command: STATUSLINE_COMMAND,
214
+ refreshInterval: STATUSLINE_REFRESH_INTERVAL,
215
+ };
216
+ writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
217
+ return { path: file, action: 'updated', reason: 'replaced previous statusLine' };
218
+ }
219
+
220
+ // Registers the SessionStart hook that surfaces route-scan delegation
221
+ // candidates as session context (startup + /clear). Idempotent: skips when a
222
+ // claude-token-saver route-scan hook is already present; never touches other
223
+ // hooks the user configured.
224
+ const ROUTE_SCAN_HOOK_COMMAND = 'claude-token-saver route-scan --hook';
225
+
226
+ export function installSessionStartHook() {
227
+ const dir = claudeUserDir();
228
+ const file = join(dir, 'settings.json');
229
+ mkdirSync(dir, { recursive: true });
230
+
231
+ let settings = {};
232
+ if (existsSync(file)) {
233
+ try {
234
+ settings = JSON.parse(readFileSync(file, 'utf8'));
235
+ } catch (e) {
236
+ return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
237
+ }
238
+ }
239
+
240
+ settings.hooks = settings.hooks || {};
241
+ // A present-but-non-array value is schema-invalid, but it's the user's
242
+ // data — back off instead of silently replacing it.
243
+ if (settings.hooks.SessionStart !== undefined && !Array.isArray(settings.hooks.SessionStart)) {
244
+ return { path: file, action: 'skipped', reason: 'hooks.SessionStart is not an array — fix settings.json manually' };
245
+ }
246
+ const list = Array.isArray(settings.hooks.SessionStart) ? settings.hooks.SessionStart : [];
247
+ const already = list.some((m) =>
248
+ Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('route-scan --hook')),
249
+ );
250
+ if (already) return { path: file, action: 'exists' };
251
+
252
+ list.push({
253
+ matcher: 'startup|clear',
254
+ hooks: [{ type: 'command', command: ROUTE_SCAN_HOOK_COMMAND, timeout: 10 }],
255
+ });
256
+ settings.hooks.SessionStart = list;
257
+ writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
258
+ return { path: file, action: 'created' };
259
+ }
260
+
261
+ // Registers the UserPromptSubmit hook that briefs mid-session state changes
262
+ // (ctx tier crossings, new route candidates, rule-health flips) into the
263
+ // conversation — the model cannot see statusline chips, so without this a
264
+ // change that happens mid-session goes unexplained until the user asks.
265
+ // Silent (no output, zero context cost) when nothing changed. Idempotent.
266
+ const BRIEF_HOOK_COMMAND = 'claude-token-saver brief --hook';
267
+
268
+ export function installBriefHook() {
269
+ const dir = claudeUserDir();
270
+ const file = join(dir, 'settings.json');
271
+ mkdirSync(dir, { recursive: true });
272
+
273
+ let settings = {};
274
+ if (existsSync(file)) {
275
+ try {
276
+ settings = JSON.parse(readFileSync(file, 'utf8'));
277
+ } catch (e) {
278
+ return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
279
+ }
280
+ }
281
+
282
+ settings.hooks = settings.hooks || {};
283
+ // A present-but-non-array value is schema-invalid, but it's the user's
284
+ // data — back off instead of silently replacing it.
285
+ if (settings.hooks.UserPromptSubmit !== undefined && !Array.isArray(settings.hooks.UserPromptSubmit)) {
286
+ return { path: file, action: 'skipped', reason: 'hooks.UserPromptSubmit is not an array — fix settings.json manually' };
287
+ }
288
+ const list = Array.isArray(settings.hooks.UserPromptSubmit) ? settings.hooks.UserPromptSubmit : [];
289
+ const already = list.some((m) =>
290
+ Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('brief --hook')),
291
+ );
292
+ if (already) return { path: file, action: 'exists' };
293
+
294
+ list.push({
295
+ hooks: [{ type: 'command', command: BRIEF_HOOK_COMMAND, timeout: 10 }],
296
+ });
297
+ settings.hooks.UserPromptSubmit = list;
298
+ writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
299
+ return { path: file, action: 'created' };
300
+ }
301
+
302
+ // Registers the PostToolUse hook that checks Korean prose the session just
303
+ // wrote. Session-start guidance teaches the model but is never re-read, so
304
+ // files written later drift back to the patterns the guidance forbids and the
305
+ // drift surfaces only when a human reads the artifact. This hook closes that
306
+ // gap: it runs on the file the model just wrote, while it can still fix it.
307
+ // Installed by `korean on`, removed by `korean off`. Idempotent.
308
+ const KOREAN_LINT_HOOK_COMMAND = 'claude-token-saver korean --hook';
309
+
310
+ export function installKoreanLintHook() {
311
+ const dir = claudeUserDir();
312
+ const file = join(dir, 'settings.json');
313
+ mkdirSync(dir, { recursive: true });
314
+
315
+ let settings = {};
316
+ if (existsSync(file)) {
317
+ try {
318
+ settings = JSON.parse(readFileSync(file, 'utf8'));
319
+ } catch (e) {
320
+ return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
321
+ }
322
+ }
323
+
324
+ settings.hooks = settings.hooks || {};
325
+ if (settings.hooks.PostToolUse !== undefined && !Array.isArray(settings.hooks.PostToolUse)) {
326
+ return { path: file, action: 'skipped', reason: 'hooks.PostToolUse is not an array — fix settings.json manually' };
327
+ }
328
+ const list = Array.isArray(settings.hooks.PostToolUse) ? settings.hooks.PostToolUse : [];
329
+ const already = list.some((m) =>
330
+ Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('korean --hook')),
331
+ );
332
+ if (already) return { path: file, action: 'exists' };
333
+
334
+ list.push({
335
+ matcher: 'Write|Edit|MultiEdit',
336
+ hooks: [{ type: 'command', command: KOREAN_LINT_HOOK_COMMAND, timeout: 10 }],
337
+ });
338
+ settings.hooks.PostToolUse = list;
339
+ writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
340
+ return { path: file, action: 'created' };
341
+ }
342
+
343
+ export function removeKoreanLintHook() {
344
+ const file = join(claudeUserDir(), 'settings.json');
345
+ if (!existsSync(file)) return { path: file, action: 'absent' };
346
+ let settings;
347
+ try {
348
+ settings = JSON.parse(readFileSync(file, 'utf8'));
349
+ } catch (e) {
350
+ return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
351
+ }
352
+ const list = settings?.hooks?.PostToolUse;
353
+ if (!Array.isArray(list)) return { path: file, action: 'absent' };
354
+ const kept = list.filter((m) =>
355
+ !(Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('korean --hook'))),
356
+ );
357
+ if (kept.length === list.length) return { path: file, action: 'absent' };
358
+ if (kept.length === 0) delete settings.hooks.PostToolUse;
359
+ else settings.hooks.PostToolUse = kept;
360
+ writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
361
+ return { path: file, action: 'removed' };
362
+ }
363
+
364
+ // Registers the PreToolUse hook that converts attached documents to Markdown
365
+ // before the model reads them. PreToolUse rather than PostToolUse because the
366
+ // point is to intervene before a pptx lands in the context window; afterwards
367
+ // the tokens are already spent.
368
+ //
369
+ // No `timeout` is set, on purpose. Claude Code defaults command hooks to ten
370
+ // minutes, so naming a number here could only lower that ceiling, and a cold
371
+ // `import markitdown` measured at twelve seconds by itself, with a very large
372
+ // workbook adding a minute on top. The row cap inside the converter is what
373
+ // actually bounds the work; the timeout is only a backstop.
374
+ // Installed by `doc2md on`, removed by `doc2md off`. Idempotent.
375
+ const DOC2MD_HOOK_COMMAND = 'claude-token-saver doc2md --hook';
376
+ const DOC2MD_PROMPT_HOOK_COMMAND = 'claude-token-saver doc2md --hook-prompt';
377
+
378
+ export function installDoc2mdHook() {
379
+ const dir = claudeUserDir();
380
+ const file = join(dir, 'settings.json');
381
+ mkdirSync(dir, { recursive: true });
382
+
383
+ let settings = {};
384
+ if (existsSync(file)) {
385
+ try {
386
+ settings = JSON.parse(readFileSync(file, 'utf8'));
387
+ } catch (e) {
388
+ return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
389
+ }
390
+ }
391
+
392
+ settings.hooks = settings.hooks || {};
393
+ if (settings.hooks.PreToolUse !== undefined && !Array.isArray(settings.hooks.PreToolUse)) {
394
+ return { path: file, action: 'skipped', reason: 'hooks.PreToolUse is not an array — fix settings.json manually' };
395
+ }
396
+ // Each event is checked on its own. Returning early on "the Read hook is
397
+ // already there" would leave anyone upgrading from 3.26.x with the half that
398
+ // cannot see pptx and without the half that can — which is exactly what
399
+ // happened on the first machine to try it.
400
+ const list = Array.isArray(settings.hooks.PreToolUse) ? settings.hooks.PreToolUse : [];
401
+ const hasReadHook = list.some((m) =>
402
+ Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && /doc2md --hook(?!-)/.test(h.command)),
403
+ );
404
+ if (!hasReadHook) {
405
+ list.push({
406
+ matcher: 'Read',
407
+ hooks: [{ type: 'command', command: DOC2MD_HOOK_COMMAND }],
408
+ });
409
+ settings.hooks.PreToolUse = list;
410
+ }
411
+
412
+ // The write guard rides the same command on its own matcher. A conversion
413
+ // is one-way: an Edit to the cached .md changes nothing the user cares
414
+ // about, and a Write to the original clobbers a binary with text. Both are
415
+ // denied with an explanation of where the work should go instead.
416
+ let addedWriteGuard = false;
417
+ {
418
+ const list2 = Array.isArray(settings.hooks.PreToolUse) ? settings.hooks.PreToolUse : [];
419
+ const hasWriteGuard = list2.some((m) =>
420
+ m?.matcher === 'Edit|Write'
421
+ && Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && /doc2md --hook(?!-)/.test(h.command)),
422
+ );
423
+ if (!hasWriteGuard) {
424
+ list2.push({
425
+ matcher: 'Edit|Write',
426
+ hooks: [{ type: 'command', command: DOC2MD_HOOK_COMMAND }],
427
+ });
428
+ settings.hooks.PreToolUse = list2;
429
+ addedWriteGuard = true;
430
+ }
431
+ }
432
+
433
+ // The Read hook alone covers only PDFs. Claude Code refuses pptx/xlsx/docx
434
+ // as binary before any PreToolUse hook runs, so for exactly the formats this
435
+ // feature exists for, the tool call is dead before doc2md is consulted.
436
+ // UserPromptSubmit runs earlier and sees the raw prompt text, which is where
437
+ // a path the user typed can still be turned into Markdown.
438
+ let addedPrompt = false;
439
+ if (settings.hooks.UserPromptSubmit === undefined || Array.isArray(settings.hooks.UserPromptSubmit)) {
440
+ const prompts = Array.isArray(settings.hooks.UserPromptSubmit) ? settings.hooks.UserPromptSubmit : [];
441
+ const hasPromptHook = prompts.some((m) =>
442
+ Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('doc2md --hook-prompt')),
443
+ );
444
+ if (!hasPromptHook) {
445
+ prompts.push({ hooks: [{ type: 'command', command: DOC2MD_PROMPT_HOOK_COMMAND }] });
446
+ settings.hooks.UserPromptSubmit = prompts;
447
+ addedPrompt = true;
448
+ }
449
+ }
450
+
451
+ if (!hasReadHook || addedPrompt || addedWriteGuard) {
452
+ writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
453
+ return { path: file, action: hasReadHook ? 'updated' : 'created' };
454
+ }
455
+ return { path: file, action: 'exists' };
456
+ }
457
+
458
+ export function removeDoc2mdHook() {
459
+ const file = join(claudeUserDir(), 'settings.json');
460
+ if (!existsSync(file)) return { path: file, action: 'absent' };
461
+ let settings;
462
+ try {
463
+ settings = JSON.parse(readFileSync(file, 'utf8'));
464
+ } catch (e) {
465
+ return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
466
+ }
467
+ // Both entries go, and only this tool's own: anything else registered under
468
+ // either event stays exactly where the user put it. The substring covers
469
+ // `--hook` and `--hook-prompt` alike.
470
+ let touched = false;
471
+ for (const event of ['PreToolUse', 'UserPromptSubmit']) {
472
+ const list = settings?.hooks?.[event];
473
+ if (!Array.isArray(list)) continue;
474
+ const kept = list.filter((m) =>
475
+ !(Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('doc2md --hook'))),
476
+ );
477
+ if (kept.length === list.length) continue;
478
+ touched = true;
479
+ if (kept.length === 0) delete settings.hooks[event];
480
+ else settings.hooks[event] = kept;
481
+ }
482
+ if (!touched) return { path: file, action: 'absent' };
483
+ writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
484
+ return { path: file, action: 'removed' };
485
+ }
486
+
487
+ /**
488
+ * Undo what `install` did: every hook this tool registered, the statusline
489
+ * entry when it is still ours, and the skill file.
490
+ *
491
+ * Written because `uninstall` was a name in the subcommand list with no
492
+ * implementation behind it — running it fell through to the usage report and
493
+ * exited non-zero, which is a poor answer to "get this off my machine". A
494
+ * company rollout needs the way out to work as well as the way in.
495
+ *
496
+ * User data (ledgers, config, conversion cache) is deliberately left alone:
497
+ * removing an integration should not throw away months of recorded savings.
498
+ * `--purge` is the separate, explicit request for that.
499
+ */
500
+ export function uninstallAll({ purge = false } = {}) {
501
+ const file = join(claudeUserDir(), 'settings.json');
502
+ const result = { removed: [], kept: [], path: file };
503
+
504
+ if (existsSync(file)) {
505
+ let settings;
506
+ try {
507
+ settings = JSON.parse(readFileSync(file, 'utf8'));
508
+ } catch (e) {
509
+ return { ...result, action: 'skipped', reason: `unreadable JSON (${e.message})` };
510
+ }
511
+
512
+ // Only our own statusline command goes. Someone else's stays exactly
513
+ // where they put it.
514
+ const cur = settings.statusLine;
515
+ if (cur && typeof cur.command === 'string' && cur.command.includes('claude-token-saver')) {
516
+ delete settings.statusLine;
517
+ result.removed.push('statusLine');
518
+ } else if (cur) {
519
+ result.kept.push(`statusLine (${cur.command || 'unrecognised'})`);
520
+ }
521
+
522
+ // Every event, every entry whose command is this CLI. Matching on the
523
+ // binary name rather than a list of exact commands means a hook added by
524
+ // an older version is still removed by a newer one.
525
+ for (const event of Object.keys(settings.hooks || {})) {
526
+ const list = settings.hooks[event];
527
+ if (!Array.isArray(list)) continue;
528
+ const kept = list.filter((m) => !(
529
+ Array.isArray(m?.hooks)
530
+ && m.hooks.some((h) => typeof h?.command === 'string'
531
+ && (h.command.includes('claude-token-saver') || h.command.includes('cache-monitor-hook')))
532
+ ));
533
+ if (kept.length === list.length) continue;
534
+ result.removed.push(`hooks.${event}`);
535
+ if (kept.length === 0) delete settings.hooks[event];
536
+ else settings.hooks[event] = kept;
537
+ }
538
+ if (settings.hooks && Object.keys(settings.hooks).length === 0) delete settings.hooks;
539
+
540
+ writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
541
+ }
542
+
543
+ const skillDir = join(claudeUserDir(), 'skills', 'claude-token-saver');
544
+ if (existsSync(skillDir)) {
545
+ rmSync(skillDir, { recursive: true, force: true });
546
+ result.removed.push('skill');
547
+ }
548
+ const legacyCommand = join(claudeUserDir(), 'commands', 'token-monitor.md');
549
+ if (existsSync(legacyCommand)) {
550
+ rmSync(legacyCommand, { force: true });
551
+ result.removed.push('legacy command');
552
+ }
553
+
554
+ if (purge) {
555
+ const data = userDataDir();
556
+ if (existsSync(data)) {
557
+ rmSync(data, { recursive: true, force: true });
558
+ result.removed.push('state directory');
559
+ }
560
+ } else {
561
+ result.kept.push('state directory (savings ledgers, config, cache) — remove with --purge');
562
+ }
563
+
564
+ return { ...result, action: 'removed' };
565
+ }
566
+
567
+ // Older versions registered the cache-monitor hook as a file copied to
568
+ // ~/.claude/cache-monitor-hook.cjs. That copy never included
569
+ // harness-analyzer.cjs, went stale across upgrades, and escaped uninstall.
570
+ // Rewrite any such entry to the CLI subcommand form and delete the copy.
571
+ // Runs on every install (postinstall included) so upgrades self-heal.
572
+ export function migrateLegacyCacheMonitorHook() {
573
+ const dir = claudeUserDir();
574
+ const file = join(dir, 'settings.json');
575
+ if (!existsSync(file)) return { path: file, action: 'none' };
576
+ let settings;
577
+ try {
578
+ settings = JSON.parse(readFileSync(file, 'utf8'));
579
+ } catch (e) {
580
+ return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
581
+ }
582
+ const list = settings.hooks?.PostToolUse;
583
+ if (!Array.isArray(list)) return { path: file, action: 'none' };
584
+
585
+ let migrated = false;
586
+ for (const m of list) {
587
+ if (!Array.isArray(m?.hooks)) continue;
588
+ for (const h of m.hooks) {
589
+ if (typeof h?.command !== 'string' || !h.command.includes('cache-monitor-hook')) continue;
590
+ const th = h.command.match(/--threshold\s+([\d.]+)/);
591
+ h.command = `claude-token-saver --hook-run --threshold ${th ? th[1] : '0.7'}`;
592
+ migrated = true;
593
+ }
594
+ }
595
+ if (migrated) writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
596
+
597
+ const legacyCopy = join(dir, 'cache-monitor-hook.cjs');
598
+ if (existsSync(legacyCopy)) {
599
+ rmSync(legacyCopy, { force: true });
600
+ migrated = true;
601
+ }
602
+ return { path: file, action: migrated ? 'migrated' : 'none' };
603
+ }
604
+
605
+ export function installAll({ force = false } = {}) {
606
+ return {
607
+ skill: installSkill({ force }),
608
+ statusline: installStatusline({ force }),
609
+ sessionStartHook: installSessionStartHook(),
610
+ briefHook: installBriefHook(),
611
+ cacheMonitorMigration: migrateLegacyCacheMonitorHook(),
612
+ legacy: removeLegacyCommand(),
613
+ };
614
+ }