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/src/installer.js
ADDED
|
@@ -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
|
+
}
|