sprag-cli 3.56.0 → 3.58.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/README.ko.md +60 -40
- package/README.md +63 -41
- package/bin/cli.js +65 -19
- package/bin/postinstall.js +22 -0
- package/docs/CODEX.ko.md +5 -1
- package/docs/CODEX.md +5 -0
- package/package.json +14 -26
- package/src/agents.js +16 -2
- package/src/brief.js +17 -8
- package/src/claude-price.js +23 -2
- package/src/codex-brief.js +35 -2
- package/src/codex-delegation.js +12 -4
- package/src/codex-harness.js +43 -9
- package/src/codex-hooks.js +24 -3
- package/src/codex-ledger.js +61 -5
- package/src/codex-panel.js +42 -22
- package/src/codex-rule-health.js +24 -1
- package/src/commands/brief.js +14 -3
- package/src/commands/codex-delegate.js +7 -4
- package/src/commands/codex.js +2 -0
- package/src/commands/harness.js +6 -0
- package/src/commands/install.js +24 -13
- package/src/compact-window.js +12 -4
- package/src/config.js +3 -3
- package/src/cost.js +5 -2
- package/src/ctx-window-cache.js +61 -0
- package/src/delegation-guard.js +15 -9
- package/src/doc2md.cjs +22 -8
- package/src/fig2md.cjs +7 -1
- package/src/formatters/statusline.js +4 -4
- package/src/formatters/table.js +18 -10
- package/src/gateway-auth.js +31 -3
- package/src/harness.js +43 -12
- package/src/history.js +6 -2
- package/src/hook-manager.js +54 -32
- package/src/installer.js +315 -158
- package/src/korean-lint.cjs +40 -7
- package/src/litellm-budget.js +10 -1
- package/src/model-rules.js +88 -41
- package/src/month-spend.js +17 -11
- package/src/parser.js +232 -56
- package/src/postinstall.js +32 -0
- package/src/prompt.js +28 -0
- package/src/route-caps.js +74 -0
- package/src/route-inject.js +31 -7
- package/src/route-scan.js +78 -26
- package/src/savings-ledger.js +33 -5
- package/src/seed-rules.js +3 -4
- package/src/session-cache.js +35 -1
- package/src/state-file.js +43 -0
- package/src/stats.js +20 -5
- package/src/statusline-base.js +37 -0
- package/src/stdin-payload.js +36 -5
package/src/installer.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* exist on every platform.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { writeFileSync, mkdirSync, existsSync, unlinkSync, readFileSync, rmSync } from 'node:fs';
|
|
16
|
+
import { writeFileSync, mkdirSync, existsSync, unlinkSync, readFileSync, rmSync, copyFileSync, renameSync, realpathSync, statSync } from 'node:fs';
|
|
17
17
|
import { loadConfig, saveConfig } from './config.js';
|
|
18
18
|
import { CLI_NAME, LEGACY_CLI_NAME } from './cli-name.js';
|
|
19
19
|
import { join } from 'node:path';
|
|
@@ -57,19 +57,156 @@ const LEGACY_CLI = LEGACY_CLI_NAME;
|
|
|
57
57
|
const RUNNERS = /^(npx|bunx|pnpx)$/;
|
|
58
58
|
const RUNNER_SUBCOMMANDS = { pnpm: ['dlx', 'exec'], yarn: ['dlx', 'run'], bun: ['x', 'run'], npm: ['exec'] };
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
/**
|
|
61
|
+
* The tokens that follow our executable, or null when the command does not
|
|
62
|
+
* start with it. The one place the "is this our binary" judgement lives, so
|
|
63
|
+
* `isOurCommand` and the subcommand matcher below cannot disagree.
|
|
64
|
+
*/
|
|
65
|
+
function argsAfterOurExecutable(command) {
|
|
66
|
+
if (typeof command !== 'string') return null;
|
|
62
67
|
const tokens = command.trim().split(/\s+/).filter(Boolean);
|
|
63
|
-
if (!tokens.length) return
|
|
68
|
+
if (!tokens.length) return null;
|
|
64
69
|
// `npx <pkg>` puts the package one token in, `yarn dlx <pkg>` two.
|
|
65
70
|
let i = 0;
|
|
66
71
|
if (RUNNERS.test(tokens[0])) i = 1;
|
|
67
72
|
else if (RUNNER_SUBCOMMANDS[tokens[0]]?.includes(tokens[1])) i = 2;
|
|
68
73
|
const token = tokens[i];
|
|
69
|
-
if (!token) return
|
|
74
|
+
if (!token) return null;
|
|
70
75
|
// A path invokes the binary by its last segment; strip a Windows .cmd/.exe too.
|
|
71
76
|
const exe = token.split(/[/\\]/).pop().replace(/\.(cmd|exe|ps1)$/i, '');
|
|
72
|
-
|
|
77
|
+
if (exe !== CLI && exe !== LEGACY_CLI && exe !== `${CLI}-cli`) return null;
|
|
78
|
+
return tokens.slice(i + 1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function isOurCommand(command) {
|
|
82
|
+
return argsAfterOurExecutable(command) !== null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Whether a command is our executable followed by exactly these leading
|
|
87
|
+
* arguments, e.g. `isOurSubcommand(cmd, 'korean', '--hook')`. Tokens are
|
|
88
|
+
* compared whole: `--hook` does not match `--hook-prompt`, and a command that
|
|
89
|
+
* merely carries the text as an argument (`echo "sprag korean --hook"`) never
|
|
90
|
+
* starts with our executable, so it is not ours. Trailing arguments are fine.
|
|
91
|
+
*/
|
|
92
|
+
export function isOurSubcommand(command, ...leading) {
|
|
93
|
+
const rest = argsAfterOurExecutable(command);
|
|
94
|
+
if (!rest || rest.length < leading.length) return false;
|
|
95
|
+
return leading.every((t, k) => rest[k] === t);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The copied-file form that versions before 3.x registered for the cache
|
|
100
|
+
* monitor: `node "<dir>/cache-monitor-hook.cjs" --threshold N`. Only that shape
|
|
101
|
+
* counts; any other command that happens to mention the file name is not ours.
|
|
102
|
+
*/
|
|
103
|
+
export function isLegacyCacheMonitorCommand(command) {
|
|
104
|
+
if (typeof command !== 'string') return false;
|
|
105
|
+
return /^\s*node(?:\.exe)?\s+(?:"[^"]*|'[^']*|\S*)cache-monitor-hook\.cjs["']?(?:\s|$)/.test(command);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Whether a value is a plain JSON object (not null, an array or a scalar).
|
|
110
|
+
*/
|
|
111
|
+
export function isPlainObject(v) {
|
|
112
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Read settings.json as one of three outcomes, so no caller has to re-derive
|
|
117
|
+
* them from a try/catch:
|
|
118
|
+
* { state: 'absent', settings: {} } the file does not exist
|
|
119
|
+
* { state: 'ok', settings } a plain object
|
|
120
|
+
* { state: 'unusable', reason } exists but cannot be used safely
|
|
121
|
+
* Only a missing file is treated as empty. A file that is present but broken
|
|
122
|
+
* (bad JSON, `null`, an array, a read error) belongs to the user and must not
|
|
123
|
+
* be replaced by a fresh object, so callers back off on 'unusable'.
|
|
124
|
+
*/
|
|
125
|
+
export function readSettings(file) {
|
|
126
|
+
let raw;
|
|
127
|
+
try {
|
|
128
|
+
raw = readFileSync(file, 'utf8');
|
|
129
|
+
} catch (e) {
|
|
130
|
+
if (e?.code === 'ENOENT') return { state: 'absent', settings: {} };
|
|
131
|
+
return { state: 'unusable', reason: `unreadable (${e.message})` };
|
|
132
|
+
}
|
|
133
|
+
let parsed;
|
|
134
|
+
try {
|
|
135
|
+
parsed = JSON.parse(raw);
|
|
136
|
+
} catch (e) {
|
|
137
|
+
return { state: 'unusable', reason: `unreadable JSON (${e.message})` };
|
|
138
|
+
}
|
|
139
|
+
if (!isPlainObject(parsed)) {
|
|
140
|
+
const kind = parsed === null ? 'null' : Array.isArray(parsed) ? 'an array' : `a ${typeof parsed}`;
|
|
141
|
+
return { state: 'unusable', reason: `top-level value is ${kind}, not an object — fix settings.json manually` };
|
|
142
|
+
}
|
|
143
|
+
return { state: 'ok', settings: parsed };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Write settings.json safely: keep one backup of the existing file next to it
|
|
148
|
+
* (`settings.json.sprag-bak`, overwritten each time), write a temp file in the
|
|
149
|
+
* same directory, then rename it over the target so a crash mid-write cannot
|
|
150
|
+
* leave a half-written settings file. Returns null on success or a failure
|
|
151
|
+
* reason; callers must not report success when this returns a string.
|
|
152
|
+
*/
|
|
153
|
+
export function writeSettings(file, settings) {
|
|
154
|
+
const tmp = `${file}.sprag-tmp-${process.pid}`;
|
|
155
|
+
try {
|
|
156
|
+
// Follow a symlinked settings.json so the rename replaces its target and
|
|
157
|
+
// not the link itself.
|
|
158
|
+
let target = file;
|
|
159
|
+
let mode;
|
|
160
|
+
if (existsSync(file)) {
|
|
161
|
+
target = realpathSync(file);
|
|
162
|
+
mode = statSync(target).mode & 0o777;
|
|
163
|
+
copyFileSync(target, `${target}.sprag-bak`);
|
|
164
|
+
}
|
|
165
|
+
const tmpPath = target === file ? tmp : `${target}.sprag-tmp-${process.pid}`;
|
|
166
|
+
try {
|
|
167
|
+
writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + '\n', mode === undefined ? undefined : { mode });
|
|
168
|
+
renameSync(tmpPath, target);
|
|
169
|
+
} catch (e) {
|
|
170
|
+
try { rmSync(tmpPath, { force: true }); } catch { /* best effort */ }
|
|
171
|
+
throw e;
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
} catch (e) {
|
|
175
|
+
return `write failed (${e.message})`;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* `settings.hooks` must be an object before we add events to it. Missing is
|
|
181
|
+
* fine; anything else is the user's data in a shape we do not understand.
|
|
182
|
+
* Returns a reason string when it cannot be used.
|
|
183
|
+
*/
|
|
184
|
+
function ensureHooksObject(settings) {
|
|
185
|
+
if (settings.hooks === undefined || settings.hooks === null) {
|
|
186
|
+
settings.hooks = {};
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
if (!isPlainObject(settings.hooks)) return 'hooks is not an object — fix settings.json manually';
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Drop the hooks `isOurs` matches, per hook and not per matcher group. Claude
|
|
195
|
+
* Code merges hooks that share a matcher into one group, so deleting a whole
|
|
196
|
+
* group because one entry in it is ours takes the user's other hooks with it.
|
|
197
|
+
* A group goes only once its `hooks` array is empty.
|
|
198
|
+
*/
|
|
199
|
+
function removeHooksWhere(list, isOurs) {
|
|
200
|
+
let touched = false;
|
|
201
|
+
const kept = [];
|
|
202
|
+
for (const m of list) {
|
|
203
|
+
if (!Array.isArray(m?.hooks)) { kept.push(m); continue; }
|
|
204
|
+
const hooks = m.hooks.filter((h) => !isOurs(h));
|
|
205
|
+
if (hooks.length === m.hooks.length) { kept.push(m); continue; }
|
|
206
|
+
touched = true;
|
|
207
|
+
if (hooks.length) kept.push({ ...m, hooks });
|
|
208
|
+
}
|
|
209
|
+
return { kept, touched };
|
|
73
210
|
}
|
|
74
211
|
|
|
75
212
|
const STATUSLINE_COMMAND = `${CLI} --statusline --icon`;
|
|
@@ -160,7 +297,7 @@ honor that for the rest of the turn without changing the saved setting.
|
|
|
160
297
|
|
|
161
298
|
- \`sprag last\` — most recent warning + full advice (start here).
|
|
162
299
|
- \`sprag last --days 7\` — widen the lookback window.
|
|
163
|
-
- \`sprag\` — full table report (default last
|
|
300
|
+
- \`sprag\` — full table report (default last 30 days).
|
|
164
301
|
- \`sprag --days 7\` — wider window.
|
|
165
302
|
- \`sprag history\` — recent warning transitions per day, with
|
|
166
303
|
inline \`💡\` action tips.
|
|
@@ -220,20 +357,17 @@ export function removeLegacyCommand() {
|
|
|
220
357
|
// - No statusLine yet: insert ours with refreshInterval:STATUSLINE_REFRESH_INTERVAL.
|
|
221
358
|
// - statusLine already points at sprag: ensure that refreshInterval
|
|
222
359
|
// (this is the bit that makes the TTL countdown tick every second while idle).
|
|
223
|
-
// - statusLine points at a different command: leave it alone unless --force
|
|
224
|
-
|
|
360
|
+
// - statusLine points at a different command: leave it alone unless --force,
|
|
361
|
+
// or `merge`, which keeps their command running underneath ours (the original
|
|
362
|
+
// object is saved as config `statuslineBase` so uninstall can restore it).
|
|
363
|
+
export function installStatusline({ force = false, merge = false } = {}) {
|
|
225
364
|
const dir = claudeUserDir();
|
|
226
365
|
const file = join(dir, 'settings.json');
|
|
227
366
|
mkdirSync(dir, { recursive: true });
|
|
228
367
|
|
|
229
|
-
|
|
230
|
-
if (
|
|
231
|
-
|
|
232
|
-
settings = JSON.parse(readFileSync(file, 'utf8'));
|
|
233
|
-
} catch (e) {
|
|
234
|
-
return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
|
|
235
|
-
}
|
|
236
|
-
}
|
|
368
|
+
const read = readSettings(file);
|
|
369
|
+
if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
|
|
370
|
+
const settings = read.settings;
|
|
237
371
|
|
|
238
372
|
const cur = settings.statusLine;
|
|
239
373
|
const targetsUs = !!cur && isOurCommand(cur.command);
|
|
@@ -244,7 +378,10 @@ export function installStatusline({ force = false } = {}) {
|
|
|
244
378
|
command: STATUSLINE_COMMAND,
|
|
245
379
|
refreshInterval: STATUSLINE_REFRESH_INTERVAL,
|
|
246
380
|
};
|
|
247
|
-
|
|
381
|
+
{
|
|
382
|
+
const writeProblem = writeSettings(file, settings);
|
|
383
|
+
if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
|
|
384
|
+
}
|
|
248
385
|
return { path: file, action: 'created' };
|
|
249
386
|
}
|
|
250
387
|
|
|
@@ -253,22 +390,40 @@ export function installStatusline({ force = false } = {}) {
|
|
|
253
390
|
return { path: file, action: 'exists' };
|
|
254
391
|
}
|
|
255
392
|
cur.refreshInterval = STATUSLINE_REFRESH_INTERVAL;
|
|
256
|
-
|
|
393
|
+
{
|
|
394
|
+
const writeProblem = writeSettings(file, settings);
|
|
395
|
+
if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
|
|
396
|
+
}
|
|
257
397
|
return { path: file, action: 'updated', reason: `set refreshInterval=${STATUSLINE_REFRESH_INTERVAL}` };
|
|
258
398
|
}
|
|
259
399
|
|
|
260
|
-
if (!force) {
|
|
400
|
+
if (!force && !merge) {
|
|
261
401
|
// `conflict` lets the interactive install distinguish "someone else's
|
|
262
402
|
// statusline is here" (worth asking about) from other skip reasons
|
|
263
403
|
// (unreadable JSON), which a prompt cannot fix.
|
|
264
404
|
return { path: file, action: 'skipped', conflict: true, existingCommand: cur.command, reason: `existing statusLine command (${cur.command}) — re-run with --force to overwrite` };
|
|
265
405
|
}
|
|
406
|
+
const base = cur;
|
|
266
407
|
settings.statusLine = {
|
|
267
408
|
type: 'command',
|
|
268
409
|
command: STATUSLINE_COMMAND,
|
|
269
410
|
refreshInterval: STATUSLINE_REFRESH_INTERVAL,
|
|
270
411
|
};
|
|
271
|
-
|
|
412
|
+
{
|
|
413
|
+
const writeProblem = writeSettings(file, settings);
|
|
414
|
+
if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
|
|
415
|
+
}
|
|
416
|
+
// Config is touched only after settings were written, so a failed write
|
|
417
|
+
// never leaves a saved base for a statusline we did not take over.
|
|
418
|
+
const cfg = loadConfig();
|
|
419
|
+
if (merge) {
|
|
420
|
+
cfg.statuslineBase = base;
|
|
421
|
+
saveConfig(cfg);
|
|
422
|
+
return { path: file, action: 'updated', reason: 'merged with previous statusLine', merged: true };
|
|
423
|
+
}
|
|
424
|
+
// Replace means the old one is gone for good; a stale base would resurrect it
|
|
425
|
+
// (or keep running it) later.
|
|
426
|
+
if (cfg.statuslineBase) { delete cfg.statuslineBase; saveConfig(cfg); }
|
|
272
427
|
return { path: file, action: 'updated', reason: 'replaced previous statusLine' };
|
|
273
428
|
}
|
|
274
429
|
|
|
@@ -283,16 +438,12 @@ export function installSessionStartHook() {
|
|
|
283
438
|
const file = join(dir, 'settings.json');
|
|
284
439
|
mkdirSync(dir, { recursive: true });
|
|
285
440
|
|
|
286
|
-
|
|
287
|
-
if (
|
|
288
|
-
|
|
289
|
-
settings = JSON.parse(readFileSync(file, 'utf8'));
|
|
290
|
-
} catch (e) {
|
|
291
|
-
return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
|
|
292
|
-
}
|
|
293
|
-
}
|
|
441
|
+
const read = readSettings(file);
|
|
442
|
+
if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
|
|
443
|
+
const settings = read.settings;
|
|
294
444
|
|
|
295
|
-
|
|
445
|
+
const hooksProblem = ensureHooksObject(settings);
|
|
446
|
+
if (hooksProblem) return { path: file, action: 'skipped', reason: hooksProblem };
|
|
296
447
|
// A present-but-non-array value is schema-invalid, but it's the user's
|
|
297
448
|
// data — back off instead of silently replacing it.
|
|
298
449
|
if (settings.hooks.SessionStart !== undefined && !Array.isArray(settings.hooks.SessionStart)) {
|
|
@@ -303,7 +454,7 @@ export function installSessionStartHook() {
|
|
|
303
454
|
// `route-scan --hook` as a prefix, so a substring test would see that hook and
|
|
304
455
|
// conclude this one is already installed.
|
|
305
456
|
const already = list.some((m) =>
|
|
306
|
-
Array.isArray(m?.hooks) && m.hooks.some((h) =>
|
|
457
|
+
Array.isArray(m?.hooks) && m.hooks.some((h) => isOurSubcommand(h?.command, 'route-scan', '--hook')),
|
|
307
458
|
);
|
|
308
459
|
if (already) return { path: file, action: 'exists' };
|
|
309
460
|
|
|
@@ -312,7 +463,10 @@ export function installSessionStartHook() {
|
|
|
312
463
|
hooks: [{ type: 'command', command: ROUTE_SCAN_HOOK_COMMAND, timeout: 10 }],
|
|
313
464
|
});
|
|
314
465
|
settings.hooks.SessionStart = list;
|
|
315
|
-
|
|
466
|
+
{
|
|
467
|
+
const writeProblem = writeSettings(file, settings);
|
|
468
|
+
if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
|
|
469
|
+
}
|
|
316
470
|
return { path: file, action: 'created' };
|
|
317
471
|
}
|
|
318
472
|
|
|
@@ -335,16 +489,12 @@ export function installDelegationHook() {
|
|
|
335
489
|
const file = join(dir, 'settings.json');
|
|
336
490
|
mkdirSync(dir, { recursive: true });
|
|
337
491
|
|
|
338
|
-
|
|
339
|
-
if (
|
|
340
|
-
|
|
341
|
-
settings = JSON.parse(readFileSync(file, 'utf8'));
|
|
342
|
-
} catch (e) {
|
|
343
|
-
return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
|
|
344
|
-
}
|
|
345
|
-
}
|
|
492
|
+
const read = readSettings(file);
|
|
493
|
+
if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
|
|
494
|
+
const settings = read.settings;
|
|
346
495
|
|
|
347
|
-
|
|
496
|
+
const hooksProblem = ensureHooksObject(settings);
|
|
497
|
+
if (hooksProblem) return { path: file, action: 'skipped', reason: hooksProblem };
|
|
348
498
|
if (settings.hooks.PostToolUse !== undefined && !Array.isArray(settings.hooks.PostToolUse)) {
|
|
349
499
|
return { path: file, action: 'skipped', reason: 'hooks.PostToolUse is not an array — fix settings.json manually' };
|
|
350
500
|
}
|
|
@@ -353,7 +503,7 @@ export function installDelegationHook() {
|
|
|
353
503
|
// a substring test is what confused `--hook` with `--hook-delegated`, and a
|
|
354
504
|
// future `--hook-delegated-batch` would confuse this one in turn.
|
|
355
505
|
const already = list.some((m) =>
|
|
356
|
-
Array.isArray(m?.hooks) && m.hooks.some((h) =>
|
|
506
|
+
Array.isArray(m?.hooks) && m.hooks.some((h) => isOurSubcommand(h?.command, 'route-scan', '--hook-delegated')),
|
|
357
507
|
);
|
|
358
508
|
if (already) return { path: file, action: 'exists' };
|
|
359
509
|
|
|
@@ -362,7 +512,10 @@ export function installDelegationHook() {
|
|
|
362
512
|
hooks: [{ type: 'command', command: ROUTE_SCAN_DELEGATED_HOOK_COMMAND, timeout: 10 }],
|
|
363
513
|
});
|
|
364
514
|
settings.hooks.PostToolUse = list;
|
|
365
|
-
|
|
515
|
+
{
|
|
516
|
+
const writeProblem = writeSettings(file, settings);
|
|
517
|
+
if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
|
|
518
|
+
}
|
|
366
519
|
return { path: file, action: 'created' };
|
|
367
520
|
}
|
|
368
521
|
|
|
@@ -373,16 +526,12 @@ export function installBriefHook() {
|
|
|
373
526
|
const file = join(dir, 'settings.json');
|
|
374
527
|
mkdirSync(dir, { recursive: true });
|
|
375
528
|
|
|
376
|
-
|
|
377
|
-
if (
|
|
378
|
-
|
|
379
|
-
settings = JSON.parse(readFileSync(file, 'utf8'));
|
|
380
|
-
} catch (e) {
|
|
381
|
-
return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
|
|
382
|
-
}
|
|
383
|
-
}
|
|
529
|
+
const read = readSettings(file);
|
|
530
|
+
if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
|
|
531
|
+
const settings = read.settings;
|
|
384
532
|
|
|
385
|
-
|
|
533
|
+
const hooksProblem = ensureHooksObject(settings);
|
|
534
|
+
if (hooksProblem) return { path: file, action: 'skipped', reason: hooksProblem };
|
|
386
535
|
// A present-but-non-array value is schema-invalid, but it's the user's
|
|
387
536
|
// data — back off instead of silently replacing it.
|
|
388
537
|
if (settings.hooks.UserPromptSubmit !== undefined && !Array.isArray(settings.hooks.UserPromptSubmit)) {
|
|
@@ -390,7 +539,7 @@ export function installBriefHook() {
|
|
|
390
539
|
}
|
|
391
540
|
const list = Array.isArray(settings.hooks.UserPromptSubmit) ? settings.hooks.UserPromptSubmit : [];
|
|
392
541
|
const already = list.some((m) =>
|
|
393
|
-
Array.isArray(m?.hooks) && m.hooks.some((h) =>
|
|
542
|
+
Array.isArray(m?.hooks) && m.hooks.some((h) => isOurSubcommand(h?.command, 'brief', '--hook')),
|
|
394
543
|
);
|
|
395
544
|
if (already) return { path: file, action: 'exists' };
|
|
396
545
|
|
|
@@ -398,7 +547,10 @@ export function installBriefHook() {
|
|
|
398
547
|
hooks: [{ type: 'command', command: BRIEF_HOOK_COMMAND, timeout: 10 }],
|
|
399
548
|
});
|
|
400
549
|
settings.hooks.UserPromptSubmit = list;
|
|
401
|
-
|
|
550
|
+
{
|
|
551
|
+
const writeProblem = writeSettings(file, settings);
|
|
552
|
+
if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
|
|
553
|
+
}
|
|
402
554
|
return { path: file, action: 'created' };
|
|
403
555
|
}
|
|
404
556
|
|
|
@@ -420,31 +572,38 @@ export function installKoreanLintHook() {
|
|
|
420
572
|
const file = join(dir, 'settings.json');
|
|
421
573
|
mkdirSync(dir, { recursive: true });
|
|
422
574
|
|
|
423
|
-
|
|
424
|
-
if (
|
|
425
|
-
|
|
426
|
-
settings = JSON.parse(readFileSync(file, 'utf8'));
|
|
427
|
-
} catch (e) {
|
|
428
|
-
return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
|
|
429
|
-
}
|
|
430
|
-
}
|
|
575
|
+
const read = readSettings(file);
|
|
576
|
+
if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
|
|
577
|
+
const settings = read.settings;
|
|
431
578
|
|
|
432
|
-
|
|
579
|
+
const hooksProblem = ensureHooksObject(settings);
|
|
580
|
+
if (hooksProblem) return { path: file, action: 'skipped', reason: hooksProblem };
|
|
433
581
|
if (settings.hooks.PostToolUse !== undefined && !Array.isArray(settings.hooks.PostToolUse)) {
|
|
434
582
|
return { path: file, action: 'skipped', reason: 'hooks.PostToolUse is not an array — fix settings.json manually' };
|
|
435
583
|
}
|
|
436
584
|
const list = Array.isArray(settings.hooks.PostToolUse) ? settings.hooks.PostToolUse : [];
|
|
437
585
|
const existing = list.find((m) =>
|
|
438
|
-
Array.isArray(m?.hooks) && m.hooks.some((h) =>
|
|
586
|
+
Array.isArray(m?.hooks) && m.hooks.some((h) => isOurSubcommand(h?.command, 'korean', '--hook')),
|
|
439
587
|
);
|
|
440
588
|
if (existing) {
|
|
441
589
|
// An install from before Bash joined the matcher leaves the narrow entry in
|
|
442
590
|
// place forever, so upgrading a machine would not close the gap. Widen it
|
|
443
591
|
// here; anything the user hand-edited to something wider is left alone.
|
|
444
592
|
if (existing.matcher === 'Write|Edit|MultiEdit') {
|
|
445
|
-
existing.
|
|
593
|
+
const ours = existing.hooks.filter((h) => isOurSubcommand(h?.command, 'korean', '--hook'));
|
|
594
|
+
if (ours.length === existing.hooks.length) {
|
|
595
|
+
existing.matcher = KOREAN_LINT_MATCHER;
|
|
596
|
+
} else {
|
|
597
|
+
// The group is shared with someone else's hook, and its matcher is
|
|
598
|
+
// theirs to keep. Move ours into a group of its own instead.
|
|
599
|
+
existing.hooks = existing.hooks.filter((h) => !isOurSubcommand(h?.command, 'korean', '--hook'));
|
|
600
|
+
list.push({ matcher: KOREAN_LINT_MATCHER, hooks: ours });
|
|
601
|
+
}
|
|
446
602
|
settings.hooks.PostToolUse = list;
|
|
447
|
-
|
|
603
|
+
{
|
|
604
|
+
const writeProblem = writeSettings(file, settings);
|
|
605
|
+
if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
|
|
606
|
+
}
|
|
448
607
|
return { path: file, action: 'updated' };
|
|
449
608
|
}
|
|
450
609
|
return { path: file, action: 'exists' };
|
|
@@ -455,28 +614,29 @@ export function installKoreanLintHook() {
|
|
|
455
614
|
hooks: [{ type: 'command', command: KOREAN_LINT_HOOK_COMMAND, timeout: 10 }],
|
|
456
615
|
});
|
|
457
616
|
settings.hooks.PostToolUse = list;
|
|
458
|
-
|
|
617
|
+
{
|
|
618
|
+
const writeProblem = writeSettings(file, settings);
|
|
619
|
+
if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
|
|
620
|
+
}
|
|
459
621
|
return { path: file, action: 'created' };
|
|
460
622
|
}
|
|
461
623
|
|
|
462
624
|
export function removeKoreanLintHook() {
|
|
463
625
|
const file = join(claudeUserDir(), 'settings.json');
|
|
464
626
|
if (!existsSync(file)) return { path: file, action: 'absent' };
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
} catch (e) {
|
|
469
|
-
return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
|
|
470
|
-
}
|
|
627
|
+
const read = readSettings(file);
|
|
628
|
+
if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
|
|
629
|
+
const settings = read.settings;
|
|
471
630
|
const list = settings?.hooks?.PostToolUse;
|
|
472
631
|
if (!Array.isArray(list)) return { path: file, action: 'absent' };
|
|
473
|
-
const kept = list
|
|
474
|
-
|
|
475
|
-
);
|
|
476
|
-
if (kept.length === list.length) return { path: file, action: 'absent' };
|
|
632
|
+
const { kept, touched } = removeHooksWhere(list, (h) => isOurSubcommand(h?.command, 'korean', '--hook'));
|
|
633
|
+
if (!touched) return { path: file, action: 'absent' };
|
|
477
634
|
if (kept.length === 0) delete settings.hooks.PostToolUse;
|
|
478
635
|
else settings.hooks.PostToolUse = kept;
|
|
479
|
-
|
|
636
|
+
{
|
|
637
|
+
const writeProblem = writeSettings(file, settings);
|
|
638
|
+
if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
|
|
639
|
+
}
|
|
480
640
|
return { path: file, action: 'removed' };
|
|
481
641
|
}
|
|
482
642
|
|
|
@@ -499,16 +659,12 @@ export function installDoc2mdHook() {
|
|
|
499
659
|
const file = join(dir, 'settings.json');
|
|
500
660
|
mkdirSync(dir, { recursive: true });
|
|
501
661
|
|
|
502
|
-
|
|
503
|
-
if (
|
|
504
|
-
|
|
505
|
-
settings = JSON.parse(readFileSync(file, 'utf8'));
|
|
506
|
-
} catch (e) {
|
|
507
|
-
return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
|
|
508
|
-
}
|
|
509
|
-
}
|
|
662
|
+
const read = readSettings(file);
|
|
663
|
+
if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
|
|
664
|
+
const settings = read.settings;
|
|
510
665
|
|
|
511
|
-
|
|
666
|
+
const hooksProblem = ensureHooksObject(settings);
|
|
667
|
+
if (hooksProblem) return { path: file, action: 'skipped', reason: hooksProblem };
|
|
512
668
|
if (settings.hooks.PreToolUse !== undefined && !Array.isArray(settings.hooks.PreToolUse)) {
|
|
513
669
|
return { path: file, action: 'skipped', reason: 'hooks.PreToolUse is not an array — fix settings.json manually' };
|
|
514
670
|
}
|
|
@@ -518,7 +674,7 @@ export function installDoc2mdHook() {
|
|
|
518
674
|
// happened on the first machine to try it.
|
|
519
675
|
const list = Array.isArray(settings.hooks.PreToolUse) ? settings.hooks.PreToolUse : [];
|
|
520
676
|
const hasReadHook = list.some((m) =>
|
|
521
|
-
Array.isArray(m?.hooks) && m.hooks.some((h) =>
|
|
677
|
+
Array.isArray(m?.hooks) && m.hooks.some((h) => isOurSubcommand(h?.command, 'doc2md', '--hook')),
|
|
522
678
|
);
|
|
523
679
|
if (!hasReadHook) {
|
|
524
680
|
list.push({
|
|
@@ -537,7 +693,7 @@ export function installDoc2mdHook() {
|
|
|
537
693
|
const list2 = Array.isArray(settings.hooks.PreToolUse) ? settings.hooks.PreToolUse : [];
|
|
538
694
|
const hasWriteGuard = list2.some((m) =>
|
|
539
695
|
m?.matcher === 'Edit|Write'
|
|
540
|
-
&& Array.isArray(m?.hooks) && m.hooks.some((h) =>
|
|
696
|
+
&& Array.isArray(m?.hooks) && m.hooks.some((h) => isOurSubcommand(h?.command, 'doc2md', '--hook')),
|
|
541
697
|
);
|
|
542
698
|
if (!hasWriteGuard) {
|
|
543
699
|
list2.push({
|
|
@@ -558,7 +714,7 @@ export function installDoc2mdHook() {
|
|
|
558
714
|
if (settings.hooks.UserPromptSubmit === undefined || Array.isArray(settings.hooks.UserPromptSubmit)) {
|
|
559
715
|
const prompts = Array.isArray(settings.hooks.UserPromptSubmit) ? settings.hooks.UserPromptSubmit : [];
|
|
560
716
|
const hasPromptHook = prompts.some((m) =>
|
|
561
|
-
Array.isArray(m?.hooks) && m.hooks.some((h) =>
|
|
717
|
+
Array.isArray(m?.hooks) && m.hooks.some((h) => isOurSubcommand(h?.command, 'doc2md', '--hook-prompt')),
|
|
562
718
|
);
|
|
563
719
|
if (!hasPromptHook) {
|
|
564
720
|
prompts.push({ hooks: [{ type: 'command', command: DOC2MD_PROMPT_HOOK_COMMAND }] });
|
|
@@ -568,7 +724,10 @@ export function installDoc2mdHook() {
|
|
|
568
724
|
}
|
|
569
725
|
|
|
570
726
|
if (!hasReadHook || addedPrompt || addedWriteGuard) {
|
|
571
|
-
|
|
727
|
+
{
|
|
728
|
+
const writeProblem = writeSettings(file, settings);
|
|
729
|
+
if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
|
|
730
|
+
}
|
|
572
731
|
return { path: file, action: hasReadHook ? 'updated' : 'created' };
|
|
573
732
|
}
|
|
574
733
|
return { path: file, action: 'exists' };
|
|
@@ -577,29 +736,28 @@ export function installDoc2mdHook() {
|
|
|
577
736
|
export function removeDoc2mdHook() {
|
|
578
737
|
const file = join(claudeUserDir(), 'settings.json');
|
|
579
738
|
if (!existsSync(file)) return { path: file, action: 'absent' };
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
} catch (e) {
|
|
584
|
-
return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
|
|
585
|
-
}
|
|
739
|
+
const read = readSettings(file);
|
|
740
|
+
if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
|
|
741
|
+
const settings = read.settings;
|
|
586
742
|
// Both entries go, and only this tool's own: anything else registered under
|
|
587
|
-
// either event stays exactly where the user put it.
|
|
588
|
-
// `--hook
|
|
743
|
+
// either event stays exactly where the user put it. Both `--hook` and
|
|
744
|
+
// `--hook-prompt` are matched as whole tokens.
|
|
589
745
|
let touched = false;
|
|
590
746
|
for (const event of ['PreToolUse', 'UserPromptSubmit']) {
|
|
591
747
|
const list = settings?.hooks?.[event];
|
|
592
748
|
if (!Array.isArray(list)) continue;
|
|
593
|
-
const
|
|
594
|
-
|
|
595
|
-
);
|
|
596
|
-
if (kept.length === list.length) continue;
|
|
749
|
+
const res = removeHooksWhere(list, (h) => isOurSubcommand(h?.command, 'doc2md', '--hook') || isOurSubcommand(h?.command, 'doc2md', '--hook-prompt'));
|
|
750
|
+
const kept = res.kept;
|
|
751
|
+
if (!res.touched) continue;
|
|
597
752
|
touched = true;
|
|
598
753
|
if (kept.length === 0) delete settings.hooks[event];
|
|
599
754
|
else settings.hooks[event] = kept;
|
|
600
755
|
}
|
|
601
756
|
if (!touched) return { path: file, action: 'absent' };
|
|
602
|
-
|
|
757
|
+
{
|
|
758
|
+
const writeProblem = writeSettings(file, settings);
|
|
759
|
+
if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
|
|
760
|
+
}
|
|
603
761
|
return { path: file, action: 'removed' };
|
|
604
762
|
}
|
|
605
763
|
|
|
@@ -619,14 +777,8 @@ export function removeDoc2mdHook() {
|
|
|
619
777
|
// which is exactly the kind of silent-by-default behavior install.js's other
|
|
620
778
|
// opt-in features (doc2md, korean, cohesion) avoid shipping without being asked.
|
|
621
779
|
const DELEGATION_GUARD_HOOK_COMMAND = `${CLI} delegate --hook`;
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
// test confused `--hook` with `--hook-delegated`. `(?![\\w-])` rejects both a
|
|
625
|
-
// hyphen and a word character, so `--hookfoo` is not mistaken for this hook.
|
|
626
|
-
// Not exported. Every consumer in this file goes through the wrapper below, and
|
|
627
|
-
// handing the bare pattern out would let a caller outside repeat the judgement
|
|
628
|
-
// this file just narrowed: the flag alone, with isOurCommand() skipped.
|
|
629
|
-
const DELEGATION_GUARD_HOOK_PATTERN = /delegate --hook(?![\w-])/;
|
|
780
|
+
// Install, remove and the status readout all judge a command through
|
|
781
|
+
// isDelegationGuardHookCommand() below, which compares whole tokens.
|
|
630
782
|
|
|
631
783
|
/**
|
|
632
784
|
* Whether a settings.json hook command is THIS hook: both our executable in
|
|
@@ -643,8 +795,7 @@ const DELEGATION_GUARD_HOOK_PATTERN = /delegate --hook(?![\w-])/;
|
|
|
643
795
|
*/
|
|
644
796
|
export function isDelegationGuardHookCommand(command) {
|
|
645
797
|
return typeof command === 'string'
|
|
646
|
-
&&
|
|
647
|
-
&& DELEGATION_GUARD_HOOK_PATTERN.test(command);
|
|
798
|
+
&& isOurSubcommand(command, 'delegate', '--hook');
|
|
648
799
|
}
|
|
649
800
|
|
|
650
801
|
// Named for the feature, not the flag: `installDelegationHook()` above is
|
|
@@ -664,12 +815,9 @@ export function isDelegationGuardHookCommand(command) {
|
|
|
664
815
|
export function delegationGuardHookState() {
|
|
665
816
|
const file = join(claudeUserDir(), 'settings.json');
|
|
666
817
|
if (!existsSync(file)) return { registered: false, path: file };
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
} catch (e) {
|
|
671
|
-
return { registered: false, path: file, reason: `unreadable JSON (${e.message})` };
|
|
672
|
-
}
|
|
818
|
+
const read = readSettings(file);
|
|
819
|
+
if (read.state === 'unusable') return { registered: false, path: file, reason: read.reason };
|
|
820
|
+
const settings = read.settings;
|
|
673
821
|
const list = settings?.hooks?.PreToolUse;
|
|
674
822
|
if (list === undefined) return { registered: false, path: file };
|
|
675
823
|
if (!Array.isArray(list)) {
|
|
@@ -690,16 +838,12 @@ export function installDelegationGuardHook() {
|
|
|
690
838
|
// user their file is new when it is not.
|
|
691
839
|
const existed = existsSync(file);
|
|
692
840
|
|
|
693
|
-
|
|
694
|
-
if (
|
|
695
|
-
|
|
696
|
-
settings = JSON.parse(readFileSync(file, 'utf8'));
|
|
697
|
-
} catch (e) {
|
|
698
|
-
return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
|
|
699
|
-
}
|
|
700
|
-
}
|
|
841
|
+
const read = readSettings(file);
|
|
842
|
+
if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
|
|
843
|
+
const settings = read.settings;
|
|
701
844
|
|
|
702
|
-
|
|
845
|
+
const hooksProblem = ensureHooksObject(settings);
|
|
846
|
+
if (hooksProblem) return { path: file, action: 'skipped', reason: hooksProblem };
|
|
703
847
|
if (settings.hooks.PreToolUse !== undefined && !Array.isArray(settings.hooks.PreToolUse)) {
|
|
704
848
|
return { path: file, action: 'skipped', reason: 'hooks.PreToolUse is not an array — fix settings.json manually' };
|
|
705
849
|
}
|
|
@@ -714,19 +858,19 @@ export function installDelegationGuardHook() {
|
|
|
714
858
|
hooks: [{ type: 'command', command: DELEGATION_GUARD_HOOK_COMMAND, timeout: 10 }],
|
|
715
859
|
});
|
|
716
860
|
settings.hooks.PreToolUse = list;
|
|
717
|
-
|
|
861
|
+
{
|
|
862
|
+
const writeProblem = writeSettings(file, settings);
|
|
863
|
+
if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
|
|
864
|
+
}
|
|
718
865
|
return { path: file, action: existed ? 'updated' : 'created' };
|
|
719
866
|
}
|
|
720
867
|
|
|
721
868
|
export function removeDelegationGuardHook() {
|
|
722
869
|
const file = join(claudeUserDir(), 'settings.json');
|
|
723
870
|
if (!existsSync(file)) return { path: file, action: 'absent' };
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
} catch (e) {
|
|
728
|
-
return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
|
|
729
|
-
}
|
|
871
|
+
const read = readSettings(file);
|
|
872
|
+
if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
|
|
873
|
+
const settings = read.settings;
|
|
730
874
|
const list = settings?.hooks?.PreToolUse;
|
|
731
875
|
if (!Array.isArray(list)) return { path: file, action: 'absent' };
|
|
732
876
|
// Per hook, not per matcher group — the same correction uninstallAll() above
|
|
@@ -747,7 +891,10 @@ export function removeDelegationGuardHook() {
|
|
|
747
891
|
if (!touched) return { path: file, action: 'absent' };
|
|
748
892
|
if (kept.length === 0) delete settings.hooks.PreToolUse;
|
|
749
893
|
else settings.hooks.PreToolUse = kept;
|
|
750
|
-
|
|
894
|
+
{
|
|
895
|
+
const writeProblem = writeSettings(file, settings);
|
|
896
|
+
if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
|
|
897
|
+
}
|
|
751
898
|
return { path: file, action: 'removed' };
|
|
752
899
|
}
|
|
753
900
|
|
|
@@ -769,19 +916,26 @@ export function uninstallAll({ purge = false } = {}) {
|
|
|
769
916
|
const result = { removed: [], kept: [], path: file };
|
|
770
917
|
|
|
771
918
|
if (existsSync(file)) {
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
} catch (e) {
|
|
776
|
-
return { ...result, action: 'skipped', reason: `unreadable JSON (${e.message})` };
|
|
777
|
-
}
|
|
919
|
+
const read = readSettings(file);
|
|
920
|
+
if (read.state === 'unusable') return { ...result, action: 'skipped', reason: read.reason };
|
|
921
|
+
const settings = read.settings;
|
|
778
922
|
|
|
779
923
|
// Only our own statusline command goes. Someone else's stays exactly
|
|
780
924
|
// where they put it.
|
|
781
925
|
const cur = settings.statusLine;
|
|
782
926
|
if (cur && isOurCommand(cur.command)) {
|
|
783
|
-
|
|
784
|
-
|
|
927
|
+
// A merged install saved the user's original statusline; give it back
|
|
928
|
+
// instead of leaving them with none.
|
|
929
|
+
const cfg = loadConfig();
|
|
930
|
+
if (cfg.statuslineBase) {
|
|
931
|
+
settings.statusLine = cfg.statuslineBase;
|
|
932
|
+
delete cfg.statuslineBase;
|
|
933
|
+
saveConfig(cfg);
|
|
934
|
+
result.removed.push('statusLine (restored previous)');
|
|
935
|
+
} else {
|
|
936
|
+
delete settings.statusLine;
|
|
937
|
+
result.removed.push('statusLine');
|
|
938
|
+
}
|
|
785
939
|
} else if (cur) {
|
|
786
940
|
result.kept.push(`statusLine (${cur.command || 'unrecognised'})`);
|
|
787
941
|
}
|
|
@@ -795,9 +949,8 @@ export function uninstallAll({ purge = false } = {}) {
|
|
|
795
949
|
// into one group — which it does for entries sharing a matcher, so a user who
|
|
796
950
|
// installed us second lost the first tool's hook on uninstall. A group is
|
|
797
951
|
// deleted only once nothing of anyone else's is left in it.
|
|
798
|
-
const isOurs = (h) => isOurCommand(h?.command)
|
|
799
|
-
|
|
800
|
-
for (const event of Object.keys(settings.hooks || {})) {
|
|
952
|
+
const isOurs = (h) => isOurCommand(h?.command) || isLegacyCacheMonitorCommand(h?.command);
|
|
953
|
+
for (const event of Object.keys(isPlainObject(settings.hooks) ? settings.hooks : {})) {
|
|
801
954
|
const list = settings.hooks[event];
|
|
802
955
|
if (!Array.isArray(list)) continue;
|
|
803
956
|
let touched = false;
|
|
@@ -815,9 +968,13 @@ export function uninstallAll({ purge = false } = {}) {
|
|
|
815
968
|
if (kept.length === 0) delete settings.hooks[event];
|
|
816
969
|
else settings.hooks[event] = kept;
|
|
817
970
|
}
|
|
818
|
-
if (settings.hooks && Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
971
|
+
if (isPlainObject(settings.hooks) && Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
819
972
|
|
|
820
|
-
|
|
973
|
+
// Nothing of ours in the file means nothing to rewrite (and no backup to churn).
|
|
974
|
+
if (result.removed.length > 0) {
|
|
975
|
+
const writeProblem = writeSettings(file, settings);
|
|
976
|
+
if (writeProblem) return { ...result, action: 'skipped', reason: writeProblem };
|
|
977
|
+
}
|
|
821
978
|
}
|
|
822
979
|
|
|
823
980
|
const skillDir = join(claudeUserDir(), 'skills', 'claude-token-saver');
|
|
@@ -872,12 +1029,9 @@ export function migrateLegacyCacheMonitorHook() {
|
|
|
872
1029
|
const dir = claudeUserDir();
|
|
873
1030
|
const file = join(dir, 'settings.json');
|
|
874
1031
|
if (!existsSync(file)) return { path: file, action: 'none' };
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
} catch (e) {
|
|
879
|
-
return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
|
|
880
|
-
}
|
|
1032
|
+
const read = readSettings(file);
|
|
1033
|
+
if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
|
|
1034
|
+
const settings = read.settings;
|
|
881
1035
|
const list = settings.hooks?.PostToolUse;
|
|
882
1036
|
if (!Array.isArray(list)) return { path: file, action: 'none' };
|
|
883
1037
|
|
|
@@ -885,13 +1039,16 @@ export function migrateLegacyCacheMonitorHook() {
|
|
|
885
1039
|
for (const m of list) {
|
|
886
1040
|
if (!Array.isArray(m?.hooks)) continue;
|
|
887
1041
|
for (const h of m.hooks) {
|
|
888
|
-
if (
|
|
1042
|
+
if (!isLegacyCacheMonitorCommand(h?.command)) continue;
|
|
889
1043
|
const th = h.command.match(/--threshold\s+([\d.]+)/);
|
|
890
1044
|
h.command = `${CLI} --hook-run --threshold ${th ? th[1] : '0.7'}`;
|
|
891
1045
|
migrated = true;
|
|
892
1046
|
}
|
|
893
1047
|
}
|
|
894
|
-
if (migrated)
|
|
1048
|
+
if (migrated) {
|
|
1049
|
+
const writeProblem = writeSettings(file, settings);
|
|
1050
|
+
if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
|
|
1051
|
+
}
|
|
895
1052
|
|
|
896
1053
|
const legacyCopy = join(dir, 'cache-monitor-hook.cjs');
|
|
897
1054
|
if (existsSync(legacyCopy)) {
|