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