@smeltjs/core 0.2.0 → 0.3.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 (319) hide show
  1. package/README.md +3 -3
  2. package/dist/apply.d.ts.map +1 -1
  3. package/dist/apply.js.map +1 -1
  4. package/dist/cache/prefix.d.ts.map +1 -1
  5. package/dist/cache/prefix.js.map +1 -1
  6. package/dist/cli/args.d.ts +16 -70
  7. package/dist/cli/args.d.ts.map +1 -1
  8. package/dist/cli/args.js +18 -338
  9. package/dist/cli/args.js.map +1 -1
  10. package/dist/cli/bin.d.ts.map +1 -1
  11. package/dist/cli/bin.js.map +1 -1
  12. package/dist/cli/config.d.ts +40 -0
  13. package/dist/cli/config.d.ts.map +1 -1
  14. package/dist/cli/config.js +55 -1
  15. package/dist/cli/config.js.map +1 -1
  16. package/dist/cli/hooks.d.ts +33 -38
  17. package/dist/cli/hooks.d.ts.map +1 -1
  18. package/dist/cli/hooks.js +140 -485
  19. package/dist/cli/hooks.js.map +1 -1
  20. package/dist/cli/init.d.ts +30 -15
  21. package/dist/cli/init.d.ts.map +1 -1
  22. package/dist/cli/init.js +167 -38
  23. package/dist/cli/init.js.map +1 -1
  24. package/dist/cli/report.d.ts.map +1 -1
  25. package/dist/cli/report.js +2 -2
  26. package/dist/cli/report.js.map +1 -1
  27. package/dist/cli/run.d.ts +25 -88
  28. package/dist/cli/run.d.ts.map +1 -1
  29. package/dist/cli/run.js +32 -341
  30. package/dist/cli/run.js.map +1 -1
  31. package/dist/cli/shell.d.ts +106 -0
  32. package/dist/cli/shell.d.ts.map +1 -0
  33. package/dist/cli/shell.js +100 -0
  34. package/dist/cli/shell.js.map +1 -0
  35. package/dist/cli/subcommands/flags.d.ts +141 -0
  36. package/dist/cli/subcommands/flags.d.ts.map +1 -0
  37. package/dist/cli/subcommands/flags.js +208 -0
  38. package/dist/cli/subcommands/flags.js.map +1 -0
  39. package/dist/cli/subcommands/hooks.d.ts +18 -0
  40. package/dist/cli/subcommands/hooks.d.ts.map +1 -0
  41. package/dist/cli/subcommands/hooks.js +64 -0
  42. package/dist/cli/subcommands/hooks.js.map +1 -0
  43. package/dist/cli/subcommands/init.d.ts +19 -0
  44. package/dist/cli/subcommands/init.d.ts.map +1 -0
  45. package/dist/cli/subcommands/init.js +44 -0
  46. package/dist/cli/subcommands/init.js.map +1 -0
  47. package/dist/cli/subcommands/map.d.ts +75 -0
  48. package/dist/cli/subcommands/map.d.ts.map +1 -0
  49. package/dist/cli/subcommands/map.js +132 -0
  50. package/dist/cli/subcommands/map.js.map +1 -0
  51. package/dist/cli/subcommands/registry.d.ts +80 -0
  52. package/dist/cli/subcommands/registry.d.ts.map +1 -0
  53. package/dist/cli/subcommands/registry.js +138 -0
  54. package/dist/cli/subcommands/registry.js.map +1 -0
  55. package/dist/cli/subcommands/retrieve.d.ts +74 -0
  56. package/dist/cli/subcommands/retrieve.d.ts.map +1 -0
  57. package/dist/cli/subcommands/retrieve.js +84 -0
  58. package/dist/cli/subcommands/retrieve.js.map +1 -0
  59. package/dist/cli/subcommands/smelt.d.ts +107 -0
  60. package/dist/cli/subcommands/smelt.d.ts.map +1 -0
  61. package/dist/cli/subcommands/smelt.js +304 -0
  62. package/dist/cli/subcommands/smelt.js.map +1 -0
  63. package/dist/cli/subcommands/stats.d.ts +43 -0
  64. package/dist/cli/subcommands/stats.d.ts.map +1 -0
  65. package/dist/cli/subcommands/stats.js +62 -0
  66. package/dist/cli/subcommands/stats.js.map +1 -0
  67. package/dist/cli/subcommands/subcommand.d.ts +113 -0
  68. package/dist/cli/subcommands/subcommand.d.ts.map +1 -0
  69. package/dist/cli/subcommands/subcommand.js +3 -0
  70. package/dist/cli/subcommands/subcommand.js.map +1 -0
  71. package/dist/cli/usage.d.ts +3 -0
  72. package/dist/cli/usage.d.ts.map +1 -0
  73. package/dist/cli/usage.js +95 -0
  74. package/dist/cli/usage.js.map +1 -0
  75. package/dist/detect.d.ts.map +1 -1
  76. package/dist/detect.js.map +1 -1
  77. package/dist/errors.d.ts +21 -1
  78. package/dist/errors.d.ts.map +1 -1
  79. package/dist/errors.js +33 -1
  80. package/dist/errors.js.map +1 -1
  81. package/dist/harness/aider.d.ts +10 -0
  82. package/dist/harness/aider.d.ts.map +1 -0
  83. package/dist/harness/aider.js +21 -0
  84. package/dist/harness/aider.js.map +1 -0
  85. package/dist/harness/claude-code.d.ts +3 -0
  86. package/dist/harness/claude-code.d.ts.map +1 -0
  87. package/dist/harness/claude-code.js +68 -0
  88. package/dist/harness/claude-code.js.map +1 -0
  89. package/dist/harness/cline.d.ts +3 -0
  90. package/dist/harness/cline.d.ts.map +1 -0
  91. package/dist/harness/cline.js +54 -0
  92. package/dist/harness/cline.js.map +1 -0
  93. package/dist/harness/codex.d.ts +3 -0
  94. package/dist/harness/codex.d.ts.map +1 -0
  95. package/dist/harness/codex.js +91 -0
  96. package/dist/harness/codex.js.map +1 -0
  97. package/dist/harness/cursor.d.ts +3 -0
  98. package/dist/harness/cursor.d.ts.map +1 -0
  99. package/dist/harness/cursor.js +54 -0
  100. package/dist/harness/cursor.js.map +1 -0
  101. package/dist/harness/gemini.d.ts +3 -0
  102. package/dist/harness/gemini.d.ts.map +1 -0
  103. package/dist/harness/gemini.js +59 -0
  104. package/dist/harness/gemini.js.map +1 -0
  105. package/dist/harness/grok.d.ts +3 -0
  106. package/dist/harness/grok.d.ts.map +1 -0
  107. package/dist/harness/grok.js +46 -0
  108. package/dist/harness/grok.js.map +1 -0
  109. package/dist/harness/hermes.d.ts +3 -0
  110. package/dist/harness/hermes.d.ts.map +1 -0
  111. package/dist/harness/hermes.js +71 -0
  112. package/dist/harness/hermes.js.map +1 -0
  113. package/dist/harness/kilocode.d.ts +3 -0
  114. package/dist/harness/kilocode.d.ts.map +1 -0
  115. package/dist/harness/kilocode.js +35 -0
  116. package/dist/harness/kilocode.js.map +1 -0
  117. package/dist/harness/opencode.d.ts +3 -0
  118. package/dist/harness/opencode.d.ts.map +1 -0
  119. package/dist/harness/opencode.js +97 -0
  120. package/dist/harness/opencode.js.map +1 -0
  121. package/dist/harness/paths.d.ts +17 -0
  122. package/dist/harness/paths.d.ts.map +1 -0
  123. package/dist/harness/paths.js +45 -0
  124. package/dist/harness/paths.js.map +1 -0
  125. package/dist/harness/profile.d.ts +184 -0
  126. package/dist/harness/profile.d.ts.map +1 -0
  127. package/dist/harness/profile.js +26 -0
  128. package/dist/harness/profile.js.map +1 -0
  129. package/dist/harness/registry.d.ts +51 -0
  130. package/dist/harness/registry.d.ts.map +1 -0
  131. package/dist/harness/registry.js +87 -0
  132. package/dist/harness/registry.js.map +1 -0
  133. package/dist/harness/snippet.d.ts +19 -0
  134. package/dist/harness/snippet.d.ts.map +1 -0
  135. package/dist/harness/snippet.js +41 -0
  136. package/dist/harness/snippet.js.map +1 -0
  137. package/dist/hash.d.ts.map +1 -1
  138. package/dist/hash.js.map +1 -1
  139. package/dist/hooks/guard-core.d.ts +14 -14
  140. package/dist/hooks/guard-core.d.ts.map +1 -1
  141. package/dist/hooks/guard-core.js +15 -37
  142. package/dist/hooks/guard-core.js.map +1 -1
  143. package/dist/hooks/shim.d.ts +95 -4
  144. package/dist/hooks/shim.d.ts.map +1 -1
  145. package/dist/hooks/shim.js +87 -2
  146. package/dist/hooks/shim.js.map +1 -1
  147. package/dist/hooks/shims/claude-code.d.ts +5 -18
  148. package/dist/hooks/shims/claude-code.d.ts.map +1 -1
  149. package/dist/hooks/shims/claude-code.js +9 -56
  150. package/dist/hooks/shims/claude-code.js.map +1 -1
  151. package/dist/hooks/shims/cline.d.ts +5 -12
  152. package/dist/hooks/shims/cline.d.ts.map +1 -1
  153. package/dist/hooks/shims/cline.js +9 -34
  154. package/dist/hooks/shims/cline.js.map +1 -1
  155. package/dist/hooks/shims/codex.d.ts +5 -18
  156. package/dist/hooks/shims/codex.d.ts.map +1 -1
  157. package/dist/hooks/shims/codex.js +9 -51
  158. package/dist/hooks/shims/codex.js.map +1 -1
  159. package/dist/hooks/shims/cursor.d.ts +5 -14
  160. package/dist/hooks/shims/cursor.d.ts.map +1 -1
  161. package/dist/hooks/shims/cursor.js +9 -42
  162. package/dist/hooks/shims/cursor.js.map +1 -1
  163. package/dist/hooks/shims/gemini.d.ts +5 -18
  164. package/dist/hooks/shims/gemini.d.ts.map +1 -1
  165. package/dist/hooks/shims/gemini.js +9 -48
  166. package/dist/hooks/shims/gemini.js.map +1 -1
  167. package/dist/hooks/shims/grok.d.ts +5 -13
  168. package/dist/hooks/shims/grok.d.ts.map +1 -1
  169. package/dist/hooks/shims/grok.js +9 -32
  170. package/dist/hooks/shims/grok.js.map +1 -1
  171. package/dist/hooks/shims/hermes.d.ts +5 -17
  172. package/dist/hooks/shims/hermes.d.ts.map +1 -1
  173. package/dist/hooks/shims/hermes.js +9 -45
  174. package/dist/hooks/shims/hermes.js.map +1 -1
  175. package/dist/index.d.ts +33 -76
  176. package/dist/index.d.ts.map +1 -1
  177. package/dist/index.js +13 -67
  178. package/dist/index.js.map +1 -1
  179. package/dist/lang/bash.d.ts.map +1 -1
  180. package/dist/lang/bash.js.map +1 -1
  181. package/dist/lang/c.d.ts.map +1 -1
  182. package/dist/lang/c.js.map +1 -1
  183. package/dist/lang/c_sharp.d.ts.map +1 -1
  184. package/dist/lang/c_sharp.js.map +1 -1
  185. package/dist/lang/cpp.d.ts.map +1 -1
  186. package/dist/lang/cpp.js.map +1 -1
  187. package/dist/lang/go.d.ts.map +1 -1
  188. package/dist/lang/go.js.map +1 -1
  189. package/dist/lang/java.d.ts.map +1 -1
  190. package/dist/lang/java.js.map +1 -1
  191. package/dist/lang/javascript.d.ts.map +1 -1
  192. package/dist/lang/javascript.js.map +1 -1
  193. package/dist/lang/kotlin.d.ts.map +1 -1
  194. package/dist/lang/kotlin.js.map +1 -1
  195. package/dist/lang/php.d.ts.map +1 -1
  196. package/dist/lang/php.js.map +1 -1
  197. package/dist/lang/profile.d.ts.map +1 -1
  198. package/dist/lang/profile.js.map +1 -1
  199. package/dist/lang/python.d.ts.map +1 -1
  200. package/dist/lang/python.js.map +1 -1
  201. package/dist/lang/registry.d.ts.map +1 -1
  202. package/dist/lang/registry.js.map +1 -1
  203. package/dist/lang/ruby.d.ts.map +1 -1
  204. package/dist/lang/ruby.js.map +1 -1
  205. package/dist/lang/rust.d.ts.map +1 -1
  206. package/dist/lang/rust.js.map +1 -1
  207. package/dist/lang/swift.d.ts.map +1 -1
  208. package/dist/lang/swift.js.map +1 -1
  209. package/dist/lang/tsx.d.ts.map +1 -1
  210. package/dist/lang/tsx.js.map +1 -1
  211. package/dist/lang/typescript.d.ts.map +1 -1
  212. package/dist/lang/typescript.js.map +1 -1
  213. package/dist/net/policy.d.ts +18 -1
  214. package/dist/net/policy.d.ts.map +1 -1
  215. package/dist/net/policy.js +6 -4
  216. package/dist/net/policy.js.map +1 -1
  217. package/dist/ops/index.d.ts +39 -0
  218. package/dist/ops/index.d.ts.map +1 -0
  219. package/dist/ops/index.js +37 -0
  220. package/dist/ops/index.js.map +1 -0
  221. package/dist/ops/inputs.d.ts +170 -0
  222. package/dist/ops/inputs.d.ts.map +1 -0
  223. package/dist/ops/inputs.js +142 -0
  224. package/dist/ops/inputs.js.map +1 -0
  225. package/dist/ops/verbs.d.ts +136 -0
  226. package/dist/ops/verbs.d.ts.map +1 -0
  227. package/dist/ops/verbs.js +76 -0
  228. package/dist/ops/verbs.js.map +1 -0
  229. package/dist/plan/auto.d.ts +69 -0
  230. package/dist/plan/auto.d.ts.map +1 -0
  231. package/dist/plan/auto.js +70 -0
  232. package/dist/plan/auto.js.map +1 -0
  233. package/dist/plan/budget.d.ts +21 -0
  234. package/dist/plan/budget.d.ts.map +1 -0
  235. package/dist/plan/budget.js +26 -0
  236. package/dist/plan/budget.js.map +1 -0
  237. package/dist/plan/grammar.d.ts +14 -0
  238. package/dist/plan/grammar.d.ts.map +1 -1
  239. package/dist/plan/grammar.js +47 -6
  240. package/dist/plan/grammar.js.map +1 -1
  241. package/dist/plan/lexical.d.ts.map +1 -1
  242. package/dist/plan/lexical.js +1 -7
  243. package/dist/plan/lexical.js.map +1 -1
  244. package/dist/plan/planners.d.ts +35 -2
  245. package/dist/plan/planners.d.ts.map +1 -1
  246. package/dist/plan/planners.js +35 -2
  247. package/dist/plan/planners.js.map +1 -1
  248. package/dist/plan/structural.d.ts +19 -5
  249. package/dist/plan/structural.d.ts.map +1 -1
  250. package/dist/plan/structural.js +180 -33
  251. package/dist/plan/structural.js.map +1 -1
  252. package/dist/repomap/cache.d.ts +48 -0
  253. package/dist/repomap/cache.d.ts.map +1 -1
  254. package/dist/repomap/cache.js +112 -13
  255. package/dist/repomap/cache.js.map +1 -1
  256. package/dist/repomap/io.d.ts +24 -0
  257. package/dist/repomap/io.d.ts.map +1 -0
  258. package/dist/repomap/io.js +34 -0
  259. package/dist/repomap/io.js.map +1 -0
  260. package/dist/repomap/map.d.ts +69 -6
  261. package/dist/repomap/map.d.ts.map +1 -1
  262. package/dist/repomap/map.js +104 -22
  263. package/dist/repomap/map.js.map +1 -1
  264. package/dist/repomap/rank.d.ts +26 -2
  265. package/dist/repomap/rank.d.ts.map +1 -1
  266. package/dist/repomap/rank.js +20 -0
  267. package/dist/repomap/rank.js.map +1 -1
  268. package/dist/repomap/reader.d.ts +73 -0
  269. package/dist/repomap/reader.d.ts.map +1 -0
  270. package/dist/repomap/reader.js +30 -0
  271. package/dist/repomap/reader.js.map +1 -0
  272. package/dist/repomap/tags.d.ts.map +1 -1
  273. package/dist/repomap/tags.js.map +1 -1
  274. package/dist/retrieve.d.ts +8 -0
  275. package/dist/retrieve.d.ts.map +1 -1
  276. package/dist/retrieve.js +12 -1
  277. package/dist/retrieve.js.map +1 -1
  278. package/dist/smelter.d.ts +79 -0
  279. package/dist/smelter.d.ts.map +1 -0
  280. package/dist/smelter.js +65 -0
  281. package/dist/smelter.js.map +1 -0
  282. package/dist/stages.d.ts.map +1 -1
  283. package/dist/stages.js +2 -2
  284. package/dist/stages.js.map +1 -1
  285. package/dist/stats.d.ts +2 -1
  286. package/dist/stats.d.ts.map +1 -1
  287. package/dist/stats.js +2 -1
  288. package/dist/stats.js.map +1 -1
  289. package/dist/store-dir.d.ts +43 -4
  290. package/dist/store-dir.d.ts.map +1 -1
  291. package/dist/store-dir.js +48 -5
  292. package/dist/store-dir.js.map +1 -1
  293. package/dist/store.d.ts +6 -0
  294. package/dist/store.d.ts.map +1 -1
  295. package/dist/store.js +6 -0
  296. package/dist/store.js.map +1 -1
  297. package/dist/types.d.ts +35 -3
  298. package/dist/types.d.ts.map +1 -1
  299. package/dist/types.js.map +1 -1
  300. package/grammars/tree-sitter-bash.wasm +0 -0
  301. package/grammars/tree-sitter-c.wasm +0 -0
  302. package/grammars/tree-sitter-c_sharp.wasm +0 -0
  303. package/grammars/tree-sitter-cpp.wasm +0 -0
  304. package/grammars/tree-sitter-go.wasm +0 -0
  305. package/grammars/tree-sitter-java.wasm +0 -0
  306. package/grammars/tree-sitter-javascript.wasm +0 -0
  307. package/grammars/tree-sitter-kotlin.wasm +0 -0
  308. package/grammars/tree-sitter-php.wasm +0 -0
  309. package/grammars/tree-sitter-python.wasm +0 -0
  310. package/grammars/tree-sitter-ruby.wasm +0 -0
  311. package/grammars/tree-sitter-rust.wasm +0 -0
  312. package/grammars/tree-sitter-swift.wasm +0 -0
  313. package/grammars/tree-sitter-tsx.wasm +0 -0
  314. package/grammars/tree-sitter-typescript.wasm +0 -0
  315. package/package.json +11 -11
  316. package/dist/cli/resolve.d.ts +0 -103
  317. package/dist/cli/resolve.d.ts.map +0 -1
  318. package/dist/cli/resolve.js +0 -106
  319. package/dist/cli/resolve.js.map +0 -1
package/dist/cli/hooks.js CHANGED
@@ -1,205 +1,23 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
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';
3
+ import { dirname, join } from 'node:path';
6
4
  import { CliUsageError } from '../errors.js';
5
+ import { nodeCommand, portablePath, shimScriptPath, smeltBinPath } from '../harness/paths.js';
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';
8
+ import { instructionSnippet, OURS_TOKEN, SNIPPET_END_MD, SNIPPET_START_MD, } from '../harness/snippet.js';
7
9
  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
- }
10
+ import { answerReader, CLI_NAME } from './shell.js';
11
+ import { CONFIG_FILE_NAME, CONFIG_VERSION, findConfigFile, parseConfig, renderConfig, } from './config.js';
12
+ export { instructionSnippet, SNIPPET_END_MD, SNIPPET_START_MD };
128
13
  /** A harness whose config directory exists in the project or the home directory. */
129
14
  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}`}`;
15
+ return HARNESSES.filter((profile) => profile.detect.some((path) => existsSync(join(cwd, path))) ||
16
+ profile.detectHome.some((path) => existsSync(join(home, path))));
164
17
  }
165
18
  /* ------------------------------------------------------------------------------------
166
19
  * Generated content
167
20
  * ---------------------------------------------------------------------------------- */
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
21
  /** Claude-style hook entry: one command under an optional matcher. */
204
22
  function commandEntry(matcher, command) {
205
23
  return {
@@ -207,9 +25,26 @@ function commandEntry(matcher, command) {
207
25
  hooks: [{ type: 'command', command }],
208
26
  };
209
27
  }
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));
28
+ /**
29
+ * The hook command a harness's entries run: its own shim script, through node.
30
+ *
31
+ * @throws {Error} when a profile declares a JSON hook file but ships no shim — a
32
+ * registry bug, pinned by `test/guards/harness-registry.test.ts`, not a user error.
33
+ */
34
+ function shimCommand(profile, cwd) {
35
+ /* v8 ignore next 5 -- unreachable: pinned by the harness-registry guard */
36
+ if (!hasShim(profile)) {
37
+ throw new Error(`smelt: harness "${profile.id}" wires a hook command but ships no shim script.`);
38
+ }
39
+ return nodeCommand(cwd, shimScriptPath(profile));
40
+ }
41
+ /**
42
+ * 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
44
+ * toggle the wizard offers is a key that is present or absent here — an absent key is
45
+ * how a re-run turns a toggle *off*, because the merge deletes what it no longer sees.
46
+ */
47
+ function jsonHookEvents(step, ctx, command) {
213
48
  // The trailing shell comment tags the entry as this installer's (see isOursEntry):
214
49
  // a bare `cli/bin.js` substring would also match some other npm CLI's built binary.
215
50
  const stats = `${nodeCommand(ctx.cwd, smeltBinPath(), 'stats')} 2>/dev/null || true # ${OURS_TOKEN}`;
@@ -217,37 +52,32 @@ function claudeStyleEvents(ctx, shim, preToolEvent, matchers) {
217
52
  return {
218
53
  ...(ctx.guard
219
54
  ? {
220
- [preToolEvent]: [
221
- commandEntry(matchers.read, shimCommand),
222
- commandEntry(matchers.bash, shimCommand),
223
- ],
55
+ [step.event]: step.matchers.map((matcher) => step.entry === 'bare-command' ? { command } : commandEntry(matcher, command)),
224
56
  }
225
57
  : {}),
226
- ...(ctx.statsOnStop ? { Stop: [commandEntry(undefined, stats)] } : {}),
227
- ...(ctx.mapOnStart
228
- ? { SessionStart: [commandEntry('startup|resume|clear|compact', map)] }
58
+ ...(step.lifecycle && ctx.statsOnStop
59
+ ? { [LIFECYCLE_EVENTS.stats]: [commandEntry(undefined, stats)] }
60
+ : {}),
61
+ ...(step.lifecycle && ctx.mapOnStart
62
+ ? { [LIFECYCLE_EVENTS.map]: [commandEntry('startup|resume|clear|compact', map)] }
229
63
  : {}),
230
64
  };
231
65
  }
232
66
  /**
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.
67
+ * True for a hook entry this installer wrote. Matched on the shim script paths and the
68
+ * `smelt:hooks` token the stats/map commands carry — never on a substring as generic
69
+ * as `cli/bin.js`, which another npm CLI's built binary could share: remove and
70
+ * re-install may only ever touch entries that are provably smelt's.
237
71
  */
238
72
  function isOursEntry(entry) {
239
73
  const text = JSON.stringify(entry) ?? '';
240
- return (text.includes('hooks/shims/') ||
241
- text.includes('hooks/guard-core.js') ||
242
- text.includes(OURS_TOKEN));
74
+ return text.includes('hooks/shims/') || text.includes(OURS_TOKEN);
243
75
  }
244
- /** The events this installer manages; foreign entries under them are always preserved. */
245
- const MANAGED_EVENTS = ['PreToolUse', 'Stop', 'SessionStart', 'BeforeTool', 'preToolUse'];
246
76
  /**
247
77
  * Merge our hook entries into a JSON settings file, preserving everything foreign
248
78
  * **byte-faithfully**: the merged `hooks` value is spliced into the original text, so
249
79
  * unknown top-level keys, string escapes, number spellings, indentation and key order
250
- * outside the `hooks` property ride through verbatim (founder ruling: an installer
80
+ * outside the `hooks` property ride through verbatim (an installer
251
81
  * that reformats somebody's settings file has edited what it was never asked to).
252
82
  * Inside `hooks`, unmanaged events and other people's entries under managed events
253
83
  * are preserved; our previous entries are replaced (that is what makes a re-run edit
@@ -468,114 +298,6 @@ export function stripMarkerBlock(existingText, start, end) {
468
298
  existingText.slice(endIndex + end.length).replace(/^\n+/, '');
469
299
  return stripped.trim() === '' ? undefined : stripped;
470
300
  }
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
301
  function planFile(cwd, name, content, mode) {
580
302
  const path = join(cwd, name);
581
303
  const exists = existsSync(path);
@@ -587,8 +309,9 @@ function readIfExists(path) {
587
309
  }
588
310
  /**
589
311
  * 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.
312
+ * planning, nothing written. A fold over each chosen profile's `install` list and its
313
+ * instruction layer; all per-harness knowledge is in the profiles. Shared instruction
314
+ * files (several harnesses read AGENTS.md) are planned once.
592
315
  *
593
316
  * @throws {CliUsageError} when an existing `smelt.config.json` is malformed — the
594
317
  * same refusal every other subcommand makes; an installer that guessed around a
@@ -621,6 +344,7 @@ export function planInstall(cwd, choices) {
621
344
  guard: choices.guard,
622
345
  statsOnStop: choices.statsOnStop,
623
346
  mapOnStart: choices.mapOnStart,
347
+ thresholdBytes: choices.thresholdBytes,
624
348
  budgetBytes,
625
349
  };
626
350
  const snippet = instructionSnippet(choices.thresholdBytes, budgetBytes);
@@ -639,92 +363,46 @@ export function planInstall(cwd, choices) {
639
363
  }
640
364
  files.set(path, planFile(cwd, name, merged));
641
365
  };
642
- const planSnippetFile = (name) => {
366
+ const planBlockFile = (name, block, start, end, skipWhen) => {
643
367
  const path = join(cwd, name);
644
368
  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)));
369
+ return; // a shared file (AGENTS.md), already planned
370
+ const existing = readIfExists(path);
371
+ // A file that already carries its owner's version of what this block does is
372
+ // theirs to edit, not ours: say so, and touch nothing.
373
+ if (skipWhen !== undefined &&
374
+ existing !== undefined &&
375
+ !existing.includes(start) &&
376
+ existing.includes(skipWhen.contains)) {
377
+ skipped.push({ name, why: skipWhen.why });
378
+ return;
379
+ }
380
+ files.set(path, planFile(cwd, name, upsertMarkerBlock(existing, block, start, end)));
647
381
  };
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;
382
+ for (const profile of choices.harnesses) {
383
+ for (const step of profile.install) {
384
+ switch (step.kind) {
385
+ case 'json-hooks':
386
+ planJsonHooks(step.file, jsonHookEvents(step, ctx, shimCommand(profile, cwd)), step.shape ?? {});
387
+ break;
388
+ case 'marker-block':
389
+ planBlockFile(step.file, step.block(ctx), step.start, step.end, step.skipWhen);
390
+ break;
391
+ case 'own-file':
392
+ if (step.guardOnly && !ctx.guard)
393
+ break;
394
+ files.set(join(cwd, step.file), planFile(cwd, step.file, step.content(ctx), step.mode));
395
+ break;
720
396
  }
721
- case 'aider':
722
- break; // instruction file only, planned below
723
397
  }
724
- if (spec.id !== 'kilocode')
725
- planSnippetFile(spec.instructionFile);
726
- for (const caveat of spec.caveats)
727
- notes.push(`${spec.name}: ${caveat}`);
398
+ if (profile.instructions === 'snippet') {
399
+ planBlockFile(profile.instructionFile, snippet, SNIPPET_START_MD, SNIPPET_END_MD);
400
+ }
401
+ else {
402
+ files.set(join(cwd, profile.instructionFile), planFile(cwd, profile.instructionFile, profile.instructions(ctx)));
403
+ }
404
+ for (const caveat of profile.caveats)
405
+ notes.push(`${profile.name}: ${caveat}`);
728
406
  }
729
407
  return { files: [...files.values()], skipped, notes };
730
408
  }
@@ -739,9 +417,13 @@ export const DEFAULT_STORE_DIR = '.smelt/store';
739
417
  * config cannot run would be the exact silent-failure shape this project refuses.
740
418
  * An *explicit* `{"kind":"memory"}` is respected; the guard then conditions its
741
419
  * retrieve promise on the store kind instead (`retrieveSentence` in guard-core).
420
+ *
421
+ * That store injection is this verb's **policy**, which is why it lives here; the
422
+ * bytes are written by `renderConfig` in `config.ts`, the one writer, so a key added
423
+ * to the schema reaches this file and `init`'s together or not at all.
742
424
  */
743
425
  export function renderConfigWithHooks(existing, hooks) {
744
- const config = {
426
+ return renderConfig({
745
427
  smeltConfig: CONFIG_VERSION,
746
428
  ...(existing?.defaultBudgetBytes === undefined
747
429
  ? {}
@@ -749,10 +431,14 @@ export function renderConfigWithHooks(existing, hooks) {
749
431
  ...(existing?.strategy === undefined ? {} : { strategy: existing.strategy }),
750
432
  store: existing?.store ?? { kind: 'directory', path: DEFAULT_STORE_DIR },
751
433
  hooks,
752
- };
753
- return `${JSON.stringify(config, null, 2)}\n`;
434
+ });
754
435
  }
755
- /** Everything `remove` would delete or strip, computed against the current disk state. */
436
+ /**
437
+ * Everything `remove` would delete or strip, computed against the current disk state.
438
+ * The mirror image of {@link planInstall}, over the same data: each install step's
439
+ * kind is also how it comes back out — a JSON hook file is strip-merged, a marker
440
+ * block is stripped, a file that is entirely ours is deleted.
441
+ */
756
442
  export function planRemove(cwd, harnesses) {
757
443
  const removals = new Map();
758
444
  const planJsonStrip = (name) => {
@@ -789,41 +475,26 @@ export function planRemove(cwd, harnesses) {
789
475
  return;
790
476
  removals.set(path, { name, path, action: 'delete' });
791
477
  };
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;
478
+ for (const profile of harnesses) {
479
+ for (const step of profile.install) {
480
+ switch (step.kind) {
481
+ case 'json-hooks':
482
+ planJsonStrip(step.file);
483
+ break;
484
+ case 'marker-block':
485
+ planBlockStrip(step.file, step.start, step.end);
486
+ break;
487
+ case 'own-file':
488
+ planWholeFileDelete(step.file);
489
+ break;
490
+ }
491
+ }
492
+ if (profile.instructions === 'snippet') {
493
+ planBlockStrip(profile.instructionFile, SNIPPET_START_MD, SNIPPET_END_MD);
494
+ }
495
+ else {
496
+ planWholeFileDelete(profile.instructionFile);
822
497
  }
823
- if (spec.id === 'kilocode')
824
- planWholeFileDelete(spec.instructionFile);
825
- else
826
- planBlockStrip(spec.instructionFile, SNIPPET_START_MD, SNIPPET_END_MD);
827
498
  }
828
499
  return [...removals.values()];
829
500
  }
@@ -832,16 +503,16 @@ export function planRemove(cwd, harnesses) {
832
503
  * `runInit`: a pure function over an input/output pair, exit code returned.
833
504
  */
834
505
  export async function runHooks(action, harnessFlag, io) {
835
- const rl = createInterface({ input: io.input });
836
- const lines = rl[Symbol.asyncIterator]();
506
+ // Same adapter, same reason, as `runInit` — see the note on `answerReader`.
507
+ const lines = answerReader(io.input);
837
508
  const ask = async (prompt) => {
838
509
  io.output(prompt);
839
510
  const next = await lines.next();
840
- if (next.done === true) {
511
+ if (next === undefined) {
841
512
  throw new CliUsageError(`${CLI_NAME} hooks: input ended before the wizard finished. ` +
842
513
  `Files already confirmed and written stay; nothing further was written.`);
843
514
  }
844
- return next.value.trim();
515
+ return next.trim();
845
516
  };
846
517
  try {
847
518
  return action === 'install'
@@ -849,19 +520,19 @@ export async function runHooks(action, harnessFlag, io) {
849
520
  : await removeFlow(io, ask, harnessFlag);
850
521
  }
851
522
  finally {
852
- rl.close();
523
+ await lines.release();
853
524
  }
854
525
  }
855
526
  function resolveHarnessFlag(flag) {
856
- const spec = harnessById(flag);
857
- if (spec === undefined) {
527
+ const profile = harnessById(flag);
528
+ if (profile === undefined) {
858
529
  throw new CliUsageError(`${CLI_NAME} hooks: unknown harness "${flag}". ` +
859
530
  `Known: ${HARNESSES.map((h) => h.id).join(', ')}.`);
860
531
  }
861
- return spec;
532
+ return profile;
862
533
  }
863
- function tierLabel(spec) {
864
- return `${spec.id.padEnd(12)} ${spec.name.padEnd(14)} [${spec.tier}] — ${TIER_HONESTY[spec.tier]}`;
534
+ function tierLabel(profile) {
535
+ return `${profile.id.padEnd(12)} ${profile.name.padEnd(14)} [${profile.tier}] — ${TIER_HONESTY[profile.tier]}`;
865
536
  }
866
537
  async function installFlow(io, ask, harnessFlag) {
867
538
  const home = io.home ?? homedir();
@@ -878,8 +549,8 @@ async function installFlow(io, ask, harnessFlag) {
878
549
  // With --harness the selection step is skipped, so the tier label — and its one
879
550
  // line of honesty about what the tier means — is printed here instead.
880
551
  if (harnessFlag !== undefined) {
881
- for (const spec of choices.harnesses)
882
- io.output(` ${tierLabel(spec)}\n`);
552
+ for (const profile of choices.harnesses)
553
+ io.output(` ${tierLabel(profile)}\n`);
883
554
  }
884
555
  const steps = [
885
556
  async (io_, ask_) => harnessFlag !== undefined ? 'ok' : stepHarnesses(io_, ask_, choices, detected),
@@ -931,13 +602,13 @@ function guardCopy() {
931
602
  }
932
603
  async function stepHarnesses(io, ask, choices, detected) {
933
604
  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`);
605
+ for (const profile of HARNESSES) {
606
+ const mark = detected.includes(profile) ? '*' : ' ';
607
+ io.output(` ${mark} ${tierLabel(profile)}\n`);
937
608
  }
938
609
  io.output(`(* = detected here)\n`);
939
610
  for (;;) {
940
- const current = choices.harnesses.map((spec) => spec.id).join(',') || '(none)';
611
+ const current = choices.harnesses.map((profile) => profile.id).join(',') || '(none)';
941
612
  const answer = await ask(`install for which? (comma-separated ids, Enter = ${current}, or back)\n> `);
942
613
  if (answer === 'back')
943
614
  return 'back';
@@ -947,20 +618,20 @@ async function stepHarnesses(io, ask, choices, detected) {
947
618
  .split(',')
948
619
  .map((id) => id.trim())
949
620
  .filter((id) => id !== '');
950
- const specs = [];
621
+ const chosen = [];
951
622
  let bad;
952
623
  for (const id of ids) {
953
- const spec = harnessById(id);
954
- if (spec === undefined)
624
+ const profile = harnessById(id);
625
+ if (profile === undefined)
955
626
  bad = id;
956
- else if (!specs.includes(spec))
957
- specs.push(spec);
627
+ else if (!chosen.includes(profile))
628
+ chosen.push(profile);
958
629
  }
959
630
  if (bad !== undefined) {
960
631
  io.output(`Unknown harness "${bad}". Known: ${HARNESSES.map((h) => h.id).join(', ')}.\n`);
961
632
  continue;
962
633
  }
963
- choices.harnesses = specs;
634
+ choices.harnesses = chosen;
964
635
  return 'ok';
965
636
  }
966
637
  }
@@ -1017,29 +688,13 @@ async function stepThreshold(io, ask, choices) {
1017
688
  io.output(`A whole number of bytes greater than zero, e.g. 8192.\n`);
1018
689
  }
1019
690
  }
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
691
  /**
1037
692
  * 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.
693
+ * file this installer writes, plus the guard-only shim files, both derived from the
694
+ * registry — so it edits instead of resetting. Harnesses that only wire the guard
695
+ * (gemini, grok, cursor, hermes, opencode, cline) persist no stats/map entries, so
696
+ * after a re-run scoped to them those toggles read back as off; the defaults below
697
+ * apply only when nothing of smelt's is installed at all.
1043
698
  */
1044
699
  function presetToggles(cwd) {
1045
700
  const defaults = { guard: true, statsOnStop: true, mapOnStart: false };
@@ -1047,7 +702,7 @@ function presetToggles(cwd) {
1047
702
  let guard = false;
1048
703
  let statsOnStop = false;
1049
704
  let mapOnStart = false;
1050
- for (const name of TOGGLE_READBACK_FILES) {
705
+ for (const name of JSON_HOOK_FILES) {
1051
706
  const text = readIfExists(join(cwd, name));
1052
707
  if (text === undefined)
1053
708
  continue;