@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.
- package/README.md +3 -3
- package/dist/apply.d.ts.map +1 -1
- package/dist/apply.js.map +1 -1
- package/dist/cache/prefix.d.ts.map +1 -1
- package/dist/cache/prefix.js.map +1 -1
- package/dist/cli/args.d.ts +16 -70
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +18 -338
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/bin.d.ts.map +1 -1
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/config.d.ts +40 -0
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +55 -1
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/hooks.d.ts +33 -38
- package/dist/cli/hooks.d.ts.map +1 -1
- package/dist/cli/hooks.js +140 -485
- package/dist/cli/hooks.js.map +1 -1
- package/dist/cli/init.d.ts +30 -15
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +167 -38
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/report.d.ts.map +1 -1
- package/dist/cli/report.js +2 -2
- package/dist/cli/report.js.map +1 -1
- package/dist/cli/run.d.ts +25 -88
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +32 -341
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/shell.d.ts +106 -0
- package/dist/cli/shell.d.ts.map +1 -0
- package/dist/cli/shell.js +100 -0
- package/dist/cli/shell.js.map +1 -0
- package/dist/cli/subcommands/flags.d.ts +141 -0
- package/dist/cli/subcommands/flags.d.ts.map +1 -0
- package/dist/cli/subcommands/flags.js +208 -0
- package/dist/cli/subcommands/flags.js.map +1 -0
- package/dist/cli/subcommands/hooks.d.ts +18 -0
- package/dist/cli/subcommands/hooks.d.ts.map +1 -0
- package/dist/cli/subcommands/hooks.js +64 -0
- package/dist/cli/subcommands/hooks.js.map +1 -0
- package/dist/cli/subcommands/init.d.ts +19 -0
- package/dist/cli/subcommands/init.d.ts.map +1 -0
- package/dist/cli/subcommands/init.js +44 -0
- package/dist/cli/subcommands/init.js.map +1 -0
- package/dist/cli/subcommands/map.d.ts +75 -0
- package/dist/cli/subcommands/map.d.ts.map +1 -0
- package/dist/cli/subcommands/map.js +132 -0
- package/dist/cli/subcommands/map.js.map +1 -0
- package/dist/cli/subcommands/registry.d.ts +80 -0
- package/dist/cli/subcommands/registry.d.ts.map +1 -0
- package/dist/cli/subcommands/registry.js +138 -0
- package/dist/cli/subcommands/registry.js.map +1 -0
- package/dist/cli/subcommands/retrieve.d.ts +74 -0
- package/dist/cli/subcommands/retrieve.d.ts.map +1 -0
- package/dist/cli/subcommands/retrieve.js +84 -0
- package/dist/cli/subcommands/retrieve.js.map +1 -0
- package/dist/cli/subcommands/smelt.d.ts +107 -0
- package/dist/cli/subcommands/smelt.d.ts.map +1 -0
- package/dist/cli/subcommands/smelt.js +304 -0
- package/dist/cli/subcommands/smelt.js.map +1 -0
- package/dist/cli/subcommands/stats.d.ts +43 -0
- package/dist/cli/subcommands/stats.d.ts.map +1 -0
- package/dist/cli/subcommands/stats.js +62 -0
- package/dist/cli/subcommands/stats.js.map +1 -0
- package/dist/cli/subcommands/subcommand.d.ts +113 -0
- package/dist/cli/subcommands/subcommand.d.ts.map +1 -0
- package/dist/cli/subcommands/subcommand.js +3 -0
- package/dist/cli/subcommands/subcommand.js.map +1 -0
- package/dist/cli/usage.d.ts +3 -0
- package/dist/cli/usage.d.ts.map +1 -0
- package/dist/cli/usage.js +95 -0
- package/dist/cli/usage.js.map +1 -0
- package/dist/detect.d.ts.map +1 -1
- package/dist/detect.js.map +1 -1
- package/dist/errors.d.ts +21 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +33 -1
- package/dist/errors.js.map +1 -1
- package/dist/harness/aider.d.ts +10 -0
- package/dist/harness/aider.d.ts.map +1 -0
- package/dist/harness/aider.js +21 -0
- package/dist/harness/aider.js.map +1 -0
- package/dist/harness/claude-code.d.ts +3 -0
- package/dist/harness/claude-code.d.ts.map +1 -0
- package/dist/harness/claude-code.js +68 -0
- package/dist/harness/claude-code.js.map +1 -0
- package/dist/harness/cline.d.ts +3 -0
- package/dist/harness/cline.d.ts.map +1 -0
- package/dist/harness/cline.js +54 -0
- package/dist/harness/cline.js.map +1 -0
- package/dist/harness/codex.d.ts +3 -0
- package/dist/harness/codex.d.ts.map +1 -0
- package/dist/harness/codex.js +91 -0
- package/dist/harness/codex.js.map +1 -0
- package/dist/harness/cursor.d.ts +3 -0
- package/dist/harness/cursor.d.ts.map +1 -0
- package/dist/harness/cursor.js +54 -0
- package/dist/harness/cursor.js.map +1 -0
- package/dist/harness/gemini.d.ts +3 -0
- package/dist/harness/gemini.d.ts.map +1 -0
- package/dist/harness/gemini.js +59 -0
- package/dist/harness/gemini.js.map +1 -0
- package/dist/harness/grok.d.ts +3 -0
- package/dist/harness/grok.d.ts.map +1 -0
- package/dist/harness/grok.js +46 -0
- package/dist/harness/grok.js.map +1 -0
- package/dist/harness/hermes.d.ts +3 -0
- package/dist/harness/hermes.d.ts.map +1 -0
- package/dist/harness/hermes.js +71 -0
- package/dist/harness/hermes.js.map +1 -0
- package/dist/harness/kilocode.d.ts +3 -0
- package/dist/harness/kilocode.d.ts.map +1 -0
- package/dist/harness/kilocode.js +35 -0
- package/dist/harness/kilocode.js.map +1 -0
- package/dist/harness/opencode.d.ts +3 -0
- package/dist/harness/opencode.d.ts.map +1 -0
- package/dist/harness/opencode.js +97 -0
- package/dist/harness/opencode.js.map +1 -0
- package/dist/harness/paths.d.ts +17 -0
- package/dist/harness/paths.d.ts.map +1 -0
- package/dist/harness/paths.js +45 -0
- package/dist/harness/paths.js.map +1 -0
- package/dist/harness/profile.d.ts +184 -0
- package/dist/harness/profile.d.ts.map +1 -0
- package/dist/harness/profile.js +26 -0
- package/dist/harness/profile.js.map +1 -0
- package/dist/harness/registry.d.ts +51 -0
- package/dist/harness/registry.d.ts.map +1 -0
- package/dist/harness/registry.js +87 -0
- package/dist/harness/registry.js.map +1 -0
- package/dist/harness/snippet.d.ts +19 -0
- package/dist/harness/snippet.d.ts.map +1 -0
- package/dist/harness/snippet.js +41 -0
- package/dist/harness/snippet.js.map +1 -0
- package/dist/hash.d.ts.map +1 -1
- package/dist/hash.js.map +1 -1
- package/dist/hooks/guard-core.d.ts +14 -14
- package/dist/hooks/guard-core.d.ts.map +1 -1
- package/dist/hooks/guard-core.js +15 -37
- package/dist/hooks/guard-core.js.map +1 -1
- package/dist/hooks/shim.d.ts +95 -4
- package/dist/hooks/shim.d.ts.map +1 -1
- package/dist/hooks/shim.js +87 -2
- package/dist/hooks/shim.js.map +1 -1
- package/dist/hooks/shims/claude-code.d.ts +5 -18
- package/dist/hooks/shims/claude-code.d.ts.map +1 -1
- package/dist/hooks/shims/claude-code.js +9 -56
- package/dist/hooks/shims/claude-code.js.map +1 -1
- package/dist/hooks/shims/cline.d.ts +5 -12
- package/dist/hooks/shims/cline.d.ts.map +1 -1
- package/dist/hooks/shims/cline.js +9 -34
- package/dist/hooks/shims/cline.js.map +1 -1
- package/dist/hooks/shims/codex.d.ts +5 -18
- package/dist/hooks/shims/codex.d.ts.map +1 -1
- package/dist/hooks/shims/codex.js +9 -51
- package/dist/hooks/shims/codex.js.map +1 -1
- package/dist/hooks/shims/cursor.d.ts +5 -14
- package/dist/hooks/shims/cursor.d.ts.map +1 -1
- package/dist/hooks/shims/cursor.js +9 -42
- package/dist/hooks/shims/cursor.js.map +1 -1
- package/dist/hooks/shims/gemini.d.ts +5 -18
- package/dist/hooks/shims/gemini.d.ts.map +1 -1
- package/dist/hooks/shims/gemini.js +9 -48
- package/dist/hooks/shims/gemini.js.map +1 -1
- package/dist/hooks/shims/grok.d.ts +5 -13
- package/dist/hooks/shims/grok.d.ts.map +1 -1
- package/dist/hooks/shims/grok.js +9 -32
- package/dist/hooks/shims/grok.js.map +1 -1
- package/dist/hooks/shims/hermes.d.ts +5 -17
- package/dist/hooks/shims/hermes.d.ts.map +1 -1
- package/dist/hooks/shims/hermes.js +9 -45
- package/dist/hooks/shims/hermes.js.map +1 -1
- package/dist/index.d.ts +33 -76
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -67
- package/dist/index.js.map +1 -1
- package/dist/lang/bash.d.ts.map +1 -1
- package/dist/lang/bash.js.map +1 -1
- package/dist/lang/c.d.ts.map +1 -1
- package/dist/lang/c.js.map +1 -1
- package/dist/lang/c_sharp.d.ts.map +1 -1
- package/dist/lang/c_sharp.js.map +1 -1
- package/dist/lang/cpp.d.ts.map +1 -1
- package/dist/lang/cpp.js.map +1 -1
- package/dist/lang/go.d.ts.map +1 -1
- package/dist/lang/go.js.map +1 -1
- package/dist/lang/java.d.ts.map +1 -1
- package/dist/lang/java.js.map +1 -1
- package/dist/lang/javascript.d.ts.map +1 -1
- package/dist/lang/javascript.js.map +1 -1
- package/dist/lang/kotlin.d.ts.map +1 -1
- package/dist/lang/kotlin.js.map +1 -1
- package/dist/lang/php.d.ts.map +1 -1
- package/dist/lang/php.js.map +1 -1
- package/dist/lang/profile.d.ts.map +1 -1
- package/dist/lang/profile.js.map +1 -1
- package/dist/lang/python.d.ts.map +1 -1
- package/dist/lang/python.js.map +1 -1
- package/dist/lang/registry.d.ts.map +1 -1
- package/dist/lang/registry.js.map +1 -1
- package/dist/lang/ruby.d.ts.map +1 -1
- package/dist/lang/ruby.js.map +1 -1
- package/dist/lang/rust.d.ts.map +1 -1
- package/dist/lang/rust.js.map +1 -1
- package/dist/lang/swift.d.ts.map +1 -1
- package/dist/lang/swift.js.map +1 -1
- package/dist/lang/tsx.d.ts.map +1 -1
- package/dist/lang/tsx.js.map +1 -1
- package/dist/lang/typescript.d.ts.map +1 -1
- package/dist/lang/typescript.js.map +1 -1
- package/dist/net/policy.d.ts +18 -1
- package/dist/net/policy.d.ts.map +1 -1
- package/dist/net/policy.js +6 -4
- package/dist/net/policy.js.map +1 -1
- package/dist/ops/index.d.ts +39 -0
- package/dist/ops/index.d.ts.map +1 -0
- package/dist/ops/index.js +37 -0
- package/dist/ops/index.js.map +1 -0
- package/dist/ops/inputs.d.ts +170 -0
- package/dist/ops/inputs.d.ts.map +1 -0
- package/dist/ops/inputs.js +142 -0
- package/dist/ops/inputs.js.map +1 -0
- package/dist/ops/verbs.d.ts +136 -0
- package/dist/ops/verbs.d.ts.map +1 -0
- package/dist/ops/verbs.js +76 -0
- package/dist/ops/verbs.js.map +1 -0
- package/dist/plan/auto.d.ts +69 -0
- package/dist/plan/auto.d.ts.map +1 -0
- package/dist/plan/auto.js +70 -0
- package/dist/plan/auto.js.map +1 -0
- package/dist/plan/budget.d.ts +21 -0
- package/dist/plan/budget.d.ts.map +1 -0
- package/dist/plan/budget.js +26 -0
- package/dist/plan/budget.js.map +1 -0
- package/dist/plan/grammar.d.ts +14 -0
- package/dist/plan/grammar.d.ts.map +1 -1
- package/dist/plan/grammar.js +47 -6
- package/dist/plan/grammar.js.map +1 -1
- package/dist/plan/lexical.d.ts.map +1 -1
- package/dist/plan/lexical.js +1 -7
- package/dist/plan/lexical.js.map +1 -1
- package/dist/plan/planners.d.ts +35 -2
- package/dist/plan/planners.d.ts.map +1 -1
- package/dist/plan/planners.js +35 -2
- package/dist/plan/planners.js.map +1 -1
- package/dist/plan/structural.d.ts +19 -5
- package/dist/plan/structural.d.ts.map +1 -1
- package/dist/plan/structural.js +180 -33
- package/dist/plan/structural.js.map +1 -1
- package/dist/repomap/cache.d.ts +48 -0
- package/dist/repomap/cache.d.ts.map +1 -1
- package/dist/repomap/cache.js +112 -13
- package/dist/repomap/cache.js.map +1 -1
- package/dist/repomap/io.d.ts +24 -0
- package/dist/repomap/io.d.ts.map +1 -0
- package/dist/repomap/io.js +34 -0
- package/dist/repomap/io.js.map +1 -0
- package/dist/repomap/map.d.ts +69 -6
- package/dist/repomap/map.d.ts.map +1 -1
- package/dist/repomap/map.js +104 -22
- package/dist/repomap/map.js.map +1 -1
- package/dist/repomap/rank.d.ts +26 -2
- package/dist/repomap/rank.d.ts.map +1 -1
- package/dist/repomap/rank.js +20 -0
- package/dist/repomap/rank.js.map +1 -1
- package/dist/repomap/reader.d.ts +73 -0
- package/dist/repomap/reader.d.ts.map +1 -0
- package/dist/repomap/reader.js +30 -0
- package/dist/repomap/reader.js.map +1 -0
- package/dist/repomap/tags.d.ts.map +1 -1
- package/dist/repomap/tags.js.map +1 -1
- package/dist/retrieve.d.ts +8 -0
- package/dist/retrieve.d.ts.map +1 -1
- package/dist/retrieve.js +12 -1
- package/dist/retrieve.js.map +1 -1
- package/dist/smelter.d.ts +79 -0
- package/dist/smelter.d.ts.map +1 -0
- package/dist/smelter.js +65 -0
- package/dist/smelter.js.map +1 -0
- package/dist/stages.d.ts.map +1 -1
- package/dist/stages.js +2 -2
- package/dist/stages.js.map +1 -1
- package/dist/stats.d.ts +2 -1
- package/dist/stats.d.ts.map +1 -1
- package/dist/stats.js +2 -1
- package/dist/stats.js.map +1 -1
- package/dist/store-dir.d.ts +43 -4
- package/dist/store-dir.d.ts.map +1 -1
- package/dist/store-dir.js +48 -5
- package/dist/store-dir.js.map +1 -1
- package/dist/store.d.ts +6 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +6 -0
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +35 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/grammars/tree-sitter-bash.wasm +0 -0
- package/grammars/tree-sitter-c.wasm +0 -0
- package/grammars/tree-sitter-c_sharp.wasm +0 -0
- package/grammars/tree-sitter-cpp.wasm +0 -0
- package/grammars/tree-sitter-go.wasm +0 -0
- package/grammars/tree-sitter-java.wasm +0 -0
- package/grammars/tree-sitter-javascript.wasm +0 -0
- package/grammars/tree-sitter-kotlin.wasm +0 -0
- package/grammars/tree-sitter-php.wasm +0 -0
- package/grammars/tree-sitter-python.wasm +0 -0
- package/grammars/tree-sitter-ruby.wasm +0 -0
- package/grammars/tree-sitter-rust.wasm +0 -0
- package/grammars/tree-sitter-swift.wasm +0 -0
- package/grammars/tree-sitter-tsx.wasm +0 -0
- package/grammars/tree-sitter-typescript.wasm +0 -0
- package/package.json +11 -11
- package/dist/cli/resolve.d.ts +0 -103
- package/dist/cli/resolve.d.ts.map +0 -1
- package/dist/cli/resolve.js +0 -106
- 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,
|
|
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 './
|
|
9
|
-
import { CONFIG_FILE_NAME, CONFIG_VERSION, findConfigFile, parseConfig } from './config.js';
|
|
10
|
-
|
|
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((
|
|
131
|
-
|
|
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
|
-
/**
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
[
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
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
|
|
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 (
|
|
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.
|
|
591
|
-
*
|
|
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
|
|
366
|
+
const planBlockFile = (name, block, start, end, skipWhen) => {
|
|
643
367
|
const path = join(cwd, name);
|
|
644
368
|
if (files.has(path))
|
|
645
|
-
return; // shared
|
|
646
|
-
|
|
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
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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 (
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
-
|
|
836
|
-
const lines =
|
|
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
|
|
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.
|
|
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
|
-
|
|
523
|
+
await lines.release();
|
|
853
524
|
}
|
|
854
525
|
}
|
|
855
526
|
function resolveHarnessFlag(flag) {
|
|
856
|
-
const
|
|
857
|
-
if (
|
|
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
|
|
532
|
+
return profile;
|
|
862
533
|
}
|
|
863
|
-
function tierLabel(
|
|
864
|
-
return `${
|
|
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
|
|
882
|
-
io.output(` ${tierLabel(
|
|
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
|
|
935
|
-
const mark = detected.includes(
|
|
936
|
-
io.output(` ${mark} ${tierLabel(
|
|
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((
|
|
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
|
|
621
|
+
const chosen = [];
|
|
951
622
|
let bad;
|
|
952
623
|
for (const id of ids) {
|
|
953
|
-
const
|
|
954
|
-
if (
|
|
624
|
+
const profile = harnessById(id);
|
|
625
|
+
if (profile === undefined)
|
|
955
626
|
bad = id;
|
|
956
|
-
else if (!
|
|
957
|
-
|
|
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 =
|
|
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
|
|
1039
|
-
* resetting. Harnesses that only wire the guard
|
|
1040
|
-
* opencode, cline) persist no stats/map entries, so
|
|
1041
|
-
* those toggles read back as off; the defaults below
|
|
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
|
|
705
|
+
for (const name of JSON_HOOK_FILES) {
|
|
1051
706
|
const text = readIfExists(join(cwd, name));
|
|
1052
707
|
if (text === undefined)
|
|
1053
708
|
continue;
|