@smeltjs/core 0.3.0 → 0.5.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 (146) hide show
  1. package/dist/agents/guide.d.ts +68 -0
  2. package/dist/agents/guide.d.ts.map +1 -0
  3. package/dist/agents/guide.js +72 -0
  4. package/dist/agents/guide.js.map +1 -0
  5. package/dist/agents/instructions.d.ts +133 -0
  6. package/dist/agents/instructions.d.ts.map +1 -0
  7. package/dist/agents/instructions.js +212 -0
  8. package/dist/agents/instructions.js.map +1 -0
  9. package/dist/agents/lint.d.ts +154 -0
  10. package/dist/agents/lint.d.ts.map +1 -0
  11. package/dist/agents/lint.js +761 -0
  12. package/dist/agents/lint.js.map +1 -0
  13. package/dist/agents/split.d.ts +113 -0
  14. package/dist/agents/split.d.ts.map +1 -0
  15. package/dist/agents/split.js +234 -0
  16. package/dist/agents/split.js.map +1 -0
  17. package/dist/cli/agents.d.ts +39 -0
  18. package/dist/cli/agents.d.ts.map +1 -0
  19. package/dist/cli/agents.js +121 -0
  20. package/dist/cli/agents.js.map +1 -0
  21. package/dist/cli/args.d.ts +1 -0
  22. package/dist/cli/args.d.ts.map +1 -1
  23. package/dist/cli/args.js.map +1 -1
  24. package/dist/cli/bin.d.ts.map +1 -1
  25. package/dist/cli/bin.js +9 -4
  26. package/dist/cli/bin.js.map +1 -1
  27. package/dist/cli/config.d.ts +20 -0
  28. package/dist/cli/config.d.ts.map +1 -1
  29. package/dist/cli/config.js +34 -1
  30. package/dist/cli/config.js.map +1 -1
  31. package/dist/cli/doctor.d.ts +66 -0
  32. package/dist/cli/doctor.d.ts.map +1 -0
  33. package/dist/cli/doctor.js +137 -0
  34. package/dist/cli/doctor.js.map +1 -0
  35. package/dist/cli/hooks.d.ts +61 -18
  36. package/dist/cli/hooks.d.ts.map +1 -1
  37. package/dist/cli/hooks.js +178 -241
  38. package/dist/cli/hooks.js.map +1 -1
  39. package/dist/cli/init.d.ts.map +1 -1
  40. package/dist/cli/init.js +8 -14
  41. package/dist/cli/init.js.map +1 -1
  42. package/dist/cli/installed.d.ts +56 -0
  43. package/dist/cli/installed.d.ts.map +1 -0
  44. package/dist/cli/installed.js +105 -0
  45. package/dist/cli/installed.js.map +1 -0
  46. package/dist/cli/lava.d.ts +31 -0
  47. package/dist/cli/lava.d.ts.map +1 -0
  48. package/dist/cli/lava.js +82 -0
  49. package/dist/cli/lava.js.map +1 -0
  50. package/dist/cli/report.d.ts +23 -0
  51. package/dist/cli/report.d.ts.map +1 -1
  52. package/dist/cli/report.js +140 -2
  53. package/dist/cli/report.js.map +1 -1
  54. package/dist/cli/run.d.ts +5 -3
  55. package/dist/cli/run.d.ts.map +1 -1
  56. package/dist/cli/run.js +2 -1
  57. package/dist/cli/run.js.map +1 -1
  58. package/dist/cli/setup.d.ts +87 -0
  59. package/dist/cli/setup.d.ts.map +1 -0
  60. package/dist/cli/setup.js +513 -0
  61. package/dist/cli/setup.js.map +1 -0
  62. package/dist/cli/shell.d.ts +11 -4
  63. package/dist/cli/shell.d.ts.map +1 -1
  64. package/dist/cli/shell.js.map +1 -1
  65. package/dist/cli/subcommands/agents.d.ts +72 -0
  66. package/dist/cli/subcommands/agents.d.ts.map +1 -0
  67. package/dist/cli/subcommands/agents.js +163 -0
  68. package/dist/cli/subcommands/agents.js.map +1 -0
  69. package/dist/cli/subcommands/doctor.d.ts +15 -0
  70. package/dist/cli/subcommands/doctor.d.ts.map +1 -0
  71. package/dist/cli/subcommands/doctor.js +43 -0
  72. package/dist/cli/subcommands/doctor.js.map +1 -0
  73. package/dist/cli/subcommands/flags.d.ts +16 -0
  74. package/dist/cli/subcommands/flags.d.ts.map +1 -1
  75. package/dist/cli/subcommands/flags.js +40 -2
  76. package/dist/cli/subcommands/flags.js.map +1 -1
  77. package/dist/cli/subcommands/hooks.d.ts.map +1 -1
  78. package/dist/cli/subcommands/hooks.js +29 -5
  79. package/dist/cli/subcommands/hooks.js.map +1 -1
  80. package/dist/cli/subcommands/init.d.ts.map +1 -1
  81. package/dist/cli/subcommands/init.js +2 -1
  82. package/dist/cli/subcommands/init.js.map +1 -1
  83. package/dist/cli/subcommands/registry.d.ts +4 -1
  84. package/dist/cli/subcommands/registry.d.ts.map +1 -1
  85. package/dist/cli/subcommands/registry.js +9 -0
  86. package/dist/cli/subcommands/registry.js.map +1 -1
  87. package/dist/cli/subcommands/setup.d.ts +23 -0
  88. package/dist/cli/subcommands/setup.d.ts.map +1 -0
  89. package/dist/cli/subcommands/setup.js +87 -0
  90. package/dist/cli/subcommands/setup.js.map +1 -0
  91. package/dist/cli/subcommands/subcommand.d.ts +1 -1
  92. package/dist/cli/subcommands/subcommand.d.ts.map +1 -1
  93. package/dist/cli/subcommands/subcommand.js.map +1 -1
  94. package/dist/cli/wizard.d.ts +72 -0
  95. package/dist/cli/wizard.d.ts.map +1 -0
  96. package/dist/cli/wizard.js +86 -0
  97. package/dist/cli/wizard.js.map +1 -0
  98. package/dist/harness/claude-code.d.ts.map +1 -1
  99. package/dist/harness/claude-code.js +10 -0
  100. package/dist/harness/claude-code.js.map +1 -1
  101. package/dist/harness/codex.d.ts.map +1 -1
  102. package/dist/harness/codex.js +2 -0
  103. package/dist/harness/codex.js.map +1 -1
  104. package/dist/harness/gemini.d.ts.map +1 -1
  105. package/dist/harness/gemini.js +1 -0
  106. package/dist/harness/gemini.js.map +1 -1
  107. package/dist/harness/grok.d.ts.map +1 -1
  108. package/dist/harness/grok.js +5 -1
  109. package/dist/harness/grok.js.map +1 -1
  110. package/dist/harness/hermes.d.ts.map +1 -1
  111. package/dist/harness/hermes.js +1 -0
  112. package/dist/harness/hermes.js.map +1 -1
  113. package/dist/harness/kilocode.d.ts.map +1 -1
  114. package/dist/harness/kilocode.js +1 -1
  115. package/dist/harness/kilocode.js.map +1 -1
  116. package/dist/harness/opencode.d.ts.map +1 -1
  117. package/dist/harness/opencode.js +10 -0
  118. package/dist/harness/opencode.js.map +1 -1
  119. package/dist/harness/profile.d.ts +49 -3
  120. package/dist/harness/profile.d.ts.map +1 -1
  121. package/dist/harness/profile.js +15 -0
  122. package/dist/harness/profile.js.map +1 -1
  123. package/dist/harness/registry.d.ts +55 -6
  124. package/dist/harness/registry.d.ts.map +1 -1
  125. package/dist/harness/registry.js +63 -6
  126. package/dist/harness/registry.js.map +1 -1
  127. package/dist/harness/snippet.d.ts +14 -1
  128. package/dist/harness/snippet.d.ts.map +1 -1
  129. package/dist/harness/snippet.js +18 -2
  130. package/dist/harness/snippet.js.map +1 -1
  131. package/dist/index.d.ts +22 -0
  132. package/dist/index.d.ts.map +1 -1
  133. package/dist/index.js +17 -0
  134. package/dist/index.js.map +1 -1
  135. package/dist/setup/recipe.d.ts +73 -0
  136. package/dist/setup/recipe.d.ts.map +1 -0
  137. package/dist/setup/recipe.js +76 -0
  138. package/dist/setup/recipe.js.map +1 -0
  139. package/dist/text/json-edit.d.ts +85 -0
  140. package/dist/text/json-edit.d.ts.map +1 -0
  141. package/dist/text/json-edit.js +290 -0
  142. package/dist/text/json-edit.js.map +1 -0
  143. package/dist/types.d.ts +0 -11
  144. package/dist/types.d.ts.map +1 -1
  145. package/dist/types.js.map +1 -1
  146. package/package.json +1 -1
package/dist/cli/hooks.js CHANGED
@@ -1,13 +1,16 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
- import { dirname, join } from 'node:path';
3
+ import { join } from 'node:path';
4
4
  import { CliUsageError } from '../errors.js';
5
5
  import { nodeCommand, portablePath, shimScriptPath, smeltBinPath } from '../harness/paths.js';
6
6
  import { hasShim, TIER_HONESTY } from '../harness/profile.js';
7
- import { GUARD_EVENTS, GUARD_ONLY_FILES, HARNESSES, harnessById, JSON_HOOK_FILES, LIFECYCLE_EVENTS, MANAGED_EVENTS, } from '../harness/registry.js';
7
+ import { GUARD_EVENTS, GUARD_ONLY_FILES, HARNESSES, harnessById, harnessNames, JSON_HOOK_FILES, LIFECYCLE_EVENTS, lifecycleHarnesses, MANAGED_EVENTS, } from '../harness/registry.js';
8
8
  import { instructionSnippet, OURS_TOKEN, SNIPPET_END_MD, SNIPPET_START_MD, } from '../harness/snippet.js';
9
9
  import { DEFAULT_SUGGESTION_BUDGET_BYTES, DEFAULT_THRESHOLD_BYTES } from '../hooks/guard-core.js';
10
- import { answerReader, CLI_NAME } from './shell.js';
10
+ import { editJsonProperty, editTopLevelProperty, jsonStyle, stripMarkerBlock, upsertMarkerBlock, } from '../text/json-edit.js';
11
+ import { SETUP_RECIPE } from '../setup/recipe.js';
12
+ import { confirmLoop, confirmYesNo, listPlannedFiles, walkSteps, wizardAsk, writePlannedFile, } from './wizard.js';
13
+ import { CLI_NAME } from './shell.js';
11
14
  import { CONFIG_FILE_NAME, CONFIG_VERSION, findConfigFile, parseConfig, renderConfig, } from './config.js';
12
15
  export { instructionSnippet, SNIPPET_END_MD, SNIPPET_START_MD };
13
16
  /** A harness whose config directory exists in the project or the home directory. */
@@ -40,15 +43,26 @@ function shimCommand(profile, cwd) {
40
43
  }
41
44
  /**
42
45
  * One harness's hook entries: the guard under each matcher its schema spells, plus
43
- * the two session-lifecycle hooks for the harnesses whose schema carries them. Every
46
+ * the session-lifecycle hooks for the harnesses whose schema carries them. Every
44
47
  * toggle the wizard offers is a key that is present or absent here — an absent key is
45
48
  * how a re-run turns a toggle *off*, because the merge deletes what it no longer sees.
49
+ *
50
+ * The two `SessionStart` toggles — the opening map and the instruction-file lint —
51
+ * are **concatenated into one array**, not spread as two objects. Spreading would put
52
+ * the same computed key twice in one literal, and the second would silently replace
53
+ * the first: turning the lint on would turn the map off, with no error anywhere. It is
54
+ * the shape of bug this file exists to refuse, one layer up from the config it writes.
46
55
  */
47
56
  function jsonHookEvents(step, ctx, command) {
48
57
  // The trailing shell comment tags the entry as this installer's (see isOursEntry):
49
58
  // a bare `cli/bin.js` substring would also match some other npm CLI's built binary.
50
59
  const stats = `${nodeCommand(ctx.cwd, smeltBinPath(), 'stats')} 2>/dev/null || true # ${OURS_TOKEN}`;
51
- const map = `${nodeCommand(ctx.cwd, smeltBinPath(), `map . --budget ${String(ctx.budgetBytes)} --cache .smelt/tags`)} 2>/dev/null || true # ${OURS_TOKEN}`;
60
+ const map = `${nodeCommand(ctx.cwd, smeltBinPath(), `${MAP_ON_START_ARGS} --budget ${String(ctx.budgetBytes)} --cache .smelt/tags`)} 2>/dev/null || true # ${OURS_TOKEN}`;
61
+ const lint = `${nodeCommand(ctx.cwd, smeltBinPath(), AGENTS_LINT_ARGS)} 2>/dev/null || true # ${OURS_TOKEN}`;
62
+ const sessionStart = [
63
+ ...(ctx.mapOnStart ? [commandEntry(SESSION_START_MATCHER, map)] : []),
64
+ ...(ctx.lintOnStart ? [commandEntry(SESSION_START_MATCHER, lint)] : []),
65
+ ];
52
66
  return {
53
67
  ...(ctx.guard
54
68
  ? {
@@ -58,11 +72,20 @@ function jsonHookEvents(step, ctx, command) {
58
72
  ...(step.lifecycle && ctx.statsOnStop
59
73
  ? { [LIFECYCLE_EVENTS.stats]: [commandEntry(undefined, stats)] }
60
74
  : {}),
61
- ...(step.lifecycle && ctx.mapOnStart
62
- ? { [LIFECYCLE_EVENTS.map]: [commandEntry('startup|resume|clear|compact', map)] }
63
- : {}),
75
+ ...(step.lifecycle && sessionStart.length > 0 ? { [LIFECYCLE_EVENTS.map]: sessionStart } : {}),
64
76
  };
65
77
  }
78
+ /** The matcher both `SessionStart` entries fire under — a session opening, however. */
79
+ const SESSION_START_MATCHER = 'startup|resume|clear|compact';
80
+ /**
81
+ * The two `SessionStart` commands' distinguishing arguments, and **the substrings a
82
+ * re-run recognises each entry by**. Spelled once so the writer and the reader cannot
83
+ * drift: `presetToggles` tells the two entries apart by the command each one runs, and
84
+ * a wizard that wrote `agents lint .` while its reader looked for `agents lint` would
85
+ * read every re-run's lint toggle back as off and quietly delete it.
86
+ */
87
+ const MAP_ON_START_ARGS = 'map .';
88
+ export const AGENTS_LINT_ARGS = 'agents lint .';
66
89
  /**
67
90
  * True for a hook entry this installer wrote. Matched on the shim script paths and the
68
91
  * `smelt:hooks` token the stats/map commands carry — never on a substring as generic
@@ -128,176 +151,23 @@ export function mergeJsonHooks(existingText, events, shape = {}) {
128
151
  const needsVersion = shape.version !== undefined && root['version'] === undefined;
129
152
  if (!hooksChanged && !needsVersion)
130
153
  return existingText;
131
- const newline = existingText.includes('\r\n') ? '\r\n' : '\n';
132
- const indent = /\n([ \t]+)"/.exec(existingText)?.[1] ?? ' ';
154
+ // The style is read once, off the original: a second edit must match the first.
155
+ const style = jsonStyle(existingText);
133
156
  let text = existingText;
134
157
  if (hooksChanged) {
135
- const scan = scanJsonTopLevel(text);
158
+ text = editTopLevelProperty(text, 'hooks', mergedHooks, style);
136
159
  /* v8 ignore next -- unreachable: JSON.parse accepted the same text above */
137
- if (scan === undefined)
160
+ if (text === undefined)
138
161
  return undefined;
139
- const property = scan.properties.find((candidate) => candidate.key === 'hooks');
140
- if (mergedHooks === undefined) {
141
- if (property !== undefined)
142
- text = removeJsonProperty(text, scan, property);
143
- }
144
- else {
145
- const rendered = renderJsonValue(mergedHooks, indent, newline);
146
- text =
147
- property !== undefined
148
- ? `${text.slice(0, property.valueStart)}${rendered}${text.slice(property.valueEnd)}`
149
- : insertJsonProperty(text, scan, 'hooks', rendered, indent, newline);
150
- }
151
162
  }
152
163
  if (needsVersion) {
153
- const scan = scanJsonTopLevel(text);
164
+ text = editTopLevelProperty(text, 'version', shape.version, style);
154
165
  /* v8 ignore next -- unreachable: every splice above keeps the text valid JSON */
155
- if (scan === undefined)
166
+ if (text === undefined)
156
167
  return undefined;
157
- text = insertJsonProperty(text, scan, 'version', JSON.stringify(shape.version), indent, newline);
158
168
  }
159
169
  return text;
160
170
  }
161
- /**
162
- * Locate the top-level properties of a JSON object *in its source text*, so one
163
- * property can be replaced, inserted or removed while every other byte of the file
164
- * rides through verbatim. `undefined` when the text is not an object — callers have
165
- * already `JSON.parse`d it, so that is belt and braces, not a validator.
166
- */
167
- function scanJsonTopLevel(text) {
168
- let i = skipJsonWhitespace(text, 0);
169
- if (text[i] !== '{')
170
- return undefined;
171
- const open = i;
172
- i = skipJsonWhitespace(text, i + 1);
173
- const properties = [];
174
- if (text[i] === '}')
175
- return { open, close: i, properties };
176
- for (;;) {
177
- if (text[i] !== '"')
178
- return undefined;
179
- const keyStart = i;
180
- const keyEnd = skipJsonString(text, i);
181
- if (keyEnd === undefined)
182
- return undefined;
183
- const key = JSON.parse(text.slice(keyStart, keyEnd));
184
- i = skipJsonWhitespace(text, keyEnd);
185
- if (text[i] !== ':')
186
- return undefined;
187
- const valueStart = skipJsonWhitespace(text, i + 1);
188
- const valueEnd = skipJsonValue(text, valueStart);
189
- if (valueEnd === undefined)
190
- return undefined;
191
- properties.push({ key, keyStart, valueStart, valueEnd });
192
- i = skipJsonWhitespace(text, valueEnd);
193
- if (text[i] === ',') {
194
- i = skipJsonWhitespace(text, i + 1);
195
- continue;
196
- }
197
- if (text[i] === '}')
198
- return { open, close: i, properties };
199
- return undefined;
200
- }
201
- }
202
- function skipJsonWhitespace(text, from) {
203
- let i = from;
204
- while (i < text.length && ' \t\r\n'.includes(text[i]))
205
- i += 1;
206
- return i;
207
- }
208
- /** `from` points at `"`; returns the offset one past the closing quote. */
209
- function skipJsonString(text, from) {
210
- let i = from + 1;
211
- while (i < text.length) {
212
- if (text[i] === '\\')
213
- i += 2;
214
- else if (text[i] === '"')
215
- return i + 1;
216
- else
217
- i += 1;
218
- }
219
- return undefined;
220
- }
221
- function skipJsonValue(text, from) {
222
- const first = text[from];
223
- if (first === '"')
224
- return skipJsonString(text, from);
225
- if (first === '{' || first === '[') {
226
- let depth = 0;
227
- let i = from;
228
- while (i < text.length) {
229
- const ch = text[i];
230
- if (ch === '"') {
231
- const end = skipJsonString(text, i);
232
- if (end === undefined)
233
- return undefined;
234
- i = end;
235
- continue;
236
- }
237
- if (ch === '{' || ch === '[')
238
- depth += 1;
239
- else if (ch === '}' || ch === ']') {
240
- depth -= 1;
241
- if (depth === 0)
242
- return i + 1;
243
- }
244
- i += 1;
245
- }
246
- return undefined;
247
- }
248
- // number / true / false / null
249
- let i = from;
250
- while (i < text.length && !',}] \t\r\n'.includes(text[i]))
251
- i += 1;
252
- return i > from ? i : undefined;
253
- }
254
- /** A JSON value indented for embedding at a top-level property position. */
255
- function renderJsonValue(value, indent, newline) {
256
- return JSON.stringify(value, null, indent).split('\n').join(`${newline}${indent}`);
257
- }
258
- function removeJsonProperty(text, scan, property) {
259
- const index = scan.properties.indexOf(property);
260
- const next = scan.properties[index + 1];
261
- if (next !== undefined) {
262
- // Delete through the separating comma and whitespace, up to the next key.
263
- return text.slice(0, property.keyStart) + text.slice(next.keyStart);
264
- }
265
- const previous = scan.properties[index - 1];
266
- // Last (or only) property: delete the preceding comma (if any) with it.
267
- const from = previous !== undefined ? previous.valueEnd : scan.open + 1;
268
- return text.slice(0, from) + text.slice(property.valueEnd);
269
- }
270
- function insertJsonProperty(text, scan, key, renderedValue, indent, newline) {
271
- const entry = `${JSON.stringify(key)}: ${renderedValue}`;
272
- if (scan.properties.length === 0) {
273
- return `${text.slice(0, scan.open + 1)}${newline}${indent}${entry}${newline}${text.slice(scan.close)}`;
274
- }
275
- const last = scan.properties[scan.properties.length - 1];
276
- return `${text.slice(0, last.valueEnd)},${newline}${indent}${entry}${text.slice(last.valueEnd)}`;
277
- }
278
- /** Replace this installer's marker block in `existingText`, or append it. */
279
- export function upsertMarkerBlock(existingText, block, start, end) {
280
- if (existingText === undefined || existingText.trim() === '')
281
- return block;
282
- const startIndex = existingText.indexOf(start);
283
- const endIndex = existingText.indexOf(end);
284
- if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) {
285
- const before = existingText.slice(0, startIndex);
286
- const after = existingText.slice(endIndex + end.length).replace(/^\n/, '');
287
- return `${before}${block}${after}`;
288
- }
289
- return `${existingText.replace(/\n*$/, '\n\n')}${block}`;
290
- }
291
- /** Remove the marker block. `undefined` when nothing (or only whitespace) remains. */
292
- export function stripMarkerBlock(existingText, start, end) {
293
- const startIndex = existingText.indexOf(start);
294
- const endIndex = existingText.indexOf(end);
295
- if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex)
296
- return existingText;
297
- const stripped = existingText.slice(0, startIndex).replace(/\n+$/, '\n') +
298
- existingText.slice(endIndex + end.length).replace(/^\n+/, '');
299
- return stripped.trim() === '' ? undefined : stripped;
300
- }
301
171
  function planFile(cwd, name, content, mode) {
302
172
  const path = join(cwd, name);
303
173
  const exists = existsSync(path);
@@ -341,13 +211,15 @@ export function planInstall(cwd, choices) {
341
211
  });
342
212
  const ctx = {
343
213
  cwd,
214
+ ...(choices.writtenBy === undefined ? {} : { writtenBy: choices.writtenBy }),
344
215
  guard: choices.guard,
345
216
  statsOnStop: choices.statsOnStop,
346
217
  mapOnStart: choices.mapOnStart,
218
+ lintOnStart: choices.lintOnStart,
347
219
  thresholdBytes: choices.thresholdBytes,
348
220
  budgetBytes,
349
221
  };
350
- const snippet = instructionSnippet(choices.thresholdBytes, budgetBytes);
222
+ const snippet = instructionSnippet(choices.thresholdBytes, budgetBytes, choices.writtenBy);
351
223
  const planJsonHooks = (name, events, shape = {}) => {
352
224
  const path = join(cwd, name);
353
225
  // Nothing to install and nothing to strip: don't create an empty hooks file.
@@ -393,6 +265,21 @@ export function planInstall(cwd, choices) {
393
265
  break;
394
266
  files.set(join(cwd, step.file), planFile(cwd, step.file, step.content(ctx), step.mode));
395
267
  break;
268
+ case 'mcp-registration': {
269
+ // Byte-faithful beside whatever servers the user already registered —
270
+ // sibling entries, key order and indentation all ride through.
271
+ const existing = readIfExists(join(cwd, step.file));
272
+ const merged = editJsonProperty(existing ?? '{}', step.path, step.entry(ctx), existing === undefined ? undefined : jsonStyle(existing));
273
+ if (merged === undefined) {
274
+ skipped.push({
275
+ name: step.file,
276
+ why: 'exists but is not a JSON object — fix or remove it, then re-run',
277
+ });
278
+ break;
279
+ }
280
+ files.set(join(cwd, step.file), planFile(cwd, step.file, merged));
281
+ break;
282
+ }
396
283
  }
397
284
  }
398
285
  if (profile.instructions === 'snippet') {
@@ -407,7 +294,7 @@ export function planInstall(cwd, choices) {
407
294
  return { files: [...files.values()], skipped, notes };
408
295
  }
409
296
  /** Where the installed config points the persistent store, relative to the config file. */
410
- export const DEFAULT_STORE_DIR = '.smelt/store';
297
+ export const DEFAULT_STORE_DIR = SETUP_RECIPE.store.defaultDir;
411
298
  /**
412
299
  * Existing config re-rendered with the hooks block, other fields carried verbatim —
413
300
  * except that a config with **no** store block gains a directory store. The deny
@@ -421,14 +308,18 @@ export const DEFAULT_STORE_DIR = '.smelt/store';
421
308
  * That store injection is this verb's **policy**, which is why it lives here; the
422
309
  * bytes are written by `renderConfig` in `config.ts`, the one writer, so a key added
423
310
  * to the schema reaches this file and `init`'s together or not at all.
311
+ *
312
+ * "Carried verbatim" is spelled as a spread rather than as a list of the fields to
313
+ * copy, and that is load-bearing: the list version silently dropped every key nobody
314
+ * remembered to add to it — `agents` was added to the schema and this function kept
315
+ * writing configs without it, which is a setting the user believed was in force,
316
+ * caught by `test/guards/config-writer.test.ts`. Only the two fields this verb
317
+ * actually decides are named.
424
318
  */
425
319
  export function renderConfigWithHooks(existing, hooks) {
426
320
  return renderConfig({
321
+ ...existing,
427
322
  smeltConfig: CONFIG_VERSION,
428
- ...(existing?.defaultBudgetBytes === undefined
429
- ? {}
430
- : { defaultBudgetBytes: existing.defaultBudgetBytes }),
431
- ...(existing?.strategy === undefined ? {} : { strategy: existing.strategy }),
432
323
  store: existing?.store ?? { kind: 'directory', path: DEFAULT_STORE_DIR },
433
324
  hooks,
434
325
  });
@@ -475,6 +366,30 @@ export function planRemove(cwd, harnesses) {
475
366
  return;
476
367
  removals.set(path, { name, path, action: 'delete' });
477
368
  };
369
+ /**
370
+ * The registration comes back out the way it went in: the server entry lifted,
371
+ * byte-faithfully, from its container. A container this install created — empty
372
+ * once the entry is gone — is removed with it, so a file that never carried the
373
+ * key round-trips to byte-identical; one that carries other servers keeps them.
374
+ */
375
+ const planMcpStrip = (name, keys) => {
376
+ const path = join(cwd, name);
377
+ const existing = readIfExists(path);
378
+ if (existing === undefined)
379
+ return;
380
+ const removed = editJsonProperty(existing, keys, undefined);
381
+ if (removed === undefined || removed === existing)
382
+ return;
383
+ let remains;
384
+ try {
385
+ remains = JSON.parse(removed);
386
+ }
387
+ catch {
388
+ return; // unreachable — the editor only returns parseable text; refuse to guess
389
+ }
390
+ const empty = typeof remains === 'object' && remains !== null && Object.keys(remains).length === 0;
391
+ removals.set(path, empty ? { name, path, action: 'delete' } : { name, path, action: 'modify', content: removed });
392
+ };
478
393
  for (const profile of harnesses) {
479
394
  for (const step of profile.install) {
480
395
  switch (step.kind) {
@@ -487,6 +402,9 @@ export function planRemove(cwd, harnesses) {
487
402
  case 'own-file':
488
403
  planWholeFileDelete(step.file);
489
404
  break;
405
+ case 'mcp-registration':
406
+ planMcpStrip(step.file, step.path);
407
+ break;
490
408
  }
491
409
  }
492
410
  if (profile.instructions === 'snippet') {
@@ -500,27 +418,20 @@ export function planRemove(cwd, harnesses) {
500
418
  }
501
419
  /**
502
420
  * `smelt hooks <install|remove>`, start to finish. The same testability pattern as
503
- * `runInit`: a pure function over an input/output pair, exit code returned.
421
+ * `runInit`: a pure function over an input/output pair, exit code returned. The ask
422
+ * adapter, the step machine and the confirms are the wizard kit's (`cli/wizard.ts`) —
423
+ * this file holds what is hooks' own: the steps, the plan, the per-file consent.
504
424
  */
505
425
  export async function runHooks(action, harnessFlag, io) {
506
- // Same adapter, same reason, as `runInit` see the note on `answerReader`.
507
- const lines = answerReader(io.input);
508
- const ask = async (prompt) => {
509
- io.output(prompt);
510
- const next = await lines.next();
511
- if (next === undefined) {
512
- throw new CliUsageError(`${CLI_NAME} hooks: input ended before the wizard finished. ` +
513
- `Files already confirmed and written stay; nothing further was written.`);
514
- }
515
- return next.trim();
516
- };
426
+ const wizard = wizardAsk(io.input, io.output, `${CLI_NAME} hooks: input ended before the wizard finished. ` +
427
+ `Files already confirmed and written stay; nothing further was written.`);
517
428
  try {
518
429
  return action === 'install'
519
- ? await installFlow(io, ask, harnessFlag)
520
- : await removeFlow(io, ask, harnessFlag);
430
+ ? await installFlow(io, wizard.ask, harnessFlag)
431
+ : await removeFlow(io, wizard.ask, harnessFlag);
521
432
  }
522
433
  finally {
523
- await lines.release();
434
+ await wizard.release();
524
435
  }
525
436
  }
526
437
  function resolveHarnessFlag(flag) {
@@ -542,6 +453,7 @@ async function installFlow(io, ask, harnessFlag) {
542
453
  `until you confirm at the end.\n\n`);
543
454
  const choices = {
544
455
  harnesses: harnessFlag !== undefined ? [resolveHarnessFlag(harnessFlag)] : [...detected],
456
+ ...(io.version === undefined ? {} : { writtenBy: io.version }),
545
457
  ...presetToggles(io.cwd),
546
458
  enforcement: 'deny',
547
459
  thresholdBytes: DEFAULT_THRESHOLD_BYTES,
@@ -558,32 +470,30 @@ async function installFlow(io, ask, harnessFlag) {
558
470
  choices.guard = on;
559
471
  }),
560
472
  async (io_, ask_) => stepToggle(io_, ask_, 'stats on Stop', `\`smelt stats\` runs when a session ends — the honest signal (expansion rate) ` +
561
- `surfaced where the turn ends. Observation only; never blocks. Wired for ` +
562
- `verified-tier harnesses (Claude Code, Codex).`, choices.statsOnStop, (on) => {
473
+ `surfaced where the turn ends. Observation only; never blocks. Wired for the ` +
474
+ `harnesses whose hooks carry session events (${harnessNames(lifecycleHarnesses())}).`, choices.statsOnStop, (on) => {
563
475
  choices.statsOnStop = on;
564
476
  }),
565
477
  async (io_, ask_) => stepToggle(io_, ask_, 'repo map on SessionStart', `\`smelt map . --budget …\` runs at session start and its output opens the ` +
566
478
  `context — the agent starts oriented. Costs one map build per session. ` +
567
- `Wired for verified-tier harnesses (Claude Code, Codex).`, choices.mapOnStart, (on) => {
479
+ `Wired for the harnesses whose hooks carry session events ` +
480
+ `(${harnessNames(lifecycleHarnesses())}).`, choices.mapOnStart, (on) => {
568
481
  choices.mapOnStart = on;
569
482
  }),
483
+ async (io_, ask_) => stepToggle(io_, ask_, 'instruction-file lint on SessionStart', `\`smelt agents lint .\` runs at session start and reports on the AGENTS.md, ` +
484
+ `CLAUDE.md and GEMINI.md this session is about to load on every request — ` +
485
+ `bytes per level, and any path or link in them that no longer resolves. ` +
486
+ `Advisory: it never blocks, and it exits 0 unless you set ` +
487
+ `agents.budgetBytes in ${CONFIG_FILE_NAME}. Wired for verified-tier ` +
488
+ `harnesses (Claude Code, Codex).`, choices.lintOnStart, (on) => {
489
+ choices.lintOnStart = on;
490
+ }),
570
491
  async (io_, ask_) => stepEnforcement(io_, ask_, choices),
571
492
  async (io_, ask_) => stepThreshold(io_, ask_, choices),
572
493
  ];
573
- let index = 0;
494
+ const machine = steps.map((step) => (a) => step(io, a));
574
495
  for (;;) {
575
- while (index < steps.length) {
576
- const outcome = await steps[index](io, ask);
577
- if (outcome === 'back') {
578
- if (index === 0)
579
- io.output(`This is the first step — there is nothing before it.\n`);
580
- else
581
- index -= 1;
582
- }
583
- else {
584
- index += 1;
585
- }
586
- }
496
+ await walkSteps(machine, ask, io.output);
587
497
  if (choices.harnesses.length === 0) {
588
498
  io.output(`No harness selected. Nothing to do; nothing was written.\n`);
589
499
  return 0;
@@ -591,7 +501,7 @@ async function installFlow(io, ask, harnessFlag) {
591
501
  const verdict = await confirmAndInstall(io, ask, choices);
592
502
  if (verdict !== 'back')
593
503
  return 0;
594
- index = steps.length - 1;
504
+ await walkSteps(machine, ask, io.output, machine.length - 1);
595
505
  }
596
506
  }
597
507
  function guardCopy() {
@@ -695,13 +605,50 @@ async function stepThreshold(io, ask, choices) {
695
605
  * (gemini, grok, cursor, hermes, opencode, cline) persist no stats/map entries, so
696
606
  * after a re-run scoped to them those toggles read back as off; the defaults below
697
607
  * apply only when nothing of smelt's is installed at all.
608
+ *
609
+ * The two `SessionStart` toggles share one event, so they are told apart by **the
610
+ * command each entry runs**, not by the key it sits under. Reading `SessionStart` as
611
+ * one boolean would make a re-run with the map on and the lint off write both back —
612
+ * or neither — which is a toggle the user believed they had set.
613
+ *
614
+ * Exported for `smelt setup`, which applies the preset's *current* state the same way
615
+ * — read off what is installed — rather than keeping a second copy of the defaults.
698
616
  */
699
- function presetToggles(cwd) {
700
- const defaults = { guard: true, statsOnStop: true, mapOnStart: false };
617
+ /**
618
+ * Whether a JSON hook file's text carries entries of ours — the **one** predicate for
619
+ * this fact, shared by the readers (`smelt doctor`, `smelt setup`'s repair policy) and
620
+ * backed by the same `isOursEntry` the writer's strip-merge uses. The guard command
621
+ * itself carries only the shim path (no token), so a text-level `OURS_TOKEN` search
622
+ * would miss a guard-only install — the exact drift this exists to prevent.
623
+ */
624
+ export function jsonHooksContainOurs(text) {
625
+ let hooks;
626
+ try {
627
+ const parsed = JSON.parse(text);
628
+ const hooksValue = typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
629
+ ? parsed['hooks']
630
+ : undefined;
631
+ hooks =
632
+ typeof hooksValue === 'object' && hooksValue !== null && !Array.isArray(hooksValue)
633
+ ? hooksValue
634
+ : undefined;
635
+ }
636
+ catch {
637
+ hooks = undefined;
638
+ }
639
+ if (hooks === undefined)
640
+ return false;
641
+ return MANAGED_EVENTS.some((event) => Array.isArray(hooks[event])
642
+ ? hooks[event].some((entry) => isOursEntry(entry))
643
+ : false);
644
+ }
645
+ export function presetToggles(cwd) {
646
+ const defaults = { guard: true, statsOnStop: true, mapOnStart: false, lintOnStart: false };
701
647
  let anyOurs = false;
702
648
  let guard = false;
703
649
  let statsOnStop = false;
704
650
  let mapOnStart = false;
651
+ let lintOnStart = false;
705
652
  for (const name of JSON_HOOK_FILES) {
706
653
  const text = readIfExists(join(cwd, name));
707
654
  if (text === undefined)
@@ -723,14 +670,19 @@ function presetToggles(cwd) {
723
670
  if (hooks === undefined)
724
671
  continue;
725
672
  const installed = hooks;
726
- const hasOurs = (event) => Array.isArray(installed[event]) &&
727
- installed[event].some((entry) => isOursEntry(entry));
673
+ const oursUnder = (event) => Array.isArray(installed[event])
674
+ ? installed[event].filter((entry) => isOursEntry(entry))
675
+ : [];
676
+ const hasOurs = (event) => oursUnder(event).length > 0;
677
+ /** One of ours under `event` whose command carries `needle`. */
678
+ const hasOursRunning = (event, needle) => oursUnder(event).some((entry) => (JSON.stringify(entry) ?? '').includes(needle));
728
679
  if (!MANAGED_EVENTS.some((event) => hasOurs(event)))
729
680
  continue;
730
681
  anyOurs = true;
731
682
  guard ||= GUARD_EVENTS.some((event) => hasOurs(event));
732
- statsOnStop ||= hasOurs('Stop');
733
- mapOnStart ||= hasOurs('SessionStart');
683
+ statsOnStop ||= hasOurs(LIFECYCLE_EVENTS.stats);
684
+ mapOnStart ||= hasOursRunning(LIFECYCLE_EVENTS.map, MAP_ON_START_ARGS);
685
+ lintOnStart ||= hasOursRunning(LIFECYCLE_EVENTS.lint, AGENTS_LINT_ARGS);
734
686
  }
735
687
  for (const name of GUARD_ONLY_FILES) {
736
688
  const text = readIfExists(join(cwd, name));
@@ -739,7 +691,7 @@ function presetToggles(cwd) {
739
691
  guard = true;
740
692
  }
741
693
  }
742
- return anyOurs ? { guard, statsOnStop, mapOnStart } : defaults;
694
+ return anyOurs ? { guard, statsOnStop, mapOnStart, lintOnStart } : defaults;
743
695
  }
744
696
  const fileLabel = (file) => {
745
697
  if (file.unchanged)
@@ -748,21 +700,15 @@ const fileLabel = (file) => {
748
700
  };
749
701
  async function confirmAndInstall(io, ask, choices) {
750
702
  const plan = planInstall(io.cwd, choices);
751
- io.output(`\nAbout to write, into ${io.cwd}:\n` +
752
- plan.files.map((file) => ` ${file.name.padEnd(32)} (${fileLabel(file)})\n`).join('') +
753
- plan.skipped.map((skip) => ` ${skip.name.padEnd(32)} (SKIPPED: ${skip.why})\n`).join('') +
754
- `Nothing has been written yet.\n`);
755
- for (;;) {
756
- const answer = await ask(`confirm (yes / no / back)> `);
757
- if (answer === 'back')
758
- return 'back';
759
- if (answer === 'no') {
760
- io.output(`Nothing was written.\n`);
761
- return 'done';
762
- }
763
- if (answer === 'yes')
764
- break;
765
- io.output(`yes to write, no to leave everything untouched, back to change a setting.\n`);
703
+ io.output(`\nAbout to write, into ${io.cwd}:\n`);
704
+ listPlannedFiles(io.output, plan.files, plan.skipped, fileLabel);
705
+ io.output(`Nothing has been written yet.\n`);
706
+ const confirmed = await confirmLoop(ask, 'yes to write, no to leave everything untouched, back to change a setting.');
707
+ if (confirmed === 'back')
708
+ return 'back';
709
+ if (confirmed === 'no') {
710
+ io.output(`Nothing was written.\n`);
711
+ return 'done';
766
712
  }
767
713
  for (const file of plan.files) {
768
714
  if (file.unchanged) {
@@ -778,10 +724,7 @@ async function confirmAndInstall(io, ask, choices) {
778
724
  continue;
779
725
  }
780
726
  }
781
- mkdirSync(dirname(file.path), { recursive: true });
782
- writeFileSync(file.path, file.content);
783
- if (file.mode !== undefined)
784
- chmodSync(file.path, file.mode);
727
+ writePlannedFile(file);
785
728
  io.output(` wrote ${file.name}\n`);
786
729
  }
787
730
  for (const note of plan.notes)
@@ -803,15 +746,9 @@ async function removeFlow(io, ask, harnessFlag) {
803
746
  .join('') +
804
747
  `${CONFIG_FILE_NAME} is left untouched — its hooks block is your config now; ` +
805
748
  `edit or remove it there.\nNothing has been changed yet.\n`);
806
- for (;;) {
807
- const answer = await ask(`confirm (yes / no)> `);
808
- if (answer === 'no') {
809
- io.output(`Nothing was changed.\n`);
810
- return 0;
811
- }
812
- if (answer === 'yes')
813
- break;
814
- io.output(`yes to proceed, no to leave everything untouched.\n`);
749
+ if ((await confirmYesNo(ask, 'yes to proceed, no to leave everything untouched.')) === 'no') {
750
+ io.output(`Nothing was changed.\n`);
751
+ return 0;
815
752
  }
816
753
  for (const removal of removals) {
817
754
  const verb = removal.action === 'delete' ? 'delete' : 'modify';