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.
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/bin/cli.js +58 -17
- package/bin/postinstall.js +22 -0
- package/docs/CODEX.ko.md +5 -1
- package/docs/CODEX.md +5 -0
- package/package.json +2 -2
- 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/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 +286 -153
- 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/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/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
|
-
|
|
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.
|
|
@@ -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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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) =>
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
|
474
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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.
|
|
588
|
-
// `--hook
|
|
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
|
|
594
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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-])/;
|
|
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
|
-
&&
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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 (
|
|
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)
|
|
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)) {
|