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.
Files changed (53) hide show
  1. package/README.ko.md +60 -40
  2. package/README.md +63 -41
  3. package/bin/cli.js +65 -19
  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 +14 -26
  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/commands/install.js +24 -13
  23. package/src/compact-window.js +12 -4
  24. package/src/config.js +3 -3
  25. package/src/cost.js +5 -2
  26. package/src/ctx-window-cache.js +61 -0
  27. package/src/delegation-guard.js +15 -9
  28. package/src/doc2md.cjs +22 -8
  29. package/src/fig2md.cjs +7 -1
  30. package/src/formatters/statusline.js +4 -4
  31. package/src/formatters/table.js +18 -10
  32. package/src/gateway-auth.js +31 -3
  33. package/src/harness.js +43 -12
  34. package/src/history.js +6 -2
  35. package/src/hook-manager.js +54 -32
  36. package/src/installer.js +315 -158
  37. package/src/korean-lint.cjs +40 -7
  38. package/src/litellm-budget.js +10 -1
  39. package/src/model-rules.js +88 -41
  40. package/src/month-spend.js +17 -11
  41. package/src/parser.js +232 -56
  42. package/src/postinstall.js +32 -0
  43. package/src/prompt.js +28 -0
  44. package/src/route-caps.js +74 -0
  45. package/src/route-inject.js +31 -7
  46. package/src/route-scan.js +78 -26
  47. package/src/savings-ledger.js +33 -5
  48. package/src/seed-rules.js +3 -4
  49. package/src/session-cache.js +35 -1
  50. package/src/state-file.js +43 -0
  51. package/src/stats.js +20 -5
  52. package/src/statusline-base.js +37 -0
  53. 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
- 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.
@@ -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
- export function installStatusline({ force = false } = {}) {
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
- 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
- }
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
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
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
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
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
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
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
- 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
- }
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
- settings.hooks = settings.hooks || {};
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) => typeof h?.command === 'string' && /route-scan --hook(?![\w-])/.test(h.command)),
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
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
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
- 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
- }
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
- settings.hooks = settings.hooks || {};
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) => typeof h?.command === 'string' && /route-scan --hook-delegated(?![\w-])/.test(h.command)),
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
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
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
- 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
- }
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
- settings.hooks = settings.hooks || {};
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) => typeof h?.command === 'string' && h.command.includes('brief --hook')),
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
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
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
- 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
- }
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
- settings.hooks = settings.hooks || {};
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) => typeof h?.command === 'string' && h.command.includes('korean --hook')),
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.matcher = KOREAN_LINT_MATCHER;
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
- 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
+ }
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
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
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
- 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
- }
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.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' };
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
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
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
- 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
- }
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
- settings.hooks = settings.hooks || {};
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) => typeof h?.command === 'string' && /doc2md --hook(?!-)/.test(h.command)),
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) => typeof h?.command === 'string' && /doc2md --hook(?!-)/.test(h.command)),
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) => typeof h?.command === 'string' && h.command.includes('doc2md --hook-prompt')),
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
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
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
- 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
- }
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. The substring covers
588
- // `--hook` and `--hook-prompt` alike.
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 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;
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
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
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
- // 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-])/;
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
- && isOurCommand(command)
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
- 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
- }
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
- 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
- }
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
- settings.hooks = settings.hooks || {};
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
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
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
- 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
- }
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
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
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
- 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
- }
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
- delete settings.statusLine;
784
- result.removed.push('statusLine');
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
- || (typeof h?.command === 'string' && h.command.includes('cache-monitor-hook'));
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
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
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
- 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
- }
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 (typeof h?.command !== 'string' || !h.command.includes('cache-monitor-hook')) continue;
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) writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
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)) {