sprag-cli 3.56.0 → 3.57.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 (50) hide show
  1. package/README.ko.md +1 -1
  2. package/README.md +1 -1
  3. package/bin/cli.js +58 -17
  4. package/bin/postinstall.js +22 -0
  5. package/docs/CODEX.ko.md +5 -1
  6. package/docs/CODEX.md +5 -0
  7. package/package.json +2 -2
  8. package/src/agents.js +16 -2
  9. package/src/brief.js +17 -8
  10. package/src/claude-price.js +23 -2
  11. package/src/codex-brief.js +35 -2
  12. package/src/codex-delegation.js +12 -4
  13. package/src/codex-harness.js +43 -9
  14. package/src/codex-hooks.js +24 -3
  15. package/src/codex-ledger.js +61 -5
  16. package/src/codex-panel.js +42 -22
  17. package/src/codex-rule-health.js +24 -1
  18. package/src/commands/brief.js +14 -3
  19. package/src/commands/codex-delegate.js +7 -4
  20. package/src/commands/codex.js +2 -0
  21. package/src/commands/harness.js +6 -0
  22. package/src/compact-window.js +12 -4
  23. package/src/config.js +3 -3
  24. package/src/cost.js +5 -2
  25. package/src/ctx-window-cache.js +61 -0
  26. package/src/delegation-guard.js +15 -9
  27. package/src/doc2md.cjs +22 -8
  28. package/src/fig2md.cjs +7 -1
  29. package/src/formatters/statusline.js +4 -4
  30. package/src/formatters/table.js +18 -10
  31. package/src/gateway-auth.js +31 -3
  32. package/src/harness.js +43 -12
  33. package/src/history.js +6 -2
  34. package/src/hook-manager.js +54 -32
  35. package/src/installer.js +286 -153
  36. package/src/korean-lint.cjs +40 -7
  37. package/src/litellm-budget.js +10 -1
  38. package/src/model-rules.js +88 -41
  39. package/src/month-spend.js +17 -11
  40. package/src/parser.js +232 -56
  41. package/src/postinstall.js +32 -0
  42. package/src/route-caps.js +74 -0
  43. package/src/route-inject.js +31 -7
  44. package/src/route-scan.js +78 -26
  45. package/src/savings-ledger.js +33 -5
  46. package/src/seed-rules.js +3 -4
  47. package/src/session-cache.js +35 -1
  48. package/src/state-file.js +43 -0
  49. package/src/stats.js +20 -5
  50. package/src/stdin-payload.js +18 -4
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
- function isOurCommand(command) {
61
- if (typeof command !== 'string') return false;
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 false;
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 false;
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
- return exe === CLI || exe === LEGACY_CLI || exe === `${CLI}-cli`;
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 1 day).
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.
@@ -226,14 +363,9 @@ export function installStatusline({ force = false } = {}) {
226
363
  const file = join(dir, 'settings.json');
227
364
  mkdirSync(dir, { recursive: true });
228
365
 
229
- let settings = {};
230
- if (existsSync(file)) {
231
- try {
232
- settings = JSON.parse(readFileSync(file, 'utf8'));
233
- } catch (e) {
234
- return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
235
- }
236
- }
366
+ const read = readSettings(file);
367
+ if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
368
+ const settings = read.settings;
237
369
 
238
370
  const cur = settings.statusLine;
239
371
  const targetsUs = !!cur && isOurCommand(cur.command);
@@ -244,7 +376,10 @@ export function installStatusline({ force = false } = {}) {
244
376
  command: STATUSLINE_COMMAND,
245
377
  refreshInterval: STATUSLINE_REFRESH_INTERVAL,
246
378
  };
247
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
379
+ {
380
+ const writeProblem = writeSettings(file, settings);
381
+ if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
382
+ }
248
383
  return { path: file, action: 'created' };
249
384
  }
250
385
 
@@ -253,7 +388,10 @@ export function installStatusline({ force = false } = {}) {
253
388
  return { path: file, action: 'exists' };
254
389
  }
255
390
  cur.refreshInterval = STATUSLINE_REFRESH_INTERVAL;
256
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
391
+ {
392
+ const writeProblem = writeSettings(file, settings);
393
+ if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
394
+ }
257
395
  return { path: file, action: 'updated', reason: `set refreshInterval=${STATUSLINE_REFRESH_INTERVAL}` };
258
396
  }
259
397
 
@@ -268,7 +406,10 @@ export function installStatusline({ force = false } = {}) {
268
406
  command: STATUSLINE_COMMAND,
269
407
  refreshInterval: STATUSLINE_REFRESH_INTERVAL,
270
408
  };
271
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
409
+ {
410
+ const writeProblem = writeSettings(file, settings);
411
+ if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
412
+ }
272
413
  return { path: file, action: 'updated', reason: 'replaced previous statusLine' };
273
414
  }
274
415
 
@@ -283,16 +424,12 @@ export function installSessionStartHook() {
283
424
  const file = join(dir, 'settings.json');
284
425
  mkdirSync(dir, { recursive: true });
285
426
 
286
- let settings = {};
287
- if (existsSync(file)) {
288
- try {
289
- settings = JSON.parse(readFileSync(file, 'utf8'));
290
- } catch (e) {
291
- return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
292
- }
293
- }
427
+ const read = readSettings(file);
428
+ if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
429
+ const settings = read.settings;
294
430
 
295
- settings.hooks = settings.hooks || {};
431
+ const hooksProblem = ensureHooksObject(settings);
432
+ if (hooksProblem) return { path: file, action: 'skipped', reason: hooksProblem };
296
433
  // A present-but-non-array value is schema-invalid, but it's the user's
297
434
  // data — back off instead of silently replacing it.
298
435
  if (settings.hooks.SessionStart !== undefined && !Array.isArray(settings.hooks.SessionStart)) {
@@ -303,7 +440,7 @@ export function installSessionStartHook() {
303
440
  // `route-scan --hook` as a prefix, so a substring test would see that hook and
304
441
  // conclude this one is already installed.
305
442
  const already = list.some((m) =>
306
- Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && /route-scan --hook(?![\w-])/.test(h.command)),
443
+ Array.isArray(m?.hooks) && m.hooks.some((h) => isOurSubcommand(h?.command, 'route-scan', '--hook')),
307
444
  );
308
445
  if (already) return { path: file, action: 'exists' };
309
446
 
@@ -312,7 +449,10 @@ export function installSessionStartHook() {
312
449
  hooks: [{ type: 'command', command: ROUTE_SCAN_HOOK_COMMAND, timeout: 10 }],
313
450
  });
314
451
  settings.hooks.SessionStart = list;
315
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
452
+ {
453
+ const writeProblem = writeSettings(file, settings);
454
+ if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
455
+ }
316
456
  return { path: file, action: 'created' };
317
457
  }
318
458
 
@@ -335,16 +475,12 @@ export function installDelegationHook() {
335
475
  const file = join(dir, 'settings.json');
336
476
  mkdirSync(dir, { recursive: true });
337
477
 
338
- let settings = {};
339
- if (existsSync(file)) {
340
- try {
341
- settings = JSON.parse(readFileSync(file, 'utf8'));
342
- } catch (e) {
343
- return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
344
- }
345
- }
478
+ const read = readSettings(file);
479
+ if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
480
+ const settings = read.settings;
346
481
 
347
- settings.hooks = settings.hooks || {};
482
+ const hooksProblem = ensureHooksObject(settings);
483
+ if (hooksProblem) return { path: file, action: 'skipped', reason: hooksProblem };
348
484
  if (settings.hooks.PostToolUse !== undefined && !Array.isArray(settings.hooks.PostToolUse)) {
349
485
  return { path: file, action: 'skipped', reason: 'hooks.PostToolUse is not an array — fix settings.json manually' };
350
486
  }
@@ -353,7 +489,7 @@ export function installDelegationHook() {
353
489
  // a substring test is what confused `--hook` with `--hook-delegated`, and a
354
490
  // future `--hook-delegated-batch` would confuse this one in turn.
355
491
  const already = list.some((m) =>
356
- Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && /route-scan --hook-delegated(?![\w-])/.test(h.command)),
492
+ Array.isArray(m?.hooks) && m.hooks.some((h) => isOurSubcommand(h?.command, 'route-scan', '--hook-delegated')),
357
493
  );
358
494
  if (already) return { path: file, action: 'exists' };
359
495
 
@@ -362,7 +498,10 @@ export function installDelegationHook() {
362
498
  hooks: [{ type: 'command', command: ROUTE_SCAN_DELEGATED_HOOK_COMMAND, timeout: 10 }],
363
499
  });
364
500
  settings.hooks.PostToolUse = list;
365
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
501
+ {
502
+ const writeProblem = writeSettings(file, settings);
503
+ if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
504
+ }
366
505
  return { path: file, action: 'created' };
367
506
  }
368
507
 
@@ -373,16 +512,12 @@ export function installBriefHook() {
373
512
  const file = join(dir, 'settings.json');
374
513
  mkdirSync(dir, { recursive: true });
375
514
 
376
- let settings = {};
377
- if (existsSync(file)) {
378
- try {
379
- settings = JSON.parse(readFileSync(file, 'utf8'));
380
- } catch (e) {
381
- return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
382
- }
383
- }
515
+ const read = readSettings(file);
516
+ if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
517
+ const settings = read.settings;
384
518
 
385
- settings.hooks = settings.hooks || {};
519
+ const hooksProblem = ensureHooksObject(settings);
520
+ if (hooksProblem) return { path: file, action: 'skipped', reason: hooksProblem };
386
521
  // A present-but-non-array value is schema-invalid, but it's the user's
387
522
  // data — back off instead of silently replacing it.
388
523
  if (settings.hooks.UserPromptSubmit !== undefined && !Array.isArray(settings.hooks.UserPromptSubmit)) {
@@ -390,7 +525,7 @@ export function installBriefHook() {
390
525
  }
391
526
  const list = Array.isArray(settings.hooks.UserPromptSubmit) ? settings.hooks.UserPromptSubmit : [];
392
527
  const already = list.some((m) =>
393
- Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('brief --hook')),
528
+ Array.isArray(m?.hooks) && m.hooks.some((h) => isOurSubcommand(h?.command, 'brief', '--hook')),
394
529
  );
395
530
  if (already) return { path: file, action: 'exists' };
396
531
 
@@ -398,7 +533,10 @@ export function installBriefHook() {
398
533
  hooks: [{ type: 'command', command: BRIEF_HOOK_COMMAND, timeout: 10 }],
399
534
  });
400
535
  settings.hooks.UserPromptSubmit = list;
401
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
536
+ {
537
+ const writeProblem = writeSettings(file, settings);
538
+ if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
539
+ }
402
540
  return { path: file, action: 'created' };
403
541
  }
404
542
 
@@ -420,31 +558,38 @@ export function installKoreanLintHook() {
420
558
  const file = join(dir, 'settings.json');
421
559
  mkdirSync(dir, { recursive: true });
422
560
 
423
- let settings = {};
424
- if (existsSync(file)) {
425
- try {
426
- settings = JSON.parse(readFileSync(file, 'utf8'));
427
- } catch (e) {
428
- return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
429
- }
430
- }
561
+ const read = readSettings(file);
562
+ if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
563
+ const settings = read.settings;
431
564
 
432
- settings.hooks = settings.hooks || {};
565
+ const hooksProblem = ensureHooksObject(settings);
566
+ if (hooksProblem) return { path: file, action: 'skipped', reason: hooksProblem };
433
567
  if (settings.hooks.PostToolUse !== undefined && !Array.isArray(settings.hooks.PostToolUse)) {
434
568
  return { path: file, action: 'skipped', reason: 'hooks.PostToolUse is not an array — fix settings.json manually' };
435
569
  }
436
570
  const list = Array.isArray(settings.hooks.PostToolUse) ? settings.hooks.PostToolUse : [];
437
571
  const existing = list.find((m) =>
438
- Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('korean --hook')),
572
+ Array.isArray(m?.hooks) && m.hooks.some((h) => isOurSubcommand(h?.command, 'korean', '--hook')),
439
573
  );
440
574
  if (existing) {
441
575
  // An install from before Bash joined the matcher leaves the narrow entry in
442
576
  // place forever, so upgrading a machine would not close the gap. Widen it
443
577
  // here; anything the user hand-edited to something wider is left alone.
444
578
  if (existing.matcher === 'Write|Edit|MultiEdit') {
445
- existing.matcher = KOREAN_LINT_MATCHER;
579
+ const ours = existing.hooks.filter((h) => isOurSubcommand(h?.command, 'korean', '--hook'));
580
+ if (ours.length === existing.hooks.length) {
581
+ existing.matcher = KOREAN_LINT_MATCHER;
582
+ } else {
583
+ // The group is shared with someone else's hook, and its matcher is
584
+ // theirs to keep. Move ours into a group of its own instead.
585
+ existing.hooks = existing.hooks.filter((h) => !isOurSubcommand(h?.command, 'korean', '--hook'));
586
+ list.push({ matcher: KOREAN_LINT_MATCHER, hooks: ours });
587
+ }
446
588
  settings.hooks.PostToolUse = list;
447
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
589
+ {
590
+ const writeProblem = writeSettings(file, settings);
591
+ if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
592
+ }
448
593
  return { path: file, action: 'updated' };
449
594
  }
450
595
  return { path: file, action: 'exists' };
@@ -455,28 +600,29 @@ export function installKoreanLintHook() {
455
600
  hooks: [{ type: 'command', command: KOREAN_LINT_HOOK_COMMAND, timeout: 10 }],
456
601
  });
457
602
  settings.hooks.PostToolUse = list;
458
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
603
+ {
604
+ const writeProblem = writeSettings(file, settings);
605
+ if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
606
+ }
459
607
  return { path: file, action: 'created' };
460
608
  }
461
609
 
462
610
  export function removeKoreanLintHook() {
463
611
  const file = join(claudeUserDir(), 'settings.json');
464
612
  if (!existsSync(file)) return { path: file, action: 'absent' };
465
- let settings;
466
- try {
467
- settings = JSON.parse(readFileSync(file, 'utf8'));
468
- } catch (e) {
469
- return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
470
- }
613
+ const read = readSettings(file);
614
+ if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
615
+ const settings = read.settings;
471
616
  const list = settings?.hooks?.PostToolUse;
472
617
  if (!Array.isArray(list)) return { path: file, action: 'absent' };
473
- const kept = list.filter((m) =>
474
- !(Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('korean --hook'))),
475
- );
476
- if (kept.length === list.length) return { path: file, action: 'absent' };
618
+ const { kept, touched } = removeHooksWhere(list, (h) => isOurSubcommand(h?.command, 'korean', '--hook'));
619
+ if (!touched) return { path: file, action: 'absent' };
477
620
  if (kept.length === 0) delete settings.hooks.PostToolUse;
478
621
  else settings.hooks.PostToolUse = kept;
479
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
622
+ {
623
+ const writeProblem = writeSettings(file, settings);
624
+ if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
625
+ }
480
626
  return { path: file, action: 'removed' };
481
627
  }
482
628
 
@@ -499,16 +645,12 @@ export function installDoc2mdHook() {
499
645
  const file = join(dir, 'settings.json');
500
646
  mkdirSync(dir, { recursive: true });
501
647
 
502
- let settings = {};
503
- if (existsSync(file)) {
504
- try {
505
- settings = JSON.parse(readFileSync(file, 'utf8'));
506
- } catch (e) {
507
- return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
508
- }
509
- }
648
+ const read = readSettings(file);
649
+ if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
650
+ const settings = read.settings;
510
651
 
511
- settings.hooks = settings.hooks || {};
652
+ const hooksProblem = ensureHooksObject(settings);
653
+ if (hooksProblem) return { path: file, action: 'skipped', reason: hooksProblem };
512
654
  if (settings.hooks.PreToolUse !== undefined && !Array.isArray(settings.hooks.PreToolUse)) {
513
655
  return { path: file, action: 'skipped', reason: 'hooks.PreToolUse is not an array — fix settings.json manually' };
514
656
  }
@@ -518,7 +660,7 @@ export function installDoc2mdHook() {
518
660
  // happened on the first machine to try it.
519
661
  const list = Array.isArray(settings.hooks.PreToolUse) ? settings.hooks.PreToolUse : [];
520
662
  const hasReadHook = list.some((m) =>
521
- Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && /doc2md --hook(?!-)/.test(h.command)),
663
+ Array.isArray(m?.hooks) && m.hooks.some((h) => isOurSubcommand(h?.command, 'doc2md', '--hook')),
522
664
  );
523
665
  if (!hasReadHook) {
524
666
  list.push({
@@ -537,7 +679,7 @@ export function installDoc2mdHook() {
537
679
  const list2 = Array.isArray(settings.hooks.PreToolUse) ? settings.hooks.PreToolUse : [];
538
680
  const hasWriteGuard = list2.some((m) =>
539
681
  m?.matcher === 'Edit|Write'
540
- && Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && /doc2md --hook(?!-)/.test(h.command)),
682
+ && Array.isArray(m?.hooks) && m.hooks.some((h) => isOurSubcommand(h?.command, 'doc2md', '--hook')),
541
683
  );
542
684
  if (!hasWriteGuard) {
543
685
  list2.push({
@@ -558,7 +700,7 @@ export function installDoc2mdHook() {
558
700
  if (settings.hooks.UserPromptSubmit === undefined || Array.isArray(settings.hooks.UserPromptSubmit)) {
559
701
  const prompts = Array.isArray(settings.hooks.UserPromptSubmit) ? settings.hooks.UserPromptSubmit : [];
560
702
  const hasPromptHook = prompts.some((m) =>
561
- Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('doc2md --hook-prompt')),
703
+ Array.isArray(m?.hooks) && m.hooks.some((h) => isOurSubcommand(h?.command, 'doc2md', '--hook-prompt')),
562
704
  );
563
705
  if (!hasPromptHook) {
564
706
  prompts.push({ hooks: [{ type: 'command', command: DOC2MD_PROMPT_HOOK_COMMAND }] });
@@ -568,7 +710,10 @@ export function installDoc2mdHook() {
568
710
  }
569
711
 
570
712
  if (!hasReadHook || addedPrompt || addedWriteGuard) {
571
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
713
+ {
714
+ const writeProblem = writeSettings(file, settings);
715
+ if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
716
+ }
572
717
  return { path: file, action: hasReadHook ? 'updated' : 'created' };
573
718
  }
574
719
  return { path: file, action: 'exists' };
@@ -577,29 +722,28 @@ export function installDoc2mdHook() {
577
722
  export function removeDoc2mdHook() {
578
723
  const file = join(claudeUserDir(), 'settings.json');
579
724
  if (!existsSync(file)) return { path: file, action: 'absent' };
580
- let settings;
581
- try {
582
- settings = JSON.parse(readFileSync(file, 'utf8'));
583
- } catch (e) {
584
- return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
585
- }
725
+ const read = readSettings(file);
726
+ if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
727
+ const settings = read.settings;
586
728
  // Both entries go, and only this tool's own: anything else registered under
587
- // either event stays exactly where the user put it. The substring covers
588
- // `--hook` and `--hook-prompt` alike.
729
+ // either event stays exactly where the user put it. Both `--hook` and
730
+ // `--hook-prompt` are matched as whole tokens.
589
731
  let touched = false;
590
732
  for (const event of ['PreToolUse', 'UserPromptSubmit']) {
591
733
  const list = settings?.hooks?.[event];
592
734
  if (!Array.isArray(list)) continue;
593
- const kept = list.filter((m) =>
594
- !(Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('doc2md --hook'))),
595
- );
596
- if (kept.length === list.length) continue;
735
+ const res = removeHooksWhere(list, (h) => isOurSubcommand(h?.command, 'doc2md', '--hook') || isOurSubcommand(h?.command, 'doc2md', '--hook-prompt'));
736
+ const kept = res.kept;
737
+ if (!res.touched) continue;
597
738
  touched = true;
598
739
  if (kept.length === 0) delete settings.hooks[event];
599
740
  else settings.hooks[event] = kept;
600
741
  }
601
742
  if (!touched) return { path: file, action: 'absent' };
602
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
743
+ {
744
+ const writeProblem = writeSettings(file, settings);
745
+ if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
746
+ }
603
747
  return { path: file, action: 'removed' };
604
748
  }
605
749
 
@@ -619,14 +763,8 @@ export function removeDoc2mdHook() {
619
763
  // which is exactly the kind of silent-by-default behavior install.js's other
620
764
  // opt-in features (doc2md, korean, cohesion) avoid shipping without being asked.
621
765
  const DELEGATION_GUARD_HOOK_COMMAND = `${CLI} delegate --hook`;
622
- // One pattern, shared by install, remove and the status readout. Three
623
- // hand-written variants drifted apart once already in this file: a substring
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-])/;
766
+ // Install, remove and the status readout all judge a command through
767
+ // isDelegationGuardHookCommand() below, which compares whole tokens.
630
768
 
631
769
  /**
632
770
  * Whether a settings.json hook command is THIS hook: both our executable in
@@ -643,8 +781,7 @@ const DELEGATION_GUARD_HOOK_PATTERN = /delegate --hook(?![\w-])/;
643
781
  */
644
782
  export function isDelegationGuardHookCommand(command) {
645
783
  return typeof command === 'string'
646
- && isOurCommand(command)
647
- && DELEGATION_GUARD_HOOK_PATTERN.test(command);
784
+ && isOurSubcommand(command, 'delegate', '--hook');
648
785
  }
649
786
 
650
787
  // Named for the feature, not the flag: `installDelegationHook()` above is
@@ -664,12 +801,9 @@ export function isDelegationGuardHookCommand(command) {
664
801
  export function delegationGuardHookState() {
665
802
  const file = join(claudeUserDir(), 'settings.json');
666
803
  if (!existsSync(file)) return { registered: false, path: file };
667
- let settings;
668
- try {
669
- settings = JSON.parse(readFileSync(file, 'utf8'));
670
- } catch (e) {
671
- return { registered: false, path: file, reason: `unreadable JSON (${e.message})` };
672
- }
804
+ const read = readSettings(file);
805
+ if (read.state === 'unusable') return { registered: false, path: file, reason: read.reason };
806
+ const settings = read.settings;
673
807
  const list = settings?.hooks?.PreToolUse;
674
808
  if (list === undefined) return { registered: false, path: file };
675
809
  if (!Array.isArray(list)) {
@@ -690,16 +824,12 @@ export function installDelegationGuardHook() {
690
824
  // user their file is new when it is not.
691
825
  const existed = existsSync(file);
692
826
 
693
- let settings = {};
694
- if (existed) {
695
- try {
696
- settings = JSON.parse(readFileSync(file, 'utf8'));
697
- } catch (e) {
698
- return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
699
- }
700
- }
827
+ const read = readSettings(file);
828
+ if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
829
+ const settings = read.settings;
701
830
 
702
- settings.hooks = settings.hooks || {};
831
+ const hooksProblem = ensureHooksObject(settings);
832
+ if (hooksProblem) return { path: file, action: 'skipped', reason: hooksProblem };
703
833
  if (settings.hooks.PreToolUse !== undefined && !Array.isArray(settings.hooks.PreToolUse)) {
704
834
  return { path: file, action: 'skipped', reason: 'hooks.PreToolUse is not an array — fix settings.json manually' };
705
835
  }
@@ -714,19 +844,19 @@ export function installDelegationGuardHook() {
714
844
  hooks: [{ type: 'command', command: DELEGATION_GUARD_HOOK_COMMAND, timeout: 10 }],
715
845
  });
716
846
  settings.hooks.PreToolUse = list;
717
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
847
+ {
848
+ const writeProblem = writeSettings(file, settings);
849
+ if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
850
+ }
718
851
  return { path: file, action: existed ? 'updated' : 'created' };
719
852
  }
720
853
 
721
854
  export function removeDelegationGuardHook() {
722
855
  const file = join(claudeUserDir(), 'settings.json');
723
856
  if (!existsSync(file)) return { path: file, action: 'absent' };
724
- let settings;
725
- try {
726
- settings = JSON.parse(readFileSync(file, 'utf8'));
727
- } catch (e) {
728
- return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
729
- }
857
+ const read = readSettings(file);
858
+ if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
859
+ const settings = read.settings;
730
860
  const list = settings?.hooks?.PreToolUse;
731
861
  if (!Array.isArray(list)) return { path: file, action: 'absent' };
732
862
  // Per hook, not per matcher group — the same correction uninstallAll() above
@@ -747,7 +877,10 @@ export function removeDelegationGuardHook() {
747
877
  if (!touched) return { path: file, action: 'absent' };
748
878
  if (kept.length === 0) delete settings.hooks.PreToolUse;
749
879
  else settings.hooks.PreToolUse = kept;
750
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
880
+ {
881
+ const writeProblem = writeSettings(file, settings);
882
+ if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
883
+ }
751
884
  return { path: file, action: 'removed' };
752
885
  }
753
886
 
@@ -769,12 +902,9 @@ export function uninstallAll({ purge = false } = {}) {
769
902
  const result = { removed: [], kept: [], path: file };
770
903
 
771
904
  if (existsSync(file)) {
772
- let settings;
773
- try {
774
- settings = JSON.parse(readFileSync(file, 'utf8'));
775
- } catch (e) {
776
- return { ...result, action: 'skipped', reason: `unreadable JSON (${e.message})` };
777
- }
905
+ const read = readSettings(file);
906
+ if (read.state === 'unusable') return { ...result, action: 'skipped', reason: read.reason };
907
+ const settings = read.settings;
778
908
 
779
909
  // Only our own statusline command goes. Someone else's stays exactly
780
910
  // where they put it.
@@ -795,9 +925,8 @@ export function uninstallAll({ purge = false } = {}) {
795
925
  // into one group — which it does for entries sharing a matcher, so a user who
796
926
  // installed us second lost the first tool's hook on uninstall. A group is
797
927
  // deleted only once nothing of anyone else's is left in it.
798
- const isOurs = (h) => isOurCommand(h?.command)
799
- || (typeof h?.command === 'string' && h.command.includes('cache-monitor-hook'));
800
- for (const event of Object.keys(settings.hooks || {})) {
928
+ const isOurs = (h) => isOurCommand(h?.command) || isLegacyCacheMonitorCommand(h?.command);
929
+ for (const event of Object.keys(isPlainObject(settings.hooks) ? settings.hooks : {})) {
801
930
  const list = settings.hooks[event];
802
931
  if (!Array.isArray(list)) continue;
803
932
  let touched = false;
@@ -815,9 +944,13 @@ export function uninstallAll({ purge = false } = {}) {
815
944
  if (kept.length === 0) delete settings.hooks[event];
816
945
  else settings.hooks[event] = kept;
817
946
  }
818
- if (settings.hooks && Object.keys(settings.hooks).length === 0) delete settings.hooks;
947
+ if (isPlainObject(settings.hooks) && Object.keys(settings.hooks).length === 0) delete settings.hooks;
819
948
 
820
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
949
+ // Nothing of ours in the file means nothing to rewrite (and no backup to churn).
950
+ if (result.removed.length > 0) {
951
+ const writeProblem = writeSettings(file, settings);
952
+ if (writeProblem) return { ...result, action: 'skipped', reason: writeProblem };
953
+ }
821
954
  }
822
955
 
823
956
  const skillDir = join(claudeUserDir(), 'skills', 'claude-token-saver');
@@ -872,12 +1005,9 @@ export function migrateLegacyCacheMonitorHook() {
872
1005
  const dir = claudeUserDir();
873
1006
  const file = join(dir, 'settings.json');
874
1007
  if (!existsSync(file)) return { path: file, action: 'none' };
875
- let settings;
876
- try {
877
- settings = JSON.parse(readFileSync(file, 'utf8'));
878
- } catch (e) {
879
- return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
880
- }
1008
+ const read = readSettings(file);
1009
+ if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
1010
+ const settings = read.settings;
881
1011
  const list = settings.hooks?.PostToolUse;
882
1012
  if (!Array.isArray(list)) return { path: file, action: 'none' };
883
1013
 
@@ -885,13 +1015,16 @@ export function migrateLegacyCacheMonitorHook() {
885
1015
  for (const m of list) {
886
1016
  if (!Array.isArray(m?.hooks)) continue;
887
1017
  for (const h of m.hooks) {
888
- if (typeof h?.command !== 'string' || !h.command.includes('cache-monitor-hook')) continue;
1018
+ if (!isLegacyCacheMonitorCommand(h?.command)) continue;
889
1019
  const th = h.command.match(/--threshold\s+([\d.]+)/);
890
1020
  h.command = `${CLI} --hook-run --threshold ${th ? th[1] : '0.7'}`;
891
1021
  migrated = true;
892
1022
  }
893
1023
  }
894
- if (migrated) writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
1024
+ if (migrated) {
1025
+ const writeProblem = writeSettings(file, settings);
1026
+ if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
1027
+ }
895
1028
 
896
1029
  const legacyCopy = join(dir, 'cache-monitor-hook.cjs');
897
1030
  if (existsSync(legacyCopy)) {