@smeltjs/core 0.1.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 +4 -4
- 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 -59
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +18 -277
- 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 +62 -0
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +87 -2
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/hooks.d.ts +148 -0
- package/dist/cli/hooks.d.ts.map +1 -0
- package/dist/cli/hooks.js +835 -0
- package/dist/cli/hooks.js.map +1 -0
- package/dist/cli/init.d.ts +30 -13
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +173 -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 -322
- 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 +164 -0
- package/dist/hooks/guard-core.d.ts.map +1 -0
- package/dist/hooks/guard-core.js +491 -0
- package/dist/hooks/guard-core.js.map +1 -0
- package/dist/hooks/shim.d.ts +176 -0
- package/dist/hooks/shim.d.ts.map +1 -0
- package/dist/hooks/shim.js +192 -0
- package/dist/hooks/shim.js.map +1 -0
- package/dist/hooks/shims/claude-code.d.ts +10 -0
- package/dist/hooks/shims/claude-code.d.ts.map +1 -0
- package/dist/hooks/shims/claude-code.js +14 -0
- package/dist/hooks/shims/claude-code.js.map +1 -0
- package/dist/hooks/shims/cline.d.ts +10 -0
- package/dist/hooks/shims/cline.d.ts.map +1 -0
- package/dist/hooks/shims/cline.js +14 -0
- package/dist/hooks/shims/cline.js.map +1 -0
- package/dist/hooks/shims/codex.d.ts +10 -0
- package/dist/hooks/shims/codex.d.ts.map +1 -0
- package/dist/hooks/shims/codex.js +14 -0
- package/dist/hooks/shims/codex.js.map +1 -0
- package/dist/hooks/shims/cursor.d.ts +10 -0
- package/dist/hooks/shims/cursor.d.ts.map +1 -0
- package/dist/hooks/shims/cursor.js +14 -0
- package/dist/hooks/shims/cursor.js.map +1 -0
- package/dist/hooks/shims/gemini.d.ts +10 -0
- package/dist/hooks/shims/gemini.d.ts.map +1 -0
- package/dist/hooks/shims/gemini.js +14 -0
- package/dist/hooks/shims/gemini.js.map +1 -0
- package/dist/hooks/shims/grok.d.ts +10 -0
- package/dist/hooks/shims/grok.d.ts.map +1 -0
- package/dist/hooks/shims/grok.js +14 -0
- package/dist/hooks/shims/grok.js.map +1 -0
- package/dist/hooks/shims/hermes.d.ts +10 -0
- package/dist/hooks/shims/hermes.d.ts.map +1 -0
- package/dist/hooks/shims/hermes.js +14 -0
- package/dist/hooks/shims/hermes.js.map +1 -0
- 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 +7 -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 +22 -14
- 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
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
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';
|
|
9
|
+
import { DEFAULT_SUGGESTION_BUDGET_BYTES, DEFAULT_THRESHOLD_BYTES } from '../hooks/guard-core.js';
|
|
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 };
|
|
13
|
+
/** A harness whose config directory exists in the project or the home directory. */
|
|
14
|
+
export function detectedHarnesses(cwd, home) {
|
|
15
|
+
return HARNESSES.filter((profile) => profile.detect.some((path) => existsSync(join(cwd, path))) ||
|
|
16
|
+
profile.detectHome.some((path) => existsSync(join(home, path))));
|
|
17
|
+
}
|
|
18
|
+
/* ------------------------------------------------------------------------------------
|
|
19
|
+
* Generated content
|
|
20
|
+
* ---------------------------------------------------------------------------------- */
|
|
21
|
+
/** Claude-style hook entry: one command under an optional matcher. */
|
|
22
|
+
function commandEntry(matcher, command) {
|
|
23
|
+
return {
|
|
24
|
+
...(matcher === undefined ? {} : { matcher }),
|
|
25
|
+
hooks: [{ type: 'command', command }],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
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) {
|
|
48
|
+
// The trailing shell comment tags the entry as this installer's (see isOursEntry):
|
|
49
|
+
// a bare `cli/bin.js` substring would also match some other npm CLI's built binary.
|
|
50
|
+
const stats = `${nodeCommand(ctx.cwd, smeltBinPath(), 'stats')} 2>/dev/null || true # ${OURS_TOKEN}`;
|
|
51
|
+
const map = `${nodeCommand(ctx.cwd, smeltBinPath(), `map . --budget ${String(ctx.budgetBytes)} --cache .smelt/tags`)} 2>/dev/null || true # ${OURS_TOKEN}`;
|
|
52
|
+
return {
|
|
53
|
+
...(ctx.guard
|
|
54
|
+
? {
|
|
55
|
+
[step.event]: step.matchers.map((matcher) => step.entry === 'bare-command' ? { command } : commandEntry(matcher, command)),
|
|
56
|
+
}
|
|
57
|
+
: {}),
|
|
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)] }
|
|
63
|
+
: {}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
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.
|
|
71
|
+
*/
|
|
72
|
+
function isOursEntry(entry) {
|
|
73
|
+
const text = JSON.stringify(entry) ?? '';
|
|
74
|
+
return text.includes('hooks/shims/') || text.includes(OURS_TOKEN);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Merge our hook entries into a JSON settings file, preserving everything foreign
|
|
78
|
+
* **byte-faithfully**: the merged `hooks` value is spliced into the original text, so
|
|
79
|
+
* unknown top-level keys, string escapes, number spellings, indentation and key order
|
|
80
|
+
* outside the `hooks` property ride through verbatim (an installer
|
|
81
|
+
* that reformats somebody's settings file has edited what it was never asked to).
|
|
82
|
+
* Inside `hooks`, unmanaged events and other people's entries under managed events
|
|
83
|
+
* are preserved; our previous entries are replaced (that is what makes a re-run edit
|
|
84
|
+
* toggles), and events left with no entries disappear. A semantic no-op returns the
|
|
85
|
+
* input text unchanged. Returns `undefined` when the existing file is not a JSON
|
|
86
|
+
* object — the caller skips the file rather than clobbering something it cannot
|
|
87
|
+
* understand.
|
|
88
|
+
*/
|
|
89
|
+
export function mergeJsonHooks(existingText, events, shape = {}) {
|
|
90
|
+
let root = {};
|
|
91
|
+
if (existingText !== undefined) {
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(existingText);
|
|
94
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
95
|
+
return undefined;
|
|
96
|
+
root = parsed;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const hooksValue = root['hooks'];
|
|
103
|
+
const existingHooks = typeof hooksValue === 'object' && hooksValue !== null && !Array.isArray(hooksValue)
|
|
104
|
+
? hooksValue
|
|
105
|
+
: undefined;
|
|
106
|
+
const hooks = { ...existingHooks };
|
|
107
|
+
for (const event of MANAGED_EVENTS) {
|
|
108
|
+
const existing = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
109
|
+
const foreign = existing.filter((entry) => !isOursEntry(entry));
|
|
110
|
+
const ours = events[event] ?? [];
|
|
111
|
+
const merged = [...foreign, ...ours];
|
|
112
|
+
if (merged.length > 0)
|
|
113
|
+
hooks[event] = merged;
|
|
114
|
+
else
|
|
115
|
+
delete hooks[event];
|
|
116
|
+
}
|
|
117
|
+
const mergedHooks = Object.keys(hooks).length > 0 ? hooks : undefined;
|
|
118
|
+
// A brand-new file: nothing to preserve, render fresh two-space JSON.
|
|
119
|
+
if (existingText === undefined) {
|
|
120
|
+
const fresh = {};
|
|
121
|
+
if (mergedHooks !== undefined)
|
|
122
|
+
fresh['hooks'] = mergedHooks;
|
|
123
|
+
if (shape.version !== undefined)
|
|
124
|
+
fresh['version'] = shape.version;
|
|
125
|
+
return `${JSON.stringify(fresh, null, 2)}\n`;
|
|
126
|
+
}
|
|
127
|
+
const hooksChanged = JSON.stringify(existingHooks ?? null) !== JSON.stringify(mergedHooks ?? null);
|
|
128
|
+
const needsVersion = shape.version !== undefined && root['version'] === undefined;
|
|
129
|
+
if (!hooksChanged && !needsVersion)
|
|
130
|
+
return existingText;
|
|
131
|
+
const newline = existingText.includes('\r\n') ? '\r\n' : '\n';
|
|
132
|
+
const indent = /\n([ \t]+)"/.exec(existingText)?.[1] ?? ' ';
|
|
133
|
+
let text = existingText;
|
|
134
|
+
if (hooksChanged) {
|
|
135
|
+
const scan = scanJsonTopLevel(text);
|
|
136
|
+
/* v8 ignore next -- unreachable: JSON.parse accepted the same text above */
|
|
137
|
+
if (scan === undefined)
|
|
138
|
+
return undefined;
|
|
139
|
+
const property = scan.properties.find((candidate) => candidate.key === 'hooks');
|
|
140
|
+
if (mergedHooks === undefined) {
|
|
141
|
+
if (property !== undefined)
|
|
142
|
+
text = removeJsonProperty(text, scan, property);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
const rendered = renderJsonValue(mergedHooks, indent, newline);
|
|
146
|
+
text =
|
|
147
|
+
property !== undefined
|
|
148
|
+
? `${text.slice(0, property.valueStart)}${rendered}${text.slice(property.valueEnd)}`
|
|
149
|
+
: insertJsonProperty(text, scan, 'hooks', rendered, indent, newline);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (needsVersion) {
|
|
153
|
+
const scan = scanJsonTopLevel(text);
|
|
154
|
+
/* v8 ignore next -- unreachable: every splice above keeps the text valid JSON */
|
|
155
|
+
if (scan === undefined)
|
|
156
|
+
return undefined;
|
|
157
|
+
text = insertJsonProperty(text, scan, 'version', JSON.stringify(shape.version), indent, newline);
|
|
158
|
+
}
|
|
159
|
+
return text;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Locate the top-level properties of a JSON object *in its source text*, so one
|
|
163
|
+
* property can be replaced, inserted or removed while every other byte of the file
|
|
164
|
+
* rides through verbatim. `undefined` when the text is not an object — callers have
|
|
165
|
+
* already `JSON.parse`d it, so that is belt and braces, not a validator.
|
|
166
|
+
*/
|
|
167
|
+
function scanJsonTopLevel(text) {
|
|
168
|
+
let i = skipJsonWhitespace(text, 0);
|
|
169
|
+
if (text[i] !== '{')
|
|
170
|
+
return undefined;
|
|
171
|
+
const open = i;
|
|
172
|
+
i = skipJsonWhitespace(text, i + 1);
|
|
173
|
+
const properties = [];
|
|
174
|
+
if (text[i] === '}')
|
|
175
|
+
return { open, close: i, properties };
|
|
176
|
+
for (;;) {
|
|
177
|
+
if (text[i] !== '"')
|
|
178
|
+
return undefined;
|
|
179
|
+
const keyStart = i;
|
|
180
|
+
const keyEnd = skipJsonString(text, i);
|
|
181
|
+
if (keyEnd === undefined)
|
|
182
|
+
return undefined;
|
|
183
|
+
const key = JSON.parse(text.slice(keyStart, keyEnd));
|
|
184
|
+
i = skipJsonWhitespace(text, keyEnd);
|
|
185
|
+
if (text[i] !== ':')
|
|
186
|
+
return undefined;
|
|
187
|
+
const valueStart = skipJsonWhitespace(text, i + 1);
|
|
188
|
+
const valueEnd = skipJsonValue(text, valueStart);
|
|
189
|
+
if (valueEnd === undefined)
|
|
190
|
+
return undefined;
|
|
191
|
+
properties.push({ key, keyStart, valueStart, valueEnd });
|
|
192
|
+
i = skipJsonWhitespace(text, valueEnd);
|
|
193
|
+
if (text[i] === ',') {
|
|
194
|
+
i = skipJsonWhitespace(text, i + 1);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (text[i] === '}')
|
|
198
|
+
return { open, close: i, properties };
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function skipJsonWhitespace(text, from) {
|
|
203
|
+
let i = from;
|
|
204
|
+
while (i < text.length && ' \t\r\n'.includes(text[i]))
|
|
205
|
+
i += 1;
|
|
206
|
+
return i;
|
|
207
|
+
}
|
|
208
|
+
/** `from` points at `"`; returns the offset one past the closing quote. */
|
|
209
|
+
function skipJsonString(text, from) {
|
|
210
|
+
let i = from + 1;
|
|
211
|
+
while (i < text.length) {
|
|
212
|
+
if (text[i] === '\\')
|
|
213
|
+
i += 2;
|
|
214
|
+
else if (text[i] === '"')
|
|
215
|
+
return i + 1;
|
|
216
|
+
else
|
|
217
|
+
i += 1;
|
|
218
|
+
}
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
function skipJsonValue(text, from) {
|
|
222
|
+
const first = text[from];
|
|
223
|
+
if (first === '"')
|
|
224
|
+
return skipJsonString(text, from);
|
|
225
|
+
if (first === '{' || first === '[') {
|
|
226
|
+
let depth = 0;
|
|
227
|
+
let i = from;
|
|
228
|
+
while (i < text.length) {
|
|
229
|
+
const ch = text[i];
|
|
230
|
+
if (ch === '"') {
|
|
231
|
+
const end = skipJsonString(text, i);
|
|
232
|
+
if (end === undefined)
|
|
233
|
+
return undefined;
|
|
234
|
+
i = end;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (ch === '{' || ch === '[')
|
|
238
|
+
depth += 1;
|
|
239
|
+
else if (ch === '}' || ch === ']') {
|
|
240
|
+
depth -= 1;
|
|
241
|
+
if (depth === 0)
|
|
242
|
+
return i + 1;
|
|
243
|
+
}
|
|
244
|
+
i += 1;
|
|
245
|
+
}
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
// number / true / false / null
|
|
249
|
+
let i = from;
|
|
250
|
+
while (i < text.length && !',}] \t\r\n'.includes(text[i]))
|
|
251
|
+
i += 1;
|
|
252
|
+
return i > from ? i : undefined;
|
|
253
|
+
}
|
|
254
|
+
/** A JSON value indented for embedding at a top-level property position. */
|
|
255
|
+
function renderJsonValue(value, indent, newline) {
|
|
256
|
+
return JSON.stringify(value, null, indent).split('\n').join(`${newline}${indent}`);
|
|
257
|
+
}
|
|
258
|
+
function removeJsonProperty(text, scan, property) {
|
|
259
|
+
const index = scan.properties.indexOf(property);
|
|
260
|
+
const next = scan.properties[index + 1];
|
|
261
|
+
if (next !== undefined) {
|
|
262
|
+
// Delete through the separating comma and whitespace, up to the next key.
|
|
263
|
+
return text.slice(0, property.keyStart) + text.slice(next.keyStart);
|
|
264
|
+
}
|
|
265
|
+
const previous = scan.properties[index - 1];
|
|
266
|
+
// Last (or only) property: delete the preceding comma (if any) with it.
|
|
267
|
+
const from = previous !== undefined ? previous.valueEnd : scan.open + 1;
|
|
268
|
+
return text.slice(0, from) + text.slice(property.valueEnd);
|
|
269
|
+
}
|
|
270
|
+
function insertJsonProperty(text, scan, key, renderedValue, indent, newline) {
|
|
271
|
+
const entry = `${JSON.stringify(key)}: ${renderedValue}`;
|
|
272
|
+
if (scan.properties.length === 0) {
|
|
273
|
+
return `${text.slice(0, scan.open + 1)}${newline}${indent}${entry}${newline}${text.slice(scan.close)}`;
|
|
274
|
+
}
|
|
275
|
+
const last = scan.properties[scan.properties.length - 1];
|
|
276
|
+
return `${text.slice(0, last.valueEnd)},${newline}${indent}${entry}${text.slice(last.valueEnd)}`;
|
|
277
|
+
}
|
|
278
|
+
/** Replace this installer's marker block in `existingText`, or append it. */
|
|
279
|
+
export function upsertMarkerBlock(existingText, block, start, end) {
|
|
280
|
+
if (existingText === undefined || existingText.trim() === '')
|
|
281
|
+
return block;
|
|
282
|
+
const startIndex = existingText.indexOf(start);
|
|
283
|
+
const endIndex = existingText.indexOf(end);
|
|
284
|
+
if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) {
|
|
285
|
+
const before = existingText.slice(0, startIndex);
|
|
286
|
+
const after = existingText.slice(endIndex + end.length).replace(/^\n/, '');
|
|
287
|
+
return `${before}${block}${after}`;
|
|
288
|
+
}
|
|
289
|
+
return `${existingText.replace(/\n*$/, '\n\n')}${block}`;
|
|
290
|
+
}
|
|
291
|
+
/** Remove the marker block. `undefined` when nothing (or only whitespace) remains. */
|
|
292
|
+
export function stripMarkerBlock(existingText, start, end) {
|
|
293
|
+
const startIndex = existingText.indexOf(start);
|
|
294
|
+
const endIndex = existingText.indexOf(end);
|
|
295
|
+
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex)
|
|
296
|
+
return existingText;
|
|
297
|
+
const stripped = existingText.slice(0, startIndex).replace(/\n+$/, '\n') +
|
|
298
|
+
existingText.slice(endIndex + end.length).replace(/^\n+/, '');
|
|
299
|
+
return stripped.trim() === '' ? undefined : stripped;
|
|
300
|
+
}
|
|
301
|
+
function planFile(cwd, name, content, mode) {
|
|
302
|
+
const path = join(cwd, name);
|
|
303
|
+
const exists = existsSync(path);
|
|
304
|
+
const unchanged = exists && readFileSync(path, 'utf8') === content;
|
|
305
|
+
return { name, path, content, exists, unchanged, ...(mode === undefined ? {} : { mode }) };
|
|
306
|
+
}
|
|
307
|
+
function readIfExists(path) {
|
|
308
|
+
return existsSync(path) ? readFileSync(path, 'utf8') : undefined;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Every file `install` would write, computed against the current disk state — pure
|
|
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.
|
|
315
|
+
*
|
|
316
|
+
* @throws {CliUsageError} when an existing `smelt.config.json` is malformed — the
|
|
317
|
+
* same refusal every other subcommand makes; an installer that guessed around a
|
|
318
|
+
* broken config would write settings the guard then ignores.
|
|
319
|
+
*/
|
|
320
|
+
export function planInstall(cwd, choices) {
|
|
321
|
+
const files = new Map();
|
|
322
|
+
const skipped = [];
|
|
323
|
+
const notes = [];
|
|
324
|
+
// -- smelt.config.json: the guard's runtime settings live here, not in any harness
|
|
325
|
+
// file, so every shim reads one source of truth.
|
|
326
|
+
const configPath = findConfigFile(cwd) ?? join(cwd, CONFIG_FILE_NAME);
|
|
327
|
+
const existingConfig = readIfExists(configPath) === undefined
|
|
328
|
+
? undefined
|
|
329
|
+
: parseConfig(readFileSync(configPath, 'utf8'), configPath);
|
|
330
|
+
const hooksBlock = {
|
|
331
|
+
thresholdBytes: choices.thresholdBytes,
|
|
332
|
+
enforcement: choices.enforcement,
|
|
333
|
+
};
|
|
334
|
+
const budgetBytes = existingConfig?.defaultBudgetBytes ?? DEFAULT_SUGGESTION_BUDGET_BYTES;
|
|
335
|
+
files.set(configPath, {
|
|
336
|
+
name: portablePath(cwd, configPath),
|
|
337
|
+
path: configPath,
|
|
338
|
+
content: renderConfigWithHooks(existingConfig, hooksBlock),
|
|
339
|
+
exists: existsSync(configPath),
|
|
340
|
+
unchanged: readIfExists(configPath) === renderConfigWithHooks(existingConfig, hooksBlock),
|
|
341
|
+
});
|
|
342
|
+
const ctx = {
|
|
343
|
+
cwd,
|
|
344
|
+
guard: choices.guard,
|
|
345
|
+
statsOnStop: choices.statsOnStop,
|
|
346
|
+
mapOnStart: choices.mapOnStart,
|
|
347
|
+
thresholdBytes: choices.thresholdBytes,
|
|
348
|
+
budgetBytes,
|
|
349
|
+
};
|
|
350
|
+
const snippet = instructionSnippet(choices.thresholdBytes, budgetBytes);
|
|
351
|
+
const planJsonHooks = (name, events, shape = {}) => {
|
|
352
|
+
const path = join(cwd, name);
|
|
353
|
+
// Nothing to install and nothing to strip: don't create an empty hooks file.
|
|
354
|
+
if (Object.keys(events).length === 0 && !existsSync(path))
|
|
355
|
+
return;
|
|
356
|
+
const merged = mergeJsonHooks(readIfExists(path), events, shape);
|
|
357
|
+
if (merged === undefined) {
|
|
358
|
+
skipped.push({
|
|
359
|
+
name,
|
|
360
|
+
why: 'exists but is not a JSON object — fix or remove it, then re-run',
|
|
361
|
+
});
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
files.set(path, planFile(cwd, name, merged));
|
|
365
|
+
};
|
|
366
|
+
const planBlockFile = (name, block, start, end, skipWhen) => {
|
|
367
|
+
const path = join(cwd, name);
|
|
368
|
+
if (files.has(path))
|
|
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)));
|
|
381
|
+
};
|
|
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;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
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}`);
|
|
406
|
+
}
|
|
407
|
+
return { files: [...files.values()], skipped, notes };
|
|
408
|
+
}
|
|
409
|
+
/** Where the installed config points the persistent store, relative to the config file. */
|
|
410
|
+
export const DEFAULT_STORE_DIR = '.smelt/store';
|
|
411
|
+
/**
|
|
412
|
+
* Existing config re-rendered with the hooks block, other fields carried verbatim —
|
|
413
|
+
* except that a config with **no** store block gains a directory store. The deny
|
|
414
|
+
* reasons and the instruction snippet teach `smelt retrieve <hash>`, and retrieval
|
|
415
|
+
* across processes needs a persistent store (`smelt retrieve` refuses a memory
|
|
416
|
+
* store, exit 2) — an install whose own guard promises a command the installed
|
|
417
|
+
* config cannot run would be the exact silent-failure shape this project refuses.
|
|
418
|
+
* An *explicit* `{"kind":"memory"}` is respected; the guard then conditions its
|
|
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.
|
|
424
|
+
*/
|
|
425
|
+
export function renderConfigWithHooks(existing, hooks) {
|
|
426
|
+
return renderConfig({
|
|
427
|
+
smeltConfig: CONFIG_VERSION,
|
|
428
|
+
...(existing?.defaultBudgetBytes === undefined
|
|
429
|
+
? {}
|
|
430
|
+
: { defaultBudgetBytes: existing.defaultBudgetBytes }),
|
|
431
|
+
...(existing?.strategy === undefined ? {} : { strategy: existing.strategy }),
|
|
432
|
+
store: existing?.store ?? { kind: 'directory', path: DEFAULT_STORE_DIR },
|
|
433
|
+
hooks,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
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
|
+
*/
|
|
442
|
+
export function planRemove(cwd, harnesses) {
|
|
443
|
+
const removals = new Map();
|
|
444
|
+
const planJsonStrip = (name) => {
|
|
445
|
+
const path = join(cwd, name);
|
|
446
|
+
const existing = readIfExists(path);
|
|
447
|
+
if (existing === undefined)
|
|
448
|
+
return;
|
|
449
|
+
const stripped = mergeJsonHooks(existing, {});
|
|
450
|
+
if (stripped === undefined || stripped === existing)
|
|
451
|
+
return;
|
|
452
|
+
const remains = JSON.parse(stripped);
|
|
453
|
+
const empty = typeof remains === 'object' &&
|
|
454
|
+
remains !== null &&
|
|
455
|
+
Object.keys(remains).filter((key) => key !== 'version').length ===
|
|
456
|
+
0;
|
|
457
|
+
removals.set(path, empty && existing.includes('hooks')
|
|
458
|
+
? { name, path, action: 'delete' }
|
|
459
|
+
: { name, path, action: 'modify', content: stripped });
|
|
460
|
+
};
|
|
461
|
+
const planBlockStrip = (name, start, end) => {
|
|
462
|
+
const path = join(cwd, name);
|
|
463
|
+
const existing = readIfExists(path);
|
|
464
|
+
if (existing === undefined || !existing.includes(start))
|
|
465
|
+
return;
|
|
466
|
+
const stripped = stripMarkerBlock(existing, start, end);
|
|
467
|
+
removals.set(path, stripped === undefined
|
|
468
|
+
? { name, path, action: 'delete' }
|
|
469
|
+
: { name, path, action: 'modify', content: stripped });
|
|
470
|
+
};
|
|
471
|
+
const planWholeFileDelete = (name) => {
|
|
472
|
+
const path = join(cwd, name);
|
|
473
|
+
const existing = readIfExists(path);
|
|
474
|
+
if (existing === undefined || !existing.includes(OURS_TOKEN))
|
|
475
|
+
return;
|
|
476
|
+
removals.set(path, { name, path, action: 'delete' });
|
|
477
|
+
};
|
|
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);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return [...removals.values()];
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* `smelt hooks <install|remove>`, start to finish. The same testability pattern as
|
|
503
|
+
* `runInit`: a pure function over an input/output pair, exit code returned.
|
|
504
|
+
*/
|
|
505
|
+
export async function runHooks(action, harnessFlag, io) {
|
|
506
|
+
// Same adapter, same reason, as `runInit` — see the note on `answerReader`.
|
|
507
|
+
const lines = answerReader(io.input);
|
|
508
|
+
const ask = async (prompt) => {
|
|
509
|
+
io.output(prompt);
|
|
510
|
+
const next = await lines.next();
|
|
511
|
+
if (next === undefined) {
|
|
512
|
+
throw new CliUsageError(`${CLI_NAME} hooks: input ended before the wizard finished. ` +
|
|
513
|
+
`Files already confirmed and written stay; nothing further was written.`);
|
|
514
|
+
}
|
|
515
|
+
return next.trim();
|
|
516
|
+
};
|
|
517
|
+
try {
|
|
518
|
+
return action === 'install'
|
|
519
|
+
? await installFlow(io, ask, harnessFlag)
|
|
520
|
+
: await removeFlow(io, ask, harnessFlag);
|
|
521
|
+
}
|
|
522
|
+
finally {
|
|
523
|
+
await lines.release();
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
function resolveHarnessFlag(flag) {
|
|
527
|
+
const profile = harnessById(flag);
|
|
528
|
+
if (profile === undefined) {
|
|
529
|
+
throw new CliUsageError(`${CLI_NAME} hooks: unknown harness "${flag}". ` +
|
|
530
|
+
`Known: ${HARNESSES.map((h) => h.id).join(', ')}.`);
|
|
531
|
+
}
|
|
532
|
+
return profile;
|
|
533
|
+
}
|
|
534
|
+
function tierLabel(profile) {
|
|
535
|
+
return `${profile.id.padEnd(12)} ${profile.name.padEnd(14)} [${profile.tier}] — ${TIER_HONESTY[profile.tier]}`;
|
|
536
|
+
}
|
|
537
|
+
async function installFlow(io, ask, harnessFlag) {
|
|
538
|
+
const home = io.home ?? homedir();
|
|
539
|
+
const detected = detectedHarnesses(io.cwd, home);
|
|
540
|
+
io.output(`${CLI_NAME} hooks install — wires the smelt guard into agent-harness hooks.\n` +
|
|
541
|
+
`Answer \`back\` at any step to return to the previous one. Nothing is written ` +
|
|
542
|
+
`until you confirm at the end.\n\n`);
|
|
543
|
+
const choices = {
|
|
544
|
+
harnesses: harnessFlag !== undefined ? [resolveHarnessFlag(harnessFlag)] : [...detected],
|
|
545
|
+
...presetToggles(io.cwd),
|
|
546
|
+
enforcement: 'deny',
|
|
547
|
+
thresholdBytes: DEFAULT_THRESHOLD_BYTES,
|
|
548
|
+
};
|
|
549
|
+
// With --harness the selection step is skipped, so the tier label — and its one
|
|
550
|
+
// line of honesty about what the tier means — is printed here instead.
|
|
551
|
+
if (harnessFlag !== undefined) {
|
|
552
|
+
for (const profile of choices.harnesses)
|
|
553
|
+
io.output(` ${tierLabel(profile)}\n`);
|
|
554
|
+
}
|
|
555
|
+
const steps = [
|
|
556
|
+
async (io_, ask_) => harnessFlag !== undefined ? 'ok' : stepHarnesses(io_, ask_, choices, detected),
|
|
557
|
+
async (io_, ask_) => stepToggle(io_, ask_, 'PreToolUse size-guard', guardCopy(), choices.guard, (on) => {
|
|
558
|
+
choices.guard = on;
|
|
559
|
+
}),
|
|
560
|
+
async (io_, ask_) => stepToggle(io_, ask_, 'stats on Stop', `\`smelt stats\` runs when a session ends — the honest signal (expansion rate) ` +
|
|
561
|
+
`surfaced where the turn ends. Observation only; never blocks. Wired for ` +
|
|
562
|
+
`verified-tier harnesses (Claude Code, Codex).`, choices.statsOnStop, (on) => {
|
|
563
|
+
choices.statsOnStop = on;
|
|
564
|
+
}),
|
|
565
|
+
async (io_, ask_) => stepToggle(io_, ask_, 'repo map on SessionStart', `\`smelt map . --budget …\` runs at session start and its output opens the ` +
|
|
566
|
+
`context — the agent starts oriented. Costs one map build per session. ` +
|
|
567
|
+
`Wired for verified-tier harnesses (Claude Code, Codex).`, choices.mapOnStart, (on) => {
|
|
568
|
+
choices.mapOnStart = on;
|
|
569
|
+
}),
|
|
570
|
+
async (io_, ask_) => stepEnforcement(io_, ask_, choices),
|
|
571
|
+
async (io_, ask_) => stepThreshold(io_, ask_, choices),
|
|
572
|
+
];
|
|
573
|
+
let index = 0;
|
|
574
|
+
for (;;) {
|
|
575
|
+
while (index < steps.length) {
|
|
576
|
+
const outcome = await steps[index](io, ask);
|
|
577
|
+
if (outcome === 'back') {
|
|
578
|
+
if (index === 0)
|
|
579
|
+
io.output(`This is the first step — there is nothing before it.\n`);
|
|
580
|
+
else
|
|
581
|
+
index -= 1;
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
index += 1;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if (choices.harnesses.length === 0) {
|
|
588
|
+
io.output(`No harness selected. Nothing to do; nothing was written.\n`);
|
|
589
|
+
return 0;
|
|
590
|
+
}
|
|
591
|
+
const verdict = await confirmAndInstall(io, ask, choices);
|
|
592
|
+
if (verdict !== 'back')
|
|
593
|
+
return 0;
|
|
594
|
+
index = steps.length - 1;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
function guardCopy() {
|
|
598
|
+
return (`Denies raw Reads (and simple \`cat\`s) of files over the size threshold, with a ` +
|
|
599
|
+
`reason naming the exact \`smelt\` replacement — the model still sees everything: ` +
|
|
600
|
+
`smelted first, \`smelt retrieve\` for the rest. Windowed reads (offset/limit) ` +
|
|
601
|
+
`always pass.`);
|
|
602
|
+
}
|
|
603
|
+
async function stepHarnesses(io, ask, choices, detected) {
|
|
604
|
+
io.output(`\nHarnesses — detected by their config directories (project or home):\n`);
|
|
605
|
+
for (const profile of HARNESSES) {
|
|
606
|
+
const mark = detected.includes(profile) ? '*' : ' ';
|
|
607
|
+
io.output(` ${mark} ${tierLabel(profile)}\n`);
|
|
608
|
+
}
|
|
609
|
+
io.output(`(* = detected here)\n`);
|
|
610
|
+
for (;;) {
|
|
611
|
+
const current = choices.harnesses.map((profile) => profile.id).join(',') || '(none)';
|
|
612
|
+
const answer = await ask(`install for which? (comma-separated ids, Enter = ${current}, or back)\n> `);
|
|
613
|
+
if (answer === 'back')
|
|
614
|
+
return 'back';
|
|
615
|
+
if (answer === '')
|
|
616
|
+
return 'ok';
|
|
617
|
+
const ids = answer
|
|
618
|
+
.split(',')
|
|
619
|
+
.map((id) => id.trim())
|
|
620
|
+
.filter((id) => id !== '');
|
|
621
|
+
const chosen = [];
|
|
622
|
+
let bad;
|
|
623
|
+
for (const id of ids) {
|
|
624
|
+
const profile = harnessById(id);
|
|
625
|
+
if (profile === undefined)
|
|
626
|
+
bad = id;
|
|
627
|
+
else if (!chosen.includes(profile))
|
|
628
|
+
chosen.push(profile);
|
|
629
|
+
}
|
|
630
|
+
if (bad !== undefined) {
|
|
631
|
+
io.output(`Unknown harness "${bad}". Known: ${HARNESSES.map((h) => h.id).join(', ')}.\n`);
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
choices.harnesses = chosen;
|
|
635
|
+
return 'ok';
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
async function stepToggle(io, ask, name, copy, current, set) {
|
|
639
|
+
io.output(`\n${name} — ${copy}\n`);
|
|
640
|
+
for (;;) {
|
|
641
|
+
const answer = await ask(`${name}? (on/off) [${current ? 'on' : 'off'}] (or back)> `);
|
|
642
|
+
if (answer === 'back')
|
|
643
|
+
return 'back';
|
|
644
|
+
if (answer === '')
|
|
645
|
+
return 'ok';
|
|
646
|
+
if (answer === 'on' || answer === 'off') {
|
|
647
|
+
set(answer === 'on');
|
|
648
|
+
return 'ok';
|
|
649
|
+
}
|
|
650
|
+
io.output(`on, off, or back.\n`);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
async function stepEnforcement(io, ask, choices) {
|
|
654
|
+
io.output(`\nEnforcement — what happens when the guard catches an oversized raw read:\n` +
|
|
655
|
+
` 1. deny — refuse with a reason naming the exact replacement command. The\n` +
|
|
656
|
+
` transcript stays truthful; the model runs the replacement itself.\n` +
|
|
657
|
+
` 2. rewrite — on harnesses whose hooks can modify tool input, substitute the\n` +
|
|
658
|
+
` replacement in-flight (grep/cat piped through smelt). Never\n` +
|
|
659
|
+
` silent: the substitution is announced in the decision reason\n` +
|
|
660
|
+
` where the harness has one, on stderr where it does not.\n` +
|
|
661
|
+
` Harnesses that cannot rewrite fall back to deny.\n`);
|
|
662
|
+
for (;;) {
|
|
663
|
+
const current = choices.enforcement === 'deny' ? '1' : '2';
|
|
664
|
+
const answer = await ask(`enforcement (1/2) [${current}] (or back)> `);
|
|
665
|
+
if (answer === 'back')
|
|
666
|
+
return 'back';
|
|
667
|
+
const pick = answer === '' ? current : answer;
|
|
668
|
+
if (pick === '1' || pick === '2') {
|
|
669
|
+
choices.enforcement = pick === '1' ? 'deny' : 'rewrite';
|
|
670
|
+
return 'ok';
|
|
671
|
+
}
|
|
672
|
+
io.output(`1 for deny, 2 for rewrite, or back.\n`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
async function stepThreshold(io, ask, choices) {
|
|
676
|
+
io.output(`\nSize threshold — reads at or under this many bytes always pass. The ${String(DEFAULT_THRESHOLD_BYTES)}-byte default comes from the measured validation in ` +
|
|
677
|
+
`docs/research/2026-09-02-agent-enforcement.md § 5.\n`);
|
|
678
|
+
for (;;) {
|
|
679
|
+
const answer = await ask(`threshold in bytes [${String(choices.thresholdBytes)}] (or back)> `);
|
|
680
|
+
if (answer === 'back')
|
|
681
|
+
return 'back';
|
|
682
|
+
if (answer === '')
|
|
683
|
+
return 'ok';
|
|
684
|
+
if (/^\d+$/.test(answer) && Number(answer) > 0) {
|
|
685
|
+
choices.thresholdBytes = Number(answer);
|
|
686
|
+
return 'ok';
|
|
687
|
+
}
|
|
688
|
+
io.output(`A whole number of bytes greater than zero, e.g. 8192.\n`);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* A re-run reads the toggles back off what is actually installed — every JSON hook
|
|
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.
|
|
698
|
+
*/
|
|
699
|
+
function presetToggles(cwd) {
|
|
700
|
+
const defaults = { guard: true, statsOnStop: true, mapOnStart: false };
|
|
701
|
+
let anyOurs = false;
|
|
702
|
+
let guard = false;
|
|
703
|
+
let statsOnStop = false;
|
|
704
|
+
let mapOnStart = false;
|
|
705
|
+
for (const name of JSON_HOOK_FILES) {
|
|
706
|
+
const text = readIfExists(join(cwd, name));
|
|
707
|
+
if (text === undefined)
|
|
708
|
+
continue;
|
|
709
|
+
let hooks;
|
|
710
|
+
try {
|
|
711
|
+
const parsed = JSON.parse(text);
|
|
712
|
+
const hooksValue = typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
713
|
+
? parsed['hooks']
|
|
714
|
+
: undefined;
|
|
715
|
+
hooks =
|
|
716
|
+
typeof hooksValue === 'object' && hooksValue !== null && !Array.isArray(hooksValue)
|
|
717
|
+
? hooksValue
|
|
718
|
+
: undefined;
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
hooks = undefined;
|
|
722
|
+
}
|
|
723
|
+
if (hooks === undefined)
|
|
724
|
+
continue;
|
|
725
|
+
const installed = hooks;
|
|
726
|
+
const hasOurs = (event) => Array.isArray(installed[event]) &&
|
|
727
|
+
installed[event].some((entry) => isOursEntry(entry));
|
|
728
|
+
if (!MANAGED_EVENTS.some((event) => hasOurs(event)))
|
|
729
|
+
continue;
|
|
730
|
+
anyOurs = true;
|
|
731
|
+
guard ||= GUARD_EVENTS.some((event) => hasOurs(event));
|
|
732
|
+
statsOnStop ||= hasOurs('Stop');
|
|
733
|
+
mapOnStart ||= hasOurs('SessionStart');
|
|
734
|
+
}
|
|
735
|
+
for (const name of GUARD_ONLY_FILES) {
|
|
736
|
+
const text = readIfExists(join(cwd, name));
|
|
737
|
+
if (text !== undefined && text.includes(OURS_TOKEN)) {
|
|
738
|
+
anyOurs = true;
|
|
739
|
+
guard = true;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
return anyOurs ? { guard, statsOnStop, mapOnStart } : defaults;
|
|
743
|
+
}
|
|
744
|
+
const fileLabel = (file) => {
|
|
745
|
+
if (file.unchanged)
|
|
746
|
+
return 'unchanged — nothing to write';
|
|
747
|
+
return file.exists ? 'exists — will ask before overwriting' : 'new';
|
|
748
|
+
};
|
|
749
|
+
async function confirmAndInstall(io, ask, choices) {
|
|
750
|
+
const plan = planInstall(io.cwd, choices);
|
|
751
|
+
io.output(`\nAbout to write, into ${io.cwd}:\n` +
|
|
752
|
+
plan.files.map((file) => ` ${file.name.padEnd(32)} (${fileLabel(file)})\n`).join('') +
|
|
753
|
+
plan.skipped.map((skip) => ` ${skip.name.padEnd(32)} (SKIPPED: ${skip.why})\n`).join('') +
|
|
754
|
+
`Nothing has been written yet.\n`);
|
|
755
|
+
for (;;) {
|
|
756
|
+
const answer = await ask(`confirm (yes / no / back)> `);
|
|
757
|
+
if (answer === 'back')
|
|
758
|
+
return 'back';
|
|
759
|
+
if (answer === 'no') {
|
|
760
|
+
io.output(`Nothing was written.\n`);
|
|
761
|
+
return 'done';
|
|
762
|
+
}
|
|
763
|
+
if (answer === 'yes')
|
|
764
|
+
break;
|
|
765
|
+
io.output(`yes to write, no to leave everything untouched, back to change a setting.\n`);
|
|
766
|
+
}
|
|
767
|
+
for (const file of plan.files) {
|
|
768
|
+
if (file.unchanged) {
|
|
769
|
+
io.output(` ${file.name} — unchanged, not rewritten\n`);
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
if (file.exists) {
|
|
773
|
+
// The one hard rule, same as `smelt init`: an existing file is never touched
|
|
774
|
+
// without an explicit per-file yes — not `y`, not Enter, a literal `yes`.
|
|
775
|
+
const answer = await ask(` ${file.name} exists — overwrite it? (yes/no)> `);
|
|
776
|
+
if (answer !== 'yes') {
|
|
777
|
+
io.output(` skipped ${file.name} — the existing file was not touched\n`);
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
mkdirSync(dirname(file.path), { recursive: true });
|
|
782
|
+
writeFileSync(file.path, file.content);
|
|
783
|
+
if (file.mode !== undefined)
|
|
784
|
+
chmodSync(file.path, file.mode);
|
|
785
|
+
io.output(` wrote ${file.name}\n`);
|
|
786
|
+
}
|
|
787
|
+
for (const note of plan.notes)
|
|
788
|
+
io.output(`note: ${note}\n`);
|
|
789
|
+
io.output(`Done. Re-run \`${CLI_NAME} hooks install\` to edit toggles; ` +
|
|
790
|
+
`\`${CLI_NAME} hooks remove\` takes it all back out.\n`);
|
|
791
|
+
return 'done';
|
|
792
|
+
}
|
|
793
|
+
async function removeFlow(io, ask, harnessFlag) {
|
|
794
|
+
const harnesses = harnessFlag !== undefined ? [resolveHarnessFlag(harnessFlag)] : [...HARNESSES];
|
|
795
|
+
const removals = planRemove(io.cwd, harnesses);
|
|
796
|
+
if (removals.length === 0) {
|
|
797
|
+
io.output(`${CLI_NAME} hooks remove: nothing of smelt's found to remove in ${io.cwd}.\n`);
|
|
798
|
+
return 0;
|
|
799
|
+
}
|
|
800
|
+
io.output(`${CLI_NAME} hooks remove — takes smelt's hook wiring back out.\n\nPlanned:\n` +
|
|
801
|
+
removals
|
|
802
|
+
.map((removal) => ` ${removal.name.padEnd(32)} (${removal.action === 'delete' ? 'delete' : 'remove smelt entries, keep the rest'})\n`)
|
|
803
|
+
.join('') +
|
|
804
|
+
`${CONFIG_FILE_NAME} is left untouched — its hooks block is your config now; ` +
|
|
805
|
+
`edit or remove it there.\nNothing has been changed yet.\n`);
|
|
806
|
+
for (;;) {
|
|
807
|
+
const answer = await ask(`confirm (yes / no)> `);
|
|
808
|
+
if (answer === 'no') {
|
|
809
|
+
io.output(`Nothing was changed.\n`);
|
|
810
|
+
return 0;
|
|
811
|
+
}
|
|
812
|
+
if (answer === 'yes')
|
|
813
|
+
break;
|
|
814
|
+
io.output(`yes to proceed, no to leave everything untouched.\n`);
|
|
815
|
+
}
|
|
816
|
+
for (const removal of removals) {
|
|
817
|
+
const verb = removal.action === 'delete' ? 'delete' : 'modify';
|
|
818
|
+
const answer = await ask(` ${removal.name} — ${verb} it? (yes/no)> `);
|
|
819
|
+
if (answer !== 'yes') {
|
|
820
|
+
io.output(` skipped ${removal.name} — not touched\n`);
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
if (removal.action === 'delete') {
|
|
824
|
+
unlinkSync(removal.path);
|
|
825
|
+
io.output(` deleted ${removal.name}\n`);
|
|
826
|
+
}
|
|
827
|
+
else {
|
|
828
|
+
writeFileSync(removal.path, removal.content ?? '');
|
|
829
|
+
io.output(` cleaned ${removal.name}\n`);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
io.output(`Done.\n`);
|
|
833
|
+
return 0;
|
|
834
|
+
}
|
|
835
|
+
//# sourceMappingURL=hooks.js.map
|