@smeltjs/core 0.1.0 → 0.2.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 (61) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/args.d.ts +12 -1
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +61 -0
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/cli/config.d.ts +22 -0
  7. package/dist/cli/config.d.ts.map +1 -1
  8. package/dist/cli/config.js +32 -1
  9. package/dist/cli/config.js.map +1 -1
  10. package/dist/cli/hooks.d.ts +153 -0
  11. package/dist/cli/hooks.d.ts.map +1 -0
  12. package/dist/cli/hooks.js +1180 -0
  13. package/dist/cli/hooks.js.map +1 -0
  14. package/dist/cli/init.d.ts +3 -1
  15. package/dist/cli/init.d.ts.map +1 -1
  16. package/dist/cli/init.js +6 -0
  17. package/dist/cli/init.js.map +1 -1
  18. package/dist/cli/run.d.ts +1 -1
  19. package/dist/cli/run.d.ts.map +1 -1
  20. package/dist/cli/run.js +19 -0
  21. package/dist/cli/run.js.map +1 -1
  22. package/dist/hooks/guard-core.d.ts +164 -0
  23. package/dist/hooks/guard-core.d.ts.map +1 -0
  24. package/dist/hooks/guard-core.js +513 -0
  25. package/dist/hooks/guard-core.js.map +1 -0
  26. package/dist/hooks/shim.d.ts +85 -0
  27. package/dist/hooks/shim.d.ts.map +1 -0
  28. package/dist/hooks/shim.js +107 -0
  29. package/dist/hooks/shim.js.map +1 -0
  30. package/dist/hooks/shims/claude-code.d.ts +23 -0
  31. package/dist/hooks/shims/claude-code.d.ts.map +1 -0
  32. package/dist/hooks/shims/claude-code.js +61 -0
  33. package/dist/hooks/shims/claude-code.js.map +1 -0
  34. package/dist/hooks/shims/cline.d.ts +17 -0
  35. package/dist/hooks/shims/cline.d.ts.map +1 -0
  36. package/dist/hooks/shims/cline.js +39 -0
  37. package/dist/hooks/shims/cline.js.map +1 -0
  38. package/dist/hooks/shims/codex.d.ts +23 -0
  39. package/dist/hooks/shims/codex.d.ts.map +1 -0
  40. package/dist/hooks/shims/codex.js +56 -0
  41. package/dist/hooks/shims/codex.js.map +1 -0
  42. package/dist/hooks/shims/cursor.d.ts +19 -0
  43. package/dist/hooks/shims/cursor.d.ts.map +1 -0
  44. package/dist/hooks/shims/cursor.js +47 -0
  45. package/dist/hooks/shims/cursor.js.map +1 -0
  46. package/dist/hooks/shims/gemini.d.ts +23 -0
  47. package/dist/hooks/shims/gemini.d.ts.map +1 -0
  48. package/dist/hooks/shims/gemini.js +53 -0
  49. package/dist/hooks/shims/gemini.js.map +1 -0
  50. package/dist/hooks/shims/grok.d.ts +18 -0
  51. package/dist/hooks/shims/grok.d.ts.map +1 -0
  52. package/dist/hooks/shims/grok.js +37 -0
  53. package/dist/hooks/shims/grok.js.map +1 -0
  54. package/dist/hooks/shims/hermes.d.ts +22 -0
  55. package/dist/hooks/shims/hermes.d.ts.map +1 -0
  56. package/dist/hooks/shims/hermes.js +50 -0
  57. package/dist/hooks/shims/hermes.js.map +1 -0
  58. package/dist/net/policy.d.ts.map +1 -1
  59. package/dist/net/policy.js +1 -0
  60. package/dist/net/policy.js.map +1 -1
  61. package/package.json +12 -4
@@ -0,0 +1,1180 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, isAbsolute, join, relative, sep } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { createInterface } from 'node:readline/promises';
6
+ import { CliUsageError } from '../errors.js';
7
+ import { DEFAULT_SUGGESTION_BUDGET_BYTES, DEFAULT_THRESHOLD_BYTES } from '../hooks/guard-core.js';
8
+ import { CLI_NAME } from './args.js';
9
+ import { CONFIG_FILE_NAME, CONFIG_VERSION, findConfigFile, parseConfig } from './config.js';
10
+ /** One line of honesty per tier, shown wherever a tier label appears. */
11
+ export const TIER_HONESTY = {
12
+ verified: 'schema verified against primary docs and pinned by fixtures',
13
+ experimental: 'schema mapped from the 2026-09-02 capability matrix, not yet smoke-tested against the real binary',
14
+ advisory: 'no usable hook API — instructions only, nothing enforces them',
15
+ };
16
+ export const HARNESSES = [
17
+ {
18
+ id: 'claude-code',
19
+ name: 'Claude Code',
20
+ tier: 'verified',
21
+ detect: ['.claude'],
22
+ detectHome: ['.claude'],
23
+ instructionFile: 'CLAUDE.md',
24
+ caveats: [],
25
+ },
26
+ {
27
+ id: 'codex',
28
+ name: 'Codex CLI',
29
+ tier: 'verified',
30
+ detect: ['.codex'],
31
+ detectHome: ['.codex'],
32
+ instructionFile: 'AGENTS.md',
33
+ caveats: [
34
+ 'project-level Codex hooks run only once the project is trusted (features.hooks; see docs/research/2026-09-02-agent-enforcement.md § 3)',
35
+ ],
36
+ },
37
+ {
38
+ id: 'gemini',
39
+ name: 'Gemini CLI',
40
+ tier: 'experimental',
41
+ detect: ['.gemini'],
42
+ detectHome: ['.gemini'],
43
+ instructionFile: 'GEMINI.md',
44
+ caveats: [
45
+ 'Gemini policy-engine allow rules are ignored in non-interactive runs (google-gemini/gemini-cli#20469) — verify hook behaviour in CI before relying on it',
46
+ ],
47
+ },
48
+ {
49
+ id: 'grok',
50
+ name: 'Grok CLI',
51
+ tier: 'experimental',
52
+ detect: ['.grok'],
53
+ detectHome: ['.grok'],
54
+ instructionFile: 'AGENTS.md',
55
+ caveats: [
56
+ 'deny-only hooks: input rewrite is not supported, so rewrite mode falls back to deny',
57
+ ],
58
+ },
59
+ {
60
+ id: 'hermes',
61
+ name: 'Hermes Agent',
62
+ tier: 'experimental',
63
+ detect: ['.hermes', '.hermes.md'],
64
+ detectHome: ['.hermes'],
65
+ instructionFile: 'AGENTS.md',
66
+ caveats: [
67
+ 'Hermes memory tools bypass disabled_toolsets (NousResearch/hermes-agent#46171) — treat tool gating there as leaky',
68
+ 'hook config may need merging into ~/.hermes/config.yaml by hand; the written file says how',
69
+ ],
70
+ },
71
+ {
72
+ id: 'cursor',
73
+ name: 'Cursor',
74
+ tier: 'experimental',
75
+ detect: ['.cursor'],
76
+ detectHome: ['.cursor'],
77
+ instructionFile: 'AGENTS.md',
78
+ caveats: ['Cursor has no static permission config — gating is entirely hook code'],
79
+ },
80
+ {
81
+ id: 'opencode',
82
+ name: 'opencode',
83
+ tier: 'experimental',
84
+ detect: ['.opencode', 'opencode.json'],
85
+ detectHome: ['.config/opencode'],
86
+ instructionFile: 'AGENTS.md',
87
+ caveats: [
88
+ 'MCP tools can bypass opencode plugin hooks (sst/opencode#2319) — the guard sees built-in tools only',
89
+ ],
90
+ },
91
+ {
92
+ id: 'cline',
93
+ name: 'Cline',
94
+ tier: 'experimental',
95
+ detect: ['.clinerules'],
96
+ detectHome: [],
97
+ instructionFile: '.clinerules/smelt.md',
98
+ caveats: [
99
+ 'deny-only hooks: input rewrite is not supported, so rewrite mode falls back to deny',
100
+ ],
101
+ },
102
+ {
103
+ id: 'kilocode',
104
+ name: 'KiloCode',
105
+ tier: 'advisory',
106
+ detect: ['.kilocode'],
107
+ detectHome: ['.config/kilo'],
108
+ instructionFile: '.kilocode/rules/smelt.md',
109
+ caveats: [
110
+ 'no first-class hooks (Kilo-Org/kilocode#5827): enforcement is permissions config + MCP, both manual',
111
+ ],
112
+ },
113
+ {
114
+ id: 'aider',
115
+ name: 'Aider',
116
+ tier: 'advisory',
117
+ detect: ['.aider.conf.yml'],
118
+ detectHome: ['.aider.conf.yml'],
119
+ instructionFile: 'CONVENTIONS.md',
120
+ caveats: [
121
+ 'Aider auto-reads no rules file: add `read: CONVENTIONS.md` to .aider.conf.yml (or pass --read CONVENTIONS.md) yourself',
122
+ ],
123
+ },
124
+ ];
125
+ export function harnessById(id) {
126
+ return HARNESSES.find((spec) => spec.id === id);
127
+ }
128
+ /** A harness whose config directory exists in the project or the home directory. */
129
+ export function detectedHarnesses(cwd, home) {
130
+ return HARNESSES.filter((spec) => spec.detect.some((path) => existsSync(join(cwd, path))) ||
131
+ spec.detectHome.some((path) => existsSync(join(home, path))));
132
+ }
133
+ /* ------------------------------------------------------------------------------------
134
+ * Paths and commands
135
+ * ---------------------------------------------------------------------------------- */
136
+ /**
137
+ * The `dist` directory of this installed package — where the shipped guard-core and
138
+ * shim scripts live. Computed from this module's own location, which is
139
+ * `<pkg>/dist/cli/` in every real run (the CLI executes from `dist`); under the test
140
+ * runner it is `<pkg>/src/cli/`, and the substitution still points at `dist`, which
141
+ * is where the scripts will exist once built — the paths are written into config
142
+ * files for *node* to execute, never imported.
143
+ */
144
+ function packageDistDir() {
145
+ const here = dirname(fileURLToPath(import.meta.url)); // <pkg>/(dist|src)/cli
146
+ return join(dirname(dirname(here)), 'dist');
147
+ }
148
+ function shimScriptPath(id) {
149
+ return join(packageDistDir(), 'hooks', 'shims', `${id}.js`);
150
+ }
151
+ function guardCoreScriptPath() {
152
+ return join(packageDistDir(), 'hooks', 'guard-core.js');
153
+ }
154
+ function smeltBinPath() {
155
+ return join(packageDistDir(), 'cli', 'bin.js');
156
+ }
157
+ /** Inside the project, a project-relative path travels with the repo; outside, absolute. */
158
+ function portablePath(cwd, absolute) {
159
+ const rel = relative(cwd, absolute);
160
+ return rel.startsWith('..') || isAbsolute(rel) ? absolute : rel.split(sep).join('/');
161
+ }
162
+ function nodeCommand(cwd, script, args = '') {
163
+ return `node "${portablePath(cwd, script)}"${args === '' ? '' : ` ${args}`}`;
164
+ }
165
+ /* ------------------------------------------------------------------------------------
166
+ * Generated content
167
+ * ---------------------------------------------------------------------------------- */
168
+ /** Marker lines bracketing every block this installer owns inside a shared file. */
169
+ export const SNIPPET_START_MD = '<!-- smelt:hooks v1 start -->';
170
+ export const SNIPPET_END_MD = '<!-- smelt:hooks v1 end -->';
171
+ const SNIPPET_START_HASH = '# smelt:hooks v1 start';
172
+ const SNIPPET_END_HASH = '# smelt:hooks v1 end';
173
+ /** Substring that identifies a file (or JSON hook entry) as written by this installer. */
174
+ const OURS_TOKEN = 'smelt:hooks';
175
+ /**
176
+ * The instruction snippet — belt and braces under every shim, and the *only* layer
177
+ * for advisory harnesses. It teaches the three commands, and in particular what to do
178
+ * after a guard deny: run the named replacement, then `smelt retrieve` per marker.
179
+ */
180
+ export function instructionSnippet(thresholdBytes, budgetBytes) {
181
+ return `${SNIPPET_START_MD}
182
+
183
+ ## smelt — context discipline
184
+
185
+ This project uses [smelt](https://github.com/smeltjs/smelt) to keep large tool output
186
+ out of the context window, reversibly.
187
+
188
+ - Do not read files over ${String(thresholdBytes)} bytes raw. Run
189
+ \`smelt <file> --budget ${String(budgetBytes)} --focus <what you are looking for>\`
190
+ instead (repeat \`--focus\` per term). Focused regions survive verbatim; everything
191
+ else collapses into a one-line marker stating what was removed.
192
+ - Every marker ends in \`retrieve("hash")\`. \`smelt retrieve <hash>\` prints the
193
+ exact original bytes back. Retrieve what you actually need — retrievals are counted,
194
+ and \`smelt stats\` reports the honest expansion rate.
195
+ - For orientation, \`smelt map . --budget ${String(budgetBytes)}\` prints a ranked
196
+ symbol map of the repository.
197
+ - If a smelt guard hook denies a raw read, run the exact replacement command named in
198
+ the denial, then \`smelt retrieve\` any marker you need expanded.
199
+
200
+ ${SNIPPET_END_MD}
201
+ `;
202
+ }
203
+ /** Claude-style hook entry: one command under an optional matcher. */
204
+ function commandEntry(matcher, command) {
205
+ return {
206
+ ...(matcher === undefined ? {} : { matcher }),
207
+ hooks: [{ type: 'command', command }],
208
+ };
209
+ }
210
+ /** The three preset hooks in Claude Code's schema; Codex's hooks.json mirrors it. */
211
+ function claudeStyleEvents(ctx, shim, preToolEvent, matchers) {
212
+ const shimCommand = nodeCommand(ctx.cwd, shimScriptPath(shim));
213
+ // The trailing shell comment tags the entry as this installer's (see isOursEntry):
214
+ // a bare `cli/bin.js` substring would also match some other npm CLI's built binary.
215
+ const stats = `${nodeCommand(ctx.cwd, smeltBinPath(), 'stats')} 2>/dev/null || true # ${OURS_TOKEN}`;
216
+ const map = `${nodeCommand(ctx.cwd, smeltBinPath(), `map . --budget ${String(ctx.budgetBytes)} --cache .smelt/tags`)} 2>/dev/null || true # ${OURS_TOKEN}`;
217
+ return {
218
+ ...(ctx.guard
219
+ ? {
220
+ [preToolEvent]: [
221
+ commandEntry(matchers.read, shimCommand),
222
+ commandEntry(matchers.bash, shimCommand),
223
+ ],
224
+ }
225
+ : {}),
226
+ ...(ctx.statsOnStop ? { Stop: [commandEntry(undefined, stats)] } : {}),
227
+ ...(ctx.mapOnStart
228
+ ? { SessionStart: [commandEntry('startup|resume|clear|compact', map)] }
229
+ : {}),
230
+ };
231
+ }
232
+ /**
233
+ * True for a hook entry this installer wrote. Matched on the shim/guard script paths
234
+ * and the `smelt:hooks` token the stats/map commands carry — never on a substring as
235
+ * generic as `cli/bin.js`, which another npm CLI's built binary could share: remove
236
+ * and re-install may only ever touch entries that are provably smelt's.
237
+ */
238
+ function isOursEntry(entry) {
239
+ const text = JSON.stringify(entry) ?? '';
240
+ return (text.includes('hooks/shims/') ||
241
+ text.includes('hooks/guard-core.js') ||
242
+ text.includes(OURS_TOKEN));
243
+ }
244
+ /** The events this installer manages; foreign entries under them are always preserved. */
245
+ const MANAGED_EVENTS = ['PreToolUse', 'Stop', 'SessionStart', 'BeforeTool', 'preToolUse'];
246
+ /**
247
+ * Merge our hook entries into a JSON settings file, preserving everything foreign
248
+ * **byte-faithfully**: the merged `hooks` value is spliced into the original text, so
249
+ * unknown top-level keys, string escapes, number spellings, indentation and key order
250
+ * outside the `hooks` property ride through verbatim (founder ruling: an installer
251
+ * that reformats somebody's settings file has edited what it was never asked to).
252
+ * Inside `hooks`, unmanaged events and other people's entries under managed events
253
+ * are preserved; our previous entries are replaced (that is what makes a re-run edit
254
+ * toggles), and events left with no entries disappear. A semantic no-op returns the
255
+ * input text unchanged. Returns `undefined` when the existing file is not a JSON
256
+ * object — the caller skips the file rather than clobbering something it cannot
257
+ * understand.
258
+ */
259
+ export function mergeJsonHooks(existingText, events, shape = {}) {
260
+ let root = {};
261
+ if (existingText !== undefined) {
262
+ try {
263
+ const parsed = JSON.parse(existingText);
264
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
265
+ return undefined;
266
+ root = parsed;
267
+ }
268
+ catch {
269
+ return undefined;
270
+ }
271
+ }
272
+ const hooksValue = root['hooks'];
273
+ const existingHooks = typeof hooksValue === 'object' && hooksValue !== null && !Array.isArray(hooksValue)
274
+ ? hooksValue
275
+ : undefined;
276
+ const hooks = { ...existingHooks };
277
+ for (const event of MANAGED_EVENTS) {
278
+ const existing = Array.isArray(hooks[event]) ? hooks[event] : [];
279
+ const foreign = existing.filter((entry) => !isOursEntry(entry));
280
+ const ours = events[event] ?? [];
281
+ const merged = [...foreign, ...ours];
282
+ if (merged.length > 0)
283
+ hooks[event] = merged;
284
+ else
285
+ delete hooks[event];
286
+ }
287
+ const mergedHooks = Object.keys(hooks).length > 0 ? hooks : undefined;
288
+ // A brand-new file: nothing to preserve, render fresh two-space JSON.
289
+ if (existingText === undefined) {
290
+ const fresh = {};
291
+ if (mergedHooks !== undefined)
292
+ fresh['hooks'] = mergedHooks;
293
+ if (shape.version !== undefined)
294
+ fresh['version'] = shape.version;
295
+ return `${JSON.stringify(fresh, null, 2)}\n`;
296
+ }
297
+ const hooksChanged = JSON.stringify(existingHooks ?? null) !== JSON.stringify(mergedHooks ?? null);
298
+ const needsVersion = shape.version !== undefined && root['version'] === undefined;
299
+ if (!hooksChanged && !needsVersion)
300
+ return existingText;
301
+ const newline = existingText.includes('\r\n') ? '\r\n' : '\n';
302
+ const indent = /\n([ \t]+)"/.exec(existingText)?.[1] ?? ' ';
303
+ let text = existingText;
304
+ if (hooksChanged) {
305
+ const scan = scanJsonTopLevel(text);
306
+ /* v8 ignore next -- unreachable: JSON.parse accepted the same text above */
307
+ if (scan === undefined)
308
+ return undefined;
309
+ const property = scan.properties.find((candidate) => candidate.key === 'hooks');
310
+ if (mergedHooks === undefined) {
311
+ if (property !== undefined)
312
+ text = removeJsonProperty(text, scan, property);
313
+ }
314
+ else {
315
+ const rendered = renderJsonValue(mergedHooks, indent, newline);
316
+ text =
317
+ property !== undefined
318
+ ? `${text.slice(0, property.valueStart)}${rendered}${text.slice(property.valueEnd)}`
319
+ : insertJsonProperty(text, scan, 'hooks', rendered, indent, newline);
320
+ }
321
+ }
322
+ if (needsVersion) {
323
+ const scan = scanJsonTopLevel(text);
324
+ /* v8 ignore next -- unreachable: every splice above keeps the text valid JSON */
325
+ if (scan === undefined)
326
+ return undefined;
327
+ text = insertJsonProperty(text, scan, 'version', JSON.stringify(shape.version), indent, newline);
328
+ }
329
+ return text;
330
+ }
331
+ /**
332
+ * Locate the top-level properties of a JSON object *in its source text*, so one
333
+ * property can be replaced, inserted or removed while every other byte of the file
334
+ * rides through verbatim. `undefined` when the text is not an object — callers have
335
+ * already `JSON.parse`d it, so that is belt and braces, not a validator.
336
+ */
337
+ function scanJsonTopLevel(text) {
338
+ let i = skipJsonWhitespace(text, 0);
339
+ if (text[i] !== '{')
340
+ return undefined;
341
+ const open = i;
342
+ i = skipJsonWhitespace(text, i + 1);
343
+ const properties = [];
344
+ if (text[i] === '}')
345
+ return { open, close: i, properties };
346
+ for (;;) {
347
+ if (text[i] !== '"')
348
+ return undefined;
349
+ const keyStart = i;
350
+ const keyEnd = skipJsonString(text, i);
351
+ if (keyEnd === undefined)
352
+ return undefined;
353
+ const key = JSON.parse(text.slice(keyStart, keyEnd));
354
+ i = skipJsonWhitespace(text, keyEnd);
355
+ if (text[i] !== ':')
356
+ return undefined;
357
+ const valueStart = skipJsonWhitespace(text, i + 1);
358
+ const valueEnd = skipJsonValue(text, valueStart);
359
+ if (valueEnd === undefined)
360
+ return undefined;
361
+ properties.push({ key, keyStart, valueStart, valueEnd });
362
+ i = skipJsonWhitespace(text, valueEnd);
363
+ if (text[i] === ',') {
364
+ i = skipJsonWhitespace(text, i + 1);
365
+ continue;
366
+ }
367
+ if (text[i] === '}')
368
+ return { open, close: i, properties };
369
+ return undefined;
370
+ }
371
+ }
372
+ function skipJsonWhitespace(text, from) {
373
+ let i = from;
374
+ while (i < text.length && ' \t\r\n'.includes(text[i]))
375
+ i += 1;
376
+ return i;
377
+ }
378
+ /** `from` points at `"`; returns the offset one past the closing quote. */
379
+ function skipJsonString(text, from) {
380
+ let i = from + 1;
381
+ while (i < text.length) {
382
+ if (text[i] === '\\')
383
+ i += 2;
384
+ else if (text[i] === '"')
385
+ return i + 1;
386
+ else
387
+ i += 1;
388
+ }
389
+ return undefined;
390
+ }
391
+ function skipJsonValue(text, from) {
392
+ const first = text[from];
393
+ if (first === '"')
394
+ return skipJsonString(text, from);
395
+ if (first === '{' || first === '[') {
396
+ let depth = 0;
397
+ let i = from;
398
+ while (i < text.length) {
399
+ const ch = text[i];
400
+ if (ch === '"') {
401
+ const end = skipJsonString(text, i);
402
+ if (end === undefined)
403
+ return undefined;
404
+ i = end;
405
+ continue;
406
+ }
407
+ if (ch === '{' || ch === '[')
408
+ depth += 1;
409
+ else if (ch === '}' || ch === ']') {
410
+ depth -= 1;
411
+ if (depth === 0)
412
+ return i + 1;
413
+ }
414
+ i += 1;
415
+ }
416
+ return undefined;
417
+ }
418
+ // number / true / false / null
419
+ let i = from;
420
+ while (i < text.length && !',}] \t\r\n'.includes(text[i]))
421
+ i += 1;
422
+ return i > from ? i : undefined;
423
+ }
424
+ /** A JSON value indented for embedding at a top-level property position. */
425
+ function renderJsonValue(value, indent, newline) {
426
+ return JSON.stringify(value, null, indent).split('\n').join(`${newline}${indent}`);
427
+ }
428
+ function removeJsonProperty(text, scan, property) {
429
+ const index = scan.properties.indexOf(property);
430
+ const next = scan.properties[index + 1];
431
+ if (next !== undefined) {
432
+ // Delete through the separating comma and whitespace, up to the next key.
433
+ return text.slice(0, property.keyStart) + text.slice(next.keyStart);
434
+ }
435
+ const previous = scan.properties[index - 1];
436
+ // Last (or only) property: delete the preceding comma (if any) with it.
437
+ const from = previous !== undefined ? previous.valueEnd : scan.open + 1;
438
+ return text.slice(0, from) + text.slice(property.valueEnd);
439
+ }
440
+ function insertJsonProperty(text, scan, key, renderedValue, indent, newline) {
441
+ const entry = `${JSON.stringify(key)}: ${renderedValue}`;
442
+ if (scan.properties.length === 0) {
443
+ return `${text.slice(0, scan.open + 1)}${newline}${indent}${entry}${newline}${text.slice(scan.close)}`;
444
+ }
445
+ const last = scan.properties[scan.properties.length - 1];
446
+ return `${text.slice(0, last.valueEnd)},${newline}${indent}${entry}${text.slice(last.valueEnd)}`;
447
+ }
448
+ /** Replace this installer's marker block in `existingText`, or append it. */
449
+ export function upsertMarkerBlock(existingText, block, start, end) {
450
+ if (existingText === undefined || existingText.trim() === '')
451
+ return block;
452
+ const startIndex = existingText.indexOf(start);
453
+ const endIndex = existingText.indexOf(end);
454
+ if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) {
455
+ const before = existingText.slice(0, startIndex);
456
+ const after = existingText.slice(endIndex + end.length).replace(/^\n/, '');
457
+ return `${before}${block}${after}`;
458
+ }
459
+ return `${existingText.replace(/\n*$/, '\n\n')}${block}`;
460
+ }
461
+ /** Remove the marker block. `undefined` when nothing (or only whitespace) remains. */
462
+ export function stripMarkerBlock(existingText, start, end) {
463
+ const startIndex = existingText.indexOf(start);
464
+ const endIndex = existingText.indexOf(end);
465
+ if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex)
466
+ return existingText;
467
+ const stripped = existingText.slice(0, startIndex).replace(/\n+$/, '\n') +
468
+ existingText.slice(endIndex + end.length).replace(/^\n+/, '');
469
+ return stripped.trim() === '' ? undefined : stripped;
470
+ }
471
+ /** The Codex `config.toml` block: enables the hooks feature, marker-bracketed. */
472
+ function codexConfigTomlBlock() {
473
+ return `${SNIPPET_START_HASH}
474
+ # Enables Codex's hooks feature so .codex/hooks.json is honored. Project-level hooks
475
+ # run only once this project is trusted. Written by \`smelt hooks install\`.
476
+ [features]
477
+ hooks = true
478
+ ${SNIPPET_END_HASH}
479
+ `;
480
+ }
481
+ /** The opencode plugin — the shim for a harness whose hooks are a JS plugin API. */
482
+ function opencodePluginSource(cwd) {
483
+ const guardCore = portablePath(cwd, guardCoreScriptPath());
484
+ return `// smelt:hooks v1 — opencode plugin shim. EXPERIMENTAL tier: mapped from the
485
+ // capability matrix (docs/research/2026-09-02-harness-capability-matrix.md, opencode
486
+ // row; https://opencode.ai/docs/plugins/). This template's deny/pass/window paths
487
+ // were exercised directly against the built guard core (KOT-212 verification,
488
+ // 2026-09-02), but a live opencode session has not been smoke-tested — that needs
489
+ // provider credentials. Caveat carried from the matrix: MCP tools can bypass plugin
490
+ // hooks (sst/opencode#2319) — this guard sees built-in tools only.
491
+ //
492
+ // Thin adapter: maps tool.execute.before onto the smelt guard core (zero
493
+ // dependencies), which owns every decision. Deny mode throws (opencode surfaces the
494
+ // reason to the model); rewrite mode substitutes the faithful replacement command —
495
+ // announced on stderr, because the plugin API has no reason channel on a rewrite
496
+ // and a substitution must never be silent.
497
+ import { pathToFileURL } from 'node:url';
498
+
499
+ const GUARD_CORE = ${JSON.stringify(guardCore)};
500
+ const core = await import(pathToFileURL(GUARD_CORE).href);
501
+
502
+ export const SmeltGuard = async () => ({
503
+ 'tool.execute.before': async (input, output) => {
504
+ const tool = input?.tool;
505
+ const args = output?.args ?? {};
506
+ let request;
507
+ if (tool === 'read' && typeof args.filePath === 'string') {
508
+ request = {
509
+ tool: 'Read',
510
+ input: {
511
+ path: args.filePath,
512
+ offsetLimited: args.offset !== undefined || args.limit !== undefined,
513
+ },
514
+ };
515
+ } else if (tool === 'bash' && typeof args.command === 'string') {
516
+ request = { tool: 'Bash', input: { command: args.command } };
517
+ } else {
518
+ return;
519
+ }
520
+ const warn = (text) => process.stderr.write(text + '\\n');
521
+ const settings = core.readGuardSettings(process.cwd(), warn);
522
+ const decision = core.decide(request, settings, process.cwd());
523
+ if (decision.action !== 'deny') return;
524
+ if (
525
+ settings.enforcement === 'rewrite' &&
526
+ request.tool === 'Bash' &&
527
+ decision.suggestion !== undefined
528
+ ) {
529
+ warn(
530
+ 'smelt guard (rewrite mode): substituted the command in-flight with \`' +
531
+ decision.suggestion +
532
+ '\`. ' +
533
+ (decision.reason ?? ''),
534
+ );
535
+ output.args.command = decision.suggestion;
536
+ return;
537
+ }
538
+ throw new Error(decision.reason ?? 'denied by the smelt guard');
539
+ },
540
+ });
541
+ `;
542
+ }
543
+ /** Cline's hook is an executable file; this two-liner hands it to the cline shim. */
544
+ function clineHookSource(cwd) {
545
+ return `#!/bin/sh
546
+ # smelt:hooks v1 — Cline PreToolUse hook. EXPERIMENTAL tier: schema mapped from the
547
+ # capability matrix (docs/research/2026-09-02-harness-capability-matrix.md, Cline row),
548
+ # not yet smoke-tested against the real binary. Written by \`smelt hooks install\`.
549
+ exec node "${portablePath(cwd, shimScriptPath('cline'))}"
550
+ `;
551
+ }
552
+ /** Hermes hook config, as a mergeable snippet — their config is a home-level YAML. */
553
+ function hermesHooksYaml(cwd) {
554
+ return `${SNIPPET_START_HASH}
555
+ # Hermes Agent hook config for the smelt guard. EXPERIMENTAL tier: schema mapped from
556
+ # the capability matrix (docs/research/2026-09-02-harness-capability-matrix.md, Hermes
557
+ # row), not yet smoke-tested against the real binary. If Hermes does not read this
558
+ # file directly, merge the \`hooks:\` section into ~/.hermes/config.yaml.
559
+ hooks:
560
+ pre_tool_call:
561
+ - command: node "${portablePath(cwd, shimScriptPath('hermes'))}"
562
+ ${SNIPPET_END_HASH}
563
+ `;
564
+ }
565
+ /** KiloCode's advisory rules file: the snippet plus the two manual enforcement legs. */
566
+ function kilocodeRulesSource(thresholdBytes, budgetBytes) {
567
+ return `${instructionSnippet(thresholdBytes, budgetBytes)}
568
+ <!-- smelt:hooks v1 advisory notes -->
569
+
570
+ KiloCode has no first-class hook API (Kilo-Org/kilocode#5827), so nothing above is
571
+ enforced — it is advisory. Two manual legs make it harder to bypass:
572
+
573
+ 1. Permissions: in your KiloCode per-tool permission config, set raw-read/execute
574
+ tools to "ask" so oversized reads surface for review instead of passing silently.
575
+ 2. MCP: expose smelt through an MCP server and prefer its tools; a resident server
576
+ also keeps smelt's grammar cache warm across calls.
577
+ `;
578
+ }
579
+ function planFile(cwd, name, content, mode) {
580
+ const path = join(cwd, name);
581
+ const exists = existsSync(path);
582
+ const unchanged = exists && readFileSync(path, 'utf8') === content;
583
+ return { name, path, content, exists, unchanged, ...(mode === undefined ? {} : { mode }) };
584
+ }
585
+ function readIfExists(path) {
586
+ return existsSync(path) ? readFileSync(path, 'utf8') : undefined;
587
+ }
588
+ /**
589
+ * Every file `install` would write, computed against the current disk state — pure
590
+ * planning, nothing written. Shared instruction files (several harnesses read
591
+ * AGENTS.md) are planned once.
592
+ *
593
+ * @throws {CliUsageError} when an existing `smelt.config.json` is malformed — the
594
+ * same refusal every other subcommand makes; an installer that guessed around a
595
+ * broken config would write settings the guard then ignores.
596
+ */
597
+ export function planInstall(cwd, choices) {
598
+ const files = new Map();
599
+ const skipped = [];
600
+ const notes = [];
601
+ // -- smelt.config.json: the guard's runtime settings live here, not in any harness
602
+ // file, so every shim reads one source of truth.
603
+ const configPath = findConfigFile(cwd) ?? join(cwd, CONFIG_FILE_NAME);
604
+ const existingConfig = readIfExists(configPath) === undefined
605
+ ? undefined
606
+ : parseConfig(readFileSync(configPath, 'utf8'), configPath);
607
+ const hooksBlock = {
608
+ thresholdBytes: choices.thresholdBytes,
609
+ enforcement: choices.enforcement,
610
+ };
611
+ const budgetBytes = existingConfig?.defaultBudgetBytes ?? DEFAULT_SUGGESTION_BUDGET_BYTES;
612
+ files.set(configPath, {
613
+ name: portablePath(cwd, configPath),
614
+ path: configPath,
615
+ content: renderConfigWithHooks(existingConfig, hooksBlock),
616
+ exists: existsSync(configPath),
617
+ unchanged: readIfExists(configPath) === renderConfigWithHooks(existingConfig, hooksBlock),
618
+ });
619
+ const ctx = {
620
+ cwd,
621
+ guard: choices.guard,
622
+ statsOnStop: choices.statsOnStop,
623
+ mapOnStart: choices.mapOnStart,
624
+ budgetBytes,
625
+ };
626
+ const snippet = instructionSnippet(choices.thresholdBytes, budgetBytes);
627
+ const planJsonHooks = (name, events, shape = {}) => {
628
+ const path = join(cwd, name);
629
+ // Nothing to install and nothing to strip: don't create an empty hooks file.
630
+ if (Object.keys(events).length === 0 && !existsSync(path))
631
+ return;
632
+ const merged = mergeJsonHooks(readIfExists(path), events, shape);
633
+ if (merged === undefined) {
634
+ skipped.push({
635
+ name,
636
+ why: 'exists but is not a JSON object — fix or remove it, then re-run',
637
+ });
638
+ return;
639
+ }
640
+ files.set(path, planFile(cwd, name, merged));
641
+ };
642
+ const planSnippetFile = (name) => {
643
+ const path = join(cwd, name);
644
+ if (files.has(path))
645
+ return; // shared instruction file, already planned
646
+ files.set(path, planFile(cwd, name, upsertMarkerBlock(readIfExists(path), snippet, SNIPPET_START_MD, SNIPPET_END_MD)));
647
+ };
648
+ for (const spec of choices.harnesses) {
649
+ switch (spec.id) {
650
+ case 'claude-code': {
651
+ planJsonHooks('.claude/settings.json', claudeStyleEvents(ctx, 'claude-code', 'PreToolUse', { read: 'Read', bash: 'Bash' }));
652
+ break;
653
+ }
654
+ case 'codex': {
655
+ planJsonHooks('.codex/hooks.json', claudeStyleEvents(ctx, 'codex', 'PreToolUse', { read: 'Read', bash: 'Bash' }));
656
+ const tomlPath = join(cwd, '.codex/config.toml');
657
+ const existingToml = readIfExists(tomlPath);
658
+ if (existingToml !== undefined &&
659
+ !existingToml.includes(SNIPPET_START_HASH) &&
660
+ existingToml.includes('[features]')) {
661
+ skipped.push({
662
+ name: '.codex/config.toml',
663
+ why: 'already has a [features] table — add `hooks = true` to it yourself',
664
+ });
665
+ }
666
+ else {
667
+ files.set(tomlPath, planFile(cwd, '.codex/config.toml', upsertMarkerBlock(existingToml, codexConfigTomlBlock(), SNIPPET_START_HASH, SNIPPET_END_HASH)));
668
+ }
669
+ break;
670
+ }
671
+ case 'gemini': {
672
+ planJsonHooks('.gemini/settings.json', ctx.guard
673
+ ? {
674
+ BeforeTool: [
675
+ commandEntry('read_file', nodeCommand(cwd, shimScriptPath('gemini'))),
676
+ commandEntry('run_shell_command', nodeCommand(cwd, shimScriptPath('gemini'))),
677
+ ],
678
+ }
679
+ : {});
680
+ break;
681
+ }
682
+ case 'grok': {
683
+ planJsonHooks('.grok/hooks.json', ctx.guard
684
+ ? {
685
+ PreToolUse: [
686
+ commandEntry('Read', nodeCommand(cwd, shimScriptPath('grok'))),
687
+ commandEntry('Bash', nodeCommand(cwd, shimScriptPath('grok'))),
688
+ ],
689
+ }
690
+ : {});
691
+ break;
692
+ }
693
+ case 'hermes': {
694
+ if (ctx.guard) {
695
+ files.set(join(cwd, '.hermes/hooks.yaml'), planFile(cwd, '.hermes/hooks.yaml', hermesHooksYaml(cwd)));
696
+ }
697
+ break;
698
+ }
699
+ case 'cursor': {
700
+ planJsonHooks('.cursor/hooks.json', ctx.guard
701
+ ? { preToolUse: [{ command: nodeCommand(cwd, shimScriptPath('cursor')) }] }
702
+ : {}, { version: 1 });
703
+ break;
704
+ }
705
+ case 'opencode': {
706
+ if (ctx.guard) {
707
+ files.set(join(cwd, '.opencode/plugin/smelt-guard.js'), planFile(cwd, '.opencode/plugin/smelt-guard.js', opencodePluginSource(cwd)));
708
+ }
709
+ break;
710
+ }
711
+ case 'cline': {
712
+ if (ctx.guard) {
713
+ files.set(join(cwd, '.clinerules/hooks/PreToolUse'), planFile(cwd, '.clinerules/hooks/PreToolUse', clineHookSource(cwd), 0o755));
714
+ }
715
+ break;
716
+ }
717
+ case 'kilocode': {
718
+ files.set(join(cwd, spec.instructionFile), planFile(cwd, spec.instructionFile, kilocodeRulesSource(choices.thresholdBytes, budgetBytes)));
719
+ break;
720
+ }
721
+ case 'aider':
722
+ break; // instruction file only, planned below
723
+ }
724
+ if (spec.id !== 'kilocode')
725
+ planSnippetFile(spec.instructionFile);
726
+ for (const caveat of spec.caveats)
727
+ notes.push(`${spec.name}: ${caveat}`);
728
+ }
729
+ return { files: [...files.values()], skipped, notes };
730
+ }
731
+ /** Where the installed config points the persistent store, relative to the config file. */
732
+ export const DEFAULT_STORE_DIR = '.smelt/store';
733
+ /**
734
+ * Existing config re-rendered with the hooks block, other fields carried verbatim —
735
+ * except that a config with **no** store block gains a directory store. The deny
736
+ * reasons and the instruction snippet teach `smelt retrieve <hash>`, and retrieval
737
+ * across processes needs a persistent store (`smelt retrieve` refuses a memory
738
+ * store, exit 2) — an install whose own guard promises a command the installed
739
+ * config cannot run would be the exact silent-failure shape this project refuses.
740
+ * An *explicit* `{"kind":"memory"}` is respected; the guard then conditions its
741
+ * retrieve promise on the store kind instead (`retrieveSentence` in guard-core).
742
+ */
743
+ export function renderConfigWithHooks(existing, hooks) {
744
+ const config = {
745
+ smeltConfig: CONFIG_VERSION,
746
+ ...(existing?.defaultBudgetBytes === undefined
747
+ ? {}
748
+ : { defaultBudgetBytes: existing.defaultBudgetBytes }),
749
+ ...(existing?.strategy === undefined ? {} : { strategy: existing.strategy }),
750
+ store: existing?.store ?? { kind: 'directory', path: DEFAULT_STORE_DIR },
751
+ hooks,
752
+ };
753
+ return `${JSON.stringify(config, null, 2)}\n`;
754
+ }
755
+ /** Everything `remove` would delete or strip, computed against the current disk state. */
756
+ export function planRemove(cwd, harnesses) {
757
+ const removals = new Map();
758
+ const planJsonStrip = (name) => {
759
+ const path = join(cwd, name);
760
+ const existing = readIfExists(path);
761
+ if (existing === undefined)
762
+ return;
763
+ const stripped = mergeJsonHooks(existing, {});
764
+ if (stripped === undefined || stripped === existing)
765
+ return;
766
+ const remains = JSON.parse(stripped);
767
+ const empty = typeof remains === 'object' &&
768
+ remains !== null &&
769
+ Object.keys(remains).filter((key) => key !== 'version').length ===
770
+ 0;
771
+ removals.set(path, empty && existing.includes('hooks')
772
+ ? { name, path, action: 'delete' }
773
+ : { name, path, action: 'modify', content: stripped });
774
+ };
775
+ const planBlockStrip = (name, start, end) => {
776
+ const path = join(cwd, name);
777
+ const existing = readIfExists(path);
778
+ if (existing === undefined || !existing.includes(start))
779
+ return;
780
+ const stripped = stripMarkerBlock(existing, start, end);
781
+ removals.set(path, stripped === undefined
782
+ ? { name, path, action: 'delete' }
783
+ : { name, path, action: 'modify', content: stripped });
784
+ };
785
+ const planWholeFileDelete = (name) => {
786
+ const path = join(cwd, name);
787
+ const existing = readIfExists(path);
788
+ if (existing === undefined || !existing.includes(OURS_TOKEN))
789
+ return;
790
+ removals.set(path, { name, path, action: 'delete' });
791
+ };
792
+ for (const spec of harnesses) {
793
+ switch (spec.id) {
794
+ case 'claude-code':
795
+ planJsonStrip('.claude/settings.json');
796
+ break;
797
+ case 'codex':
798
+ planJsonStrip('.codex/hooks.json');
799
+ planBlockStrip('.codex/config.toml', SNIPPET_START_HASH, SNIPPET_END_HASH);
800
+ break;
801
+ case 'gemini':
802
+ planJsonStrip('.gemini/settings.json');
803
+ break;
804
+ case 'grok':
805
+ planJsonStrip('.grok/hooks.json');
806
+ break;
807
+ case 'hermes':
808
+ planWholeFileDelete('.hermes/hooks.yaml');
809
+ break;
810
+ case 'cursor':
811
+ planJsonStrip('.cursor/hooks.json');
812
+ break;
813
+ case 'opencode':
814
+ planWholeFileDelete('.opencode/plugin/smelt-guard.js');
815
+ break;
816
+ case 'cline':
817
+ planWholeFileDelete('.clinerules/hooks/PreToolUse');
818
+ break;
819
+ case 'kilocode':
820
+ case 'aider':
821
+ break;
822
+ }
823
+ if (spec.id === 'kilocode')
824
+ planWholeFileDelete(spec.instructionFile);
825
+ else
826
+ planBlockStrip(spec.instructionFile, SNIPPET_START_MD, SNIPPET_END_MD);
827
+ }
828
+ return [...removals.values()];
829
+ }
830
+ /**
831
+ * `smelt hooks <install|remove>`, start to finish. The same testability pattern as
832
+ * `runInit`: a pure function over an input/output pair, exit code returned.
833
+ */
834
+ export async function runHooks(action, harnessFlag, io) {
835
+ const rl = createInterface({ input: io.input });
836
+ const lines = rl[Symbol.asyncIterator]();
837
+ const ask = async (prompt) => {
838
+ io.output(prompt);
839
+ const next = await lines.next();
840
+ if (next.done === true) {
841
+ throw new CliUsageError(`${CLI_NAME} hooks: input ended before the wizard finished. ` +
842
+ `Files already confirmed and written stay; nothing further was written.`);
843
+ }
844
+ return next.value.trim();
845
+ };
846
+ try {
847
+ return action === 'install'
848
+ ? await installFlow(io, ask, harnessFlag)
849
+ : await removeFlow(io, ask, harnessFlag);
850
+ }
851
+ finally {
852
+ rl.close();
853
+ }
854
+ }
855
+ function resolveHarnessFlag(flag) {
856
+ const spec = harnessById(flag);
857
+ if (spec === undefined) {
858
+ throw new CliUsageError(`${CLI_NAME} hooks: unknown harness "${flag}". ` +
859
+ `Known: ${HARNESSES.map((h) => h.id).join(', ')}.`);
860
+ }
861
+ return spec;
862
+ }
863
+ function tierLabel(spec) {
864
+ return `${spec.id.padEnd(12)} ${spec.name.padEnd(14)} [${spec.tier}] — ${TIER_HONESTY[spec.tier]}`;
865
+ }
866
+ async function installFlow(io, ask, harnessFlag) {
867
+ const home = io.home ?? homedir();
868
+ const detected = detectedHarnesses(io.cwd, home);
869
+ io.output(`${CLI_NAME} hooks install — wires the smelt guard into agent-harness hooks.\n` +
870
+ `Answer \`back\` at any step to return to the previous one. Nothing is written ` +
871
+ `until you confirm at the end.\n\n`);
872
+ const choices = {
873
+ harnesses: harnessFlag !== undefined ? [resolveHarnessFlag(harnessFlag)] : [...detected],
874
+ ...presetToggles(io.cwd),
875
+ enforcement: 'deny',
876
+ thresholdBytes: DEFAULT_THRESHOLD_BYTES,
877
+ };
878
+ // With --harness the selection step is skipped, so the tier label — and its one
879
+ // line of honesty about what the tier means — is printed here instead.
880
+ if (harnessFlag !== undefined) {
881
+ for (const spec of choices.harnesses)
882
+ io.output(` ${tierLabel(spec)}\n`);
883
+ }
884
+ const steps = [
885
+ async (io_, ask_) => harnessFlag !== undefined ? 'ok' : stepHarnesses(io_, ask_, choices, detected),
886
+ async (io_, ask_) => stepToggle(io_, ask_, 'PreToolUse size-guard', guardCopy(), choices.guard, (on) => {
887
+ choices.guard = on;
888
+ }),
889
+ async (io_, ask_) => stepToggle(io_, ask_, 'stats on Stop', `\`smelt stats\` runs when a session ends — the honest signal (expansion rate) ` +
890
+ `surfaced where the turn ends. Observation only; never blocks. Wired for ` +
891
+ `verified-tier harnesses (Claude Code, Codex).`, choices.statsOnStop, (on) => {
892
+ choices.statsOnStop = on;
893
+ }),
894
+ async (io_, ask_) => stepToggle(io_, ask_, 'repo map on SessionStart', `\`smelt map . --budget …\` runs at session start and its output opens the ` +
895
+ `context — the agent starts oriented. Costs one map build per session. ` +
896
+ `Wired for verified-tier harnesses (Claude Code, Codex).`, choices.mapOnStart, (on) => {
897
+ choices.mapOnStart = on;
898
+ }),
899
+ async (io_, ask_) => stepEnforcement(io_, ask_, choices),
900
+ async (io_, ask_) => stepThreshold(io_, ask_, choices),
901
+ ];
902
+ let index = 0;
903
+ for (;;) {
904
+ while (index < steps.length) {
905
+ const outcome = await steps[index](io, ask);
906
+ if (outcome === 'back') {
907
+ if (index === 0)
908
+ io.output(`This is the first step — there is nothing before it.\n`);
909
+ else
910
+ index -= 1;
911
+ }
912
+ else {
913
+ index += 1;
914
+ }
915
+ }
916
+ if (choices.harnesses.length === 0) {
917
+ io.output(`No harness selected. Nothing to do; nothing was written.\n`);
918
+ return 0;
919
+ }
920
+ const verdict = await confirmAndInstall(io, ask, choices);
921
+ if (verdict !== 'back')
922
+ return 0;
923
+ index = steps.length - 1;
924
+ }
925
+ }
926
+ function guardCopy() {
927
+ return (`Denies raw Reads (and simple \`cat\`s) of files over the size threshold, with a ` +
928
+ `reason naming the exact \`smelt\` replacement — the model still sees everything: ` +
929
+ `smelted first, \`smelt retrieve\` for the rest. Windowed reads (offset/limit) ` +
930
+ `always pass.`);
931
+ }
932
+ async function stepHarnesses(io, ask, choices, detected) {
933
+ io.output(`\nHarnesses — detected by their config directories (project or home):\n`);
934
+ for (const spec of HARNESSES) {
935
+ const mark = detected.includes(spec) ? '*' : ' ';
936
+ io.output(` ${mark} ${tierLabel(spec)}\n`);
937
+ }
938
+ io.output(`(* = detected here)\n`);
939
+ for (;;) {
940
+ const current = choices.harnesses.map((spec) => spec.id).join(',') || '(none)';
941
+ const answer = await ask(`install for which? (comma-separated ids, Enter = ${current}, or back)\n> `);
942
+ if (answer === 'back')
943
+ return 'back';
944
+ if (answer === '')
945
+ return 'ok';
946
+ const ids = answer
947
+ .split(',')
948
+ .map((id) => id.trim())
949
+ .filter((id) => id !== '');
950
+ const specs = [];
951
+ let bad;
952
+ for (const id of ids) {
953
+ const spec = harnessById(id);
954
+ if (spec === undefined)
955
+ bad = id;
956
+ else if (!specs.includes(spec))
957
+ specs.push(spec);
958
+ }
959
+ if (bad !== undefined) {
960
+ io.output(`Unknown harness "${bad}". Known: ${HARNESSES.map((h) => h.id).join(', ')}.\n`);
961
+ continue;
962
+ }
963
+ choices.harnesses = specs;
964
+ return 'ok';
965
+ }
966
+ }
967
+ async function stepToggle(io, ask, name, copy, current, set) {
968
+ io.output(`\n${name} — ${copy}\n`);
969
+ for (;;) {
970
+ const answer = await ask(`${name}? (on/off) [${current ? 'on' : 'off'}] (or back)> `);
971
+ if (answer === 'back')
972
+ return 'back';
973
+ if (answer === '')
974
+ return 'ok';
975
+ if (answer === 'on' || answer === 'off') {
976
+ set(answer === 'on');
977
+ return 'ok';
978
+ }
979
+ io.output(`on, off, or back.\n`);
980
+ }
981
+ }
982
+ async function stepEnforcement(io, ask, choices) {
983
+ io.output(`\nEnforcement — what happens when the guard catches an oversized raw read:\n` +
984
+ ` 1. deny — refuse with a reason naming the exact replacement command. The\n` +
985
+ ` transcript stays truthful; the model runs the replacement itself.\n` +
986
+ ` 2. rewrite — on harnesses whose hooks can modify tool input, substitute the\n` +
987
+ ` replacement in-flight (grep/cat piped through smelt). Never\n` +
988
+ ` silent: the substitution is announced in the decision reason\n` +
989
+ ` where the harness has one, on stderr where it does not.\n` +
990
+ ` Harnesses that cannot rewrite fall back to deny.\n`);
991
+ for (;;) {
992
+ const current = choices.enforcement === 'deny' ? '1' : '2';
993
+ const answer = await ask(`enforcement (1/2) [${current}] (or back)> `);
994
+ if (answer === 'back')
995
+ return 'back';
996
+ const pick = answer === '' ? current : answer;
997
+ if (pick === '1' || pick === '2') {
998
+ choices.enforcement = pick === '1' ? 'deny' : 'rewrite';
999
+ return 'ok';
1000
+ }
1001
+ io.output(`1 for deny, 2 for rewrite, or back.\n`);
1002
+ }
1003
+ }
1004
+ async function stepThreshold(io, ask, choices) {
1005
+ io.output(`\nSize threshold — reads at or under this many bytes always pass. The ${String(DEFAULT_THRESHOLD_BYTES)}-byte default comes from the measured validation in ` +
1006
+ `docs/research/2026-09-02-agent-enforcement.md § 5.\n`);
1007
+ for (;;) {
1008
+ const answer = await ask(`threshold in bytes [${String(choices.thresholdBytes)}] (or back)> `);
1009
+ if (answer === 'back')
1010
+ return 'back';
1011
+ if (answer === '')
1012
+ return 'ok';
1013
+ if (/^\d+$/.test(answer) && Number(answer) > 0) {
1014
+ choices.thresholdBytes = Number(answer);
1015
+ return 'ok';
1016
+ }
1017
+ io.output(`A whole number of bytes greater than zero, e.g. 8192.\n`);
1018
+ }
1019
+ }
1020
+ /** The JSON hook files a re-run reads installed toggles back from, per harness. */
1021
+ const TOGGLE_READBACK_FILES = [
1022
+ '.claude/settings.json',
1023
+ '.codex/hooks.json',
1024
+ '.gemini/settings.json',
1025
+ '.grok/hooks.json',
1026
+ '.cursor/hooks.json',
1027
+ ];
1028
+ /** Guard-only files whose presence means the guard toggle was installed. */
1029
+ const GUARD_ONLY_FILES = [
1030
+ '.hermes/hooks.yaml',
1031
+ '.opencode/plugin/smelt-guard.js',
1032
+ '.clinerules/hooks/PreToolUse',
1033
+ ];
1034
+ /** The managed events that wire the PreToolUse guard, across harness spellings. */
1035
+ const GUARD_EVENTS = ['PreToolUse', 'BeforeTool', 'preToolUse'];
1036
+ /**
1037
+ * A re-run reads the toggles back off what is actually installed — every JSON hook
1038
+ * file this installer writes, plus the guard-only shim files — so it edits instead of
1039
+ * resetting. Harnesses that only wire the guard (gemini, grok, cursor, hermes,
1040
+ * opencode, cline) persist no stats/map entries, so after a re-run scoped to them
1041
+ * those toggles read back as off; the defaults below apply only when nothing of
1042
+ * smelt's is installed at all.
1043
+ */
1044
+ function presetToggles(cwd) {
1045
+ const defaults = { guard: true, statsOnStop: true, mapOnStart: false };
1046
+ let anyOurs = false;
1047
+ let guard = false;
1048
+ let statsOnStop = false;
1049
+ let mapOnStart = false;
1050
+ for (const name of TOGGLE_READBACK_FILES) {
1051
+ const text = readIfExists(join(cwd, name));
1052
+ if (text === undefined)
1053
+ continue;
1054
+ let hooks;
1055
+ try {
1056
+ const parsed = JSON.parse(text);
1057
+ const hooksValue = typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
1058
+ ? parsed['hooks']
1059
+ : undefined;
1060
+ hooks =
1061
+ typeof hooksValue === 'object' && hooksValue !== null && !Array.isArray(hooksValue)
1062
+ ? hooksValue
1063
+ : undefined;
1064
+ }
1065
+ catch {
1066
+ hooks = undefined;
1067
+ }
1068
+ if (hooks === undefined)
1069
+ continue;
1070
+ const installed = hooks;
1071
+ const hasOurs = (event) => Array.isArray(installed[event]) &&
1072
+ installed[event].some((entry) => isOursEntry(entry));
1073
+ if (!MANAGED_EVENTS.some((event) => hasOurs(event)))
1074
+ continue;
1075
+ anyOurs = true;
1076
+ guard ||= GUARD_EVENTS.some((event) => hasOurs(event));
1077
+ statsOnStop ||= hasOurs('Stop');
1078
+ mapOnStart ||= hasOurs('SessionStart');
1079
+ }
1080
+ for (const name of GUARD_ONLY_FILES) {
1081
+ const text = readIfExists(join(cwd, name));
1082
+ if (text !== undefined && text.includes(OURS_TOKEN)) {
1083
+ anyOurs = true;
1084
+ guard = true;
1085
+ }
1086
+ }
1087
+ return anyOurs ? { guard, statsOnStop, mapOnStart } : defaults;
1088
+ }
1089
+ const fileLabel = (file) => {
1090
+ if (file.unchanged)
1091
+ return 'unchanged — nothing to write';
1092
+ return file.exists ? 'exists — will ask before overwriting' : 'new';
1093
+ };
1094
+ async function confirmAndInstall(io, ask, choices) {
1095
+ const plan = planInstall(io.cwd, choices);
1096
+ io.output(`\nAbout to write, into ${io.cwd}:\n` +
1097
+ plan.files.map((file) => ` ${file.name.padEnd(32)} (${fileLabel(file)})\n`).join('') +
1098
+ plan.skipped.map((skip) => ` ${skip.name.padEnd(32)} (SKIPPED: ${skip.why})\n`).join('') +
1099
+ `Nothing has been written yet.\n`);
1100
+ for (;;) {
1101
+ const answer = await ask(`confirm (yes / no / back)> `);
1102
+ if (answer === 'back')
1103
+ return 'back';
1104
+ if (answer === 'no') {
1105
+ io.output(`Nothing was written.\n`);
1106
+ return 'done';
1107
+ }
1108
+ if (answer === 'yes')
1109
+ break;
1110
+ io.output(`yes to write, no to leave everything untouched, back to change a setting.\n`);
1111
+ }
1112
+ for (const file of plan.files) {
1113
+ if (file.unchanged) {
1114
+ io.output(` ${file.name} — unchanged, not rewritten\n`);
1115
+ continue;
1116
+ }
1117
+ if (file.exists) {
1118
+ // The one hard rule, same as `smelt init`: an existing file is never touched
1119
+ // without an explicit per-file yes — not `y`, not Enter, a literal `yes`.
1120
+ const answer = await ask(` ${file.name} exists — overwrite it? (yes/no)> `);
1121
+ if (answer !== 'yes') {
1122
+ io.output(` skipped ${file.name} — the existing file was not touched\n`);
1123
+ continue;
1124
+ }
1125
+ }
1126
+ mkdirSync(dirname(file.path), { recursive: true });
1127
+ writeFileSync(file.path, file.content);
1128
+ if (file.mode !== undefined)
1129
+ chmodSync(file.path, file.mode);
1130
+ io.output(` wrote ${file.name}\n`);
1131
+ }
1132
+ for (const note of plan.notes)
1133
+ io.output(`note: ${note}\n`);
1134
+ io.output(`Done. Re-run \`${CLI_NAME} hooks install\` to edit toggles; ` +
1135
+ `\`${CLI_NAME} hooks remove\` takes it all back out.\n`);
1136
+ return 'done';
1137
+ }
1138
+ async function removeFlow(io, ask, harnessFlag) {
1139
+ const harnesses = harnessFlag !== undefined ? [resolveHarnessFlag(harnessFlag)] : [...HARNESSES];
1140
+ const removals = planRemove(io.cwd, harnesses);
1141
+ if (removals.length === 0) {
1142
+ io.output(`${CLI_NAME} hooks remove: nothing of smelt's found to remove in ${io.cwd}.\n`);
1143
+ return 0;
1144
+ }
1145
+ io.output(`${CLI_NAME} hooks remove — takes smelt's hook wiring back out.\n\nPlanned:\n` +
1146
+ removals
1147
+ .map((removal) => ` ${removal.name.padEnd(32)} (${removal.action === 'delete' ? 'delete' : 'remove smelt entries, keep the rest'})\n`)
1148
+ .join('') +
1149
+ `${CONFIG_FILE_NAME} is left untouched — its hooks block is your config now; ` +
1150
+ `edit or remove it there.\nNothing has been changed yet.\n`);
1151
+ for (;;) {
1152
+ const answer = await ask(`confirm (yes / no)> `);
1153
+ if (answer === 'no') {
1154
+ io.output(`Nothing was changed.\n`);
1155
+ return 0;
1156
+ }
1157
+ if (answer === 'yes')
1158
+ break;
1159
+ io.output(`yes to proceed, no to leave everything untouched.\n`);
1160
+ }
1161
+ for (const removal of removals) {
1162
+ const verb = removal.action === 'delete' ? 'delete' : 'modify';
1163
+ const answer = await ask(` ${removal.name} — ${verb} it? (yes/no)> `);
1164
+ if (answer !== 'yes') {
1165
+ io.output(` skipped ${removal.name} — not touched\n`);
1166
+ continue;
1167
+ }
1168
+ if (removal.action === 'delete') {
1169
+ unlinkSync(removal.path);
1170
+ io.output(` deleted ${removal.name}\n`);
1171
+ }
1172
+ else {
1173
+ writeFileSync(removal.path, removal.content ?? '');
1174
+ io.output(` cleaned ${removal.name}\n`);
1175
+ }
1176
+ }
1177
+ io.output(`Done.\n`);
1178
+ return 0;
1179
+ }
1180
+ //# sourceMappingURL=hooks.js.map