@sabaiway/agent-workflow-kit 14.24.0 → 14.25.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/CHANGELOG.md +30 -0
- package/README.md +15 -4
- package/SKILL.md +1 -1
- package/bin/install.mjs +15 -15
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/bootstrap.md +5 -1
- package/references/modes/mcp.md +9 -4
- package/references/modes/recommendations.md +17 -9
- package/references/modes/upgrade.md +3 -2
- package/tools/init-preflight.mjs +92 -0
- package/tools/init-project.mjs +400 -0
- package/tools/mcp.mjs +77 -19
- package/tools/recommendations.mjs +54 -62
- package/tools/write-lanes.mjs +171 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { closeSync, lstatSync, openSync, readFileSync, readdirSync, readlinkSync, statSync } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
5
|
+
import { createInterface } from 'node:readline/promises';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { resolveGitHooksPath } from './commit-guard.mjs';
|
|
8
|
+
import { dirCovers } from './declared-paths.mjs';
|
|
9
|
+
import { isDirectRun } from './direct-run.mjs';
|
|
10
|
+
import { GIT_MAX_BUFFER } from './git-env.mjs';
|
|
11
|
+
import { preflightInit } from './init-preflight.mjs';
|
|
12
|
+
import { buildRecommendations, hostFacts } from './recommendations.mjs';
|
|
13
|
+
import { compareSemver } from './semver-lite.mjs';
|
|
14
|
+
import { EXPECTED_WORKFLOW_VERSION } from './velocity-profile.mjs';
|
|
15
|
+
import { LANE_CONSOLE, VARIANT_LANES, consoleArgv, isProtectedPath, laneOf, routeLine, writesOf } from './write-lanes.mjs';
|
|
16
|
+
|
|
17
|
+
const INSIDE_LINE = 'you are inside an agent; run this in your own terminal';
|
|
18
|
+
const APPLY_PROMPT = 'apply? y/N: ';
|
|
19
|
+
const RUN_NONE = 'y applies every preview above (a sudo install asks once more on its own line); run none of their commands yourself';
|
|
20
|
+
const RESTART_LINE = 'restart the agent from this folder: init changed its configuration';
|
|
21
|
+
const SETTINGS = ['.claude/settings.json', '.claude/settings.local.json', '.mcp.json', '.git/config'];
|
|
22
|
+
const FILE_DIRS = ['.claude/agents', '.claude/hooks'];
|
|
23
|
+
// The record key whose state is the sorted *.json names of its directory, so a created file is a change.
|
|
24
|
+
const JSON_NAMES = '*.json';
|
|
25
|
+
const DANGLING = new Set(['ENOENT', 'ENOTDIR', 'ELOOP']);
|
|
26
|
+
const INSTALL_VARIANT = 'sandbox-provision.installable';
|
|
27
|
+
const DEFAULT_IO = { lstat: lstatSync, stat: statSync, readlink: readlinkSync, readFile: readFileSync, readdir: readdirSync,
|
|
28
|
+
openSync, closeSync, geteuid: process.geteuid };
|
|
29
|
+
const isYes = (answer) => answer === 'y' || answer === 'Y';
|
|
30
|
+
|
|
31
|
+
const readStat = (path, io) => {
|
|
32
|
+
try {
|
|
33
|
+
return io.lstat(path);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const findProject = ({ cwd, lstat = lstatSync }) => {
|
|
41
|
+
const candidates = [];
|
|
42
|
+
const visit = (root) => {
|
|
43
|
+
const io = { lstat };
|
|
44
|
+
if (readStat(join(root, 'docs/ai'), io)?.isDirectory()) {
|
|
45
|
+
const shape = readStat(join(root, 'docs/ai/.workflow-version'), io) ? 'stamped' : 'pre-versioned';
|
|
46
|
+
candidates.push({ root, shape });
|
|
47
|
+
}
|
|
48
|
+
const parent = dirname(root);
|
|
49
|
+
if (!readStat(join(root, '.git'), io) && parent !== root) visit(parent);
|
|
50
|
+
};
|
|
51
|
+
visit(resolve(cwd));
|
|
52
|
+
return { candidates };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const readEntries = (dir, io) => readStat(dir, io)?.isDirectory() ? io.readdir(dir).sort() : [];
|
|
56
|
+
const collectPaths = ({ root, home, hooksDir }, io) => {
|
|
57
|
+
const paths = [join(home, '.claude/settings.json')];
|
|
58
|
+
if (root === null) return paths;
|
|
59
|
+
const docs = join(root, 'docs/ai');
|
|
60
|
+
paths.push(...SETTINGS.map((path) => join(root, path)), join(docs, '.workflow-version'), join(docs, JSON_NAMES));
|
|
61
|
+
for (const dir of [...FILE_DIRS.map((path) => join(root, path)), hooksDir]) {
|
|
62
|
+
paths.push(dir, ...readEntries(dir, io).map((name) => join(dir, name)));
|
|
63
|
+
}
|
|
64
|
+
paths.push(...readEntries(docs, io).filter((name) => name.endsWith('.json')).map((name) => join(docs, name)));
|
|
65
|
+
return paths;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const fileState = (path, io) => `file:${Buffer.from(io.readFile(path)).toString('hex')}`;
|
|
69
|
+
const otherState = (stat) => `other:${stat.mode}:${stat.rdev}`;
|
|
70
|
+
const entryState = (path, io) => {
|
|
71
|
+
const stat = io.lstat(path);
|
|
72
|
+
if (stat.isDirectory()) return 'directory';
|
|
73
|
+
if (stat.isSymbolicLink()) return `link:${io.readlink(path)}`;
|
|
74
|
+
return stat.isFile() ? fileState(path, io) : otherState(stat);
|
|
75
|
+
};
|
|
76
|
+
// A directory reads as its sorted entries with each entry's own state, one level deep, so a changed file in it is a change.
|
|
77
|
+
const statState = (path, stat, io) => {
|
|
78
|
+
if (stat.isDirectory()) return `directory:${JSON.stringify(io.readdir(path).sort().map((name) => [name, entryState(join(path, name), io)]))}`;
|
|
79
|
+
return stat.isFile() ? fileState(path, io) : otherState(stat);
|
|
80
|
+
};
|
|
81
|
+
const followedState = (path, io) => {
|
|
82
|
+
try {
|
|
83
|
+
return statState(path, io.stat(path), io);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (DANGLING.has(error.code)) return 'absent';
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const readState = (path, io) => {
|
|
90
|
+
if (basename(path) === JSON_NAMES) {
|
|
91
|
+
const names = readStat(dirname(path), io)?.isDirectory() ? io.readdir(dirname(path)).filter((name) => name.endsWith('.json')) : null;
|
|
92
|
+
return names === null ? 'absent' : `names:${JSON.stringify(names.sort())}`;
|
|
93
|
+
}
|
|
94
|
+
const stat = readStat(path, io);
|
|
95
|
+
if (stat === null) return 'absent';
|
|
96
|
+
if (stat.isSymbolicLink()) return `link:${io.readlink(path)}:${followedState(path, io)}`;
|
|
97
|
+
return statState(path, stat, io);
|
|
98
|
+
};
|
|
99
|
+
const stateOf = (path, io) => {
|
|
100
|
+
try {
|
|
101
|
+
return readState(path, io);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
throw new Error(`${path} could not be read: ${error.code ?? error.message}`);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const recordPaths = (paths, io) => new Map([...new Set(paths)].map((path) => [path, stateOf(path, io)]));
|
|
107
|
+
const findChanged = (record, io) => [...record].find(([path, state]) => {
|
|
108
|
+
try {
|
|
109
|
+
return readState(path, io) !== state;
|
|
110
|
+
} catch {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
})?.[0];
|
|
114
|
+
// After an apply a path that cannot be read is recorded as such, so the next comparison stops the batch on it.
|
|
115
|
+
const refreshState = (path, io) => {
|
|
116
|
+
try {
|
|
117
|
+
return readState(path, io);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return `unreadable:${error.code ?? error.message}`;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const DIRECTORY = 'directory:';
|
|
123
|
+
const entriesOf = (state) => (state === 'absent' ? [] : JSON.parse(state.slice(DIRECTORY.length)));
|
|
124
|
+
// A recorded parent moves only by the written entry, so an entry created or removed beside it still stops the batch.
|
|
125
|
+
const refreshParent = (record, path, io) => {
|
|
126
|
+
const [parent, name, current] = [dirname(path), basename(path), refreshState(dirname(path), io)];
|
|
127
|
+
const recorded = record.get(parent);
|
|
128
|
+
if (!(recorded === 'absent' || recorded.startsWith(DIRECTORY)) || !current.startsWith(DIRECTORY)) return;
|
|
129
|
+
const merged = [...entriesOf(recorded).filter(([entry]) => entry !== name), ...entriesOf(current).filter(([entry]) => entry === name)];
|
|
130
|
+
record.set(parent, DIRECTORY + JSON.stringify(merged.sort(([a], [b]) => (a < b ? -1 : 1))));
|
|
131
|
+
};
|
|
132
|
+
const refreshWrites = (record, paths, io) => {
|
|
133
|
+
for (const path of paths) {
|
|
134
|
+
record.set(path, refreshState(path, io));
|
|
135
|
+
if (record.has(dirname(path))) refreshParent(record, path, io);
|
|
136
|
+
if (readStat(path, io)?.isDirectory()) {
|
|
137
|
+
for (const watched of record.keys()) {
|
|
138
|
+
if (watched.startsWith(path + sep)) record.set(watched, refreshState(watched, io));
|
|
139
|
+
}
|
|
140
|
+
for (const child of readEntries(path, io).map((name) => join(path, name))) record.set(child, refreshState(child, io));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const findMasks = (paths, facts, io, print) => {
|
|
146
|
+
const masks = new Set();
|
|
147
|
+
const root = facts.root ?? facts.cwd;
|
|
148
|
+
for (const path of new Set(paths)) {
|
|
149
|
+
if (!isProtectedPath(path, { root, home: facts.home, hooksDir: join(root, '.git/hooks') })) continue;
|
|
150
|
+
const stat = readStat(path, io);
|
|
151
|
+
if (!stat?.isFile() || stat.size !== 0 || (stat.mode & 0o222) !== 0) continue;
|
|
152
|
+
masks.add(path);
|
|
153
|
+
print(`${path}: an empty read-only file — a running agent command's mask (run init again once it ends) or litter a sandbox left (remove it, then run init again)`);
|
|
154
|
+
}
|
|
155
|
+
return masks;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const launch = async (context, argv, stdio) => {
|
|
159
|
+
try {
|
|
160
|
+
const result = await context.spawn(argv, { cwd: context.facts.root ?? context.cwd, env: context.spawnEnv, stdio });
|
|
161
|
+
if (!result || typeof result !== 'object') return { status: null, stdout: '', stderr: 'empty answer', valid: false };
|
|
162
|
+
return {
|
|
163
|
+
status: result.status ?? null,
|
|
164
|
+
stdout: String(result.stdout ?? ''),
|
|
165
|
+
stderr: String(result.stderr ?? result.error?.message ?? result.signal ?? ''),
|
|
166
|
+
valid: typeof result.status === 'number' && !result.error && !result.signal,
|
|
167
|
+
};
|
|
168
|
+
} catch (error) {
|
|
169
|
+
return { status: null, stdout: '', stderr: String(error.code ?? error.message ?? error), valid: false };
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Outside a project only the host-scoped variants reach the queue: an apply-only one or one judged over a claude dir.
|
|
174
|
+
const reachesQueue = (variant, facts) => {
|
|
175
|
+
if (facts.root !== null) return true;
|
|
176
|
+
const argv = consoleArgv(variant, facts);
|
|
177
|
+
return Boolean(argv) && (argv.preview === null || argv.preview.includes('--claude-dir'));
|
|
178
|
+
};
|
|
179
|
+
const isMasked = (variant, context) => (writesOf(variant, context.facts) ?? [])
|
|
180
|
+
.some((write) => [...context.masks].some((mask) => dirCovers(write, mask)));
|
|
181
|
+
|
|
182
|
+
// context.items keeps the console items of the advisor's latest return, before the queue's filters.
|
|
183
|
+
const getQueue = async (context) => {
|
|
184
|
+
const { items } = await context.recommend(context.facts.root, { keySet: context.keySet });
|
|
185
|
+
context.items = items.filter(({ variant }) => laneOf(variant) === LANE_CONSOLE);
|
|
186
|
+
return context.items.filter(({ variant }) => reachesQueue(variant, context.facts) && !isMasked(variant, context));
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const printNotPending = (context) => {
|
|
190
|
+
const plain = routeLine('velocity-core');
|
|
191
|
+
const variants = Object.keys(VARIANT_LANES).filter((variant) => laneOf(variant) === LANE_CONSOLE && routeLine(variant)?.length > plain.length);
|
|
192
|
+
const keys = new Set();
|
|
193
|
+
for (const variant of variants) {
|
|
194
|
+
const key = variant.split('.')[0];
|
|
195
|
+
if (keys.has(key)) continue;
|
|
196
|
+
keys.add(key);
|
|
197
|
+
if (!reachesQueue(variant, context.facts) || context.items.some((item) => item.key === key)) continue;
|
|
198
|
+
const preview = consoleArgv(variant, context.facts)?.preview;
|
|
199
|
+
const index = preview?.indexOf('--claude-dir') ?? -1;
|
|
200
|
+
const judged = index < 0 ? '' : ` (judged ${preview[index + 1]})`;
|
|
201
|
+
context.print(`not pending in this console: ${key}${judged}`);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const printNote = (context, key) => {
|
|
206
|
+
const lines = String(context.io.readFile(join(context.facts.toolsDir, '../references/modes/recommendations.md'), 'utf8')).split('\n');
|
|
207
|
+
const start = lines.findIndex((line) => line.startsWith(`- \`${key}\` — `));
|
|
208
|
+
if (start < 0) return;
|
|
209
|
+
const end = lines.findIndex((line, index) => index > start && !line.startsWith(' ') && !line.startsWith('\t'));
|
|
210
|
+
for (const line of lines.slice(start, end < 0 ? lines.length : end)) context.print(line);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const previewQueue = async (context, queue) => {
|
|
214
|
+
const batch = [];
|
|
215
|
+
for (const item of queue) {
|
|
216
|
+
const argv = consoleArgv(item.variant, context.facts);
|
|
217
|
+
if (!argv?.preview) continue;
|
|
218
|
+
context.print(`preview: ${item.variant}`);
|
|
219
|
+
const result = await launch(context, argv.preview, 'pipe');
|
|
220
|
+
const success = result.valid && (result.status === 0 || (result.status === 3 && result.stdout.includes('status=missing-binaries')));
|
|
221
|
+
if (!success) {
|
|
222
|
+
context.print(`preview refused (exit ${result.status}): ${item.variant}`);
|
|
223
|
+
if (result.stdout) context.print(result.stdout);
|
|
224
|
+
if (result.stderr) context.print(result.stderr);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
context.print(result.stdout);
|
|
228
|
+
const tuple = result.stdout.match(/--apply\s+(\S+)/)?.[1];
|
|
229
|
+
const facts = { ...context.facts, tuple };
|
|
230
|
+
batch.push({ ...item, applyArgv: consoleArgv(item.variant, facts).apply, writes: writesOf(item.variant, facts) });
|
|
231
|
+
printNote(context, item.key);
|
|
232
|
+
}
|
|
233
|
+
for (const item of queue.filter(({ variant }) => consoleArgv(variant, context.facts) === null)) {
|
|
234
|
+
context.print(`listed, not applied here: ${item.variant} — ${item.what}`);
|
|
235
|
+
}
|
|
236
|
+
return batch;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const isConfiguration = (path, root) => root !== null && (
|
|
240
|
+
SETTINGS.slice(0, 3).some((rel) => path === join(root, rel))
|
|
241
|
+
|| ['.claude/agents', '.claude/hooks'].some((rel) => path === join(root, rel) || path.startsWith(join(root, rel) + sep))
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
// The record is the state the first preview reads: taken before any preview, compared before each apply.
|
|
245
|
+
const takeRecord = (context, queue) => {
|
|
246
|
+
const previewable = queue.filter(({ variant }) => consoleArgv(variant, context.facts)?.preview);
|
|
247
|
+
if (previewable.length === 0) return new Map();
|
|
248
|
+
const paths = [...collectPaths(context.facts, context.io), ...previewable.flatMap(({ variant }) => writesOf(variant, context.facts))];
|
|
249
|
+
try {
|
|
250
|
+
return recordPaths(paths, context.io);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
for (const item of previewable) context.print(`not applied: ${item.variant} — ${error.message}`);
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const runPass = async (context, queue) => {
|
|
258
|
+
const record = takeRecord(context, queue);
|
|
259
|
+
if (record === null) return { code: 1, successes: 0, variants: [] };
|
|
260
|
+
const batch = await previewQueue(context, queue);
|
|
261
|
+
if (batch.length === 0) return { code: 0, successes: 0, variants: [] };
|
|
262
|
+
const before = new Map(record);
|
|
263
|
+
const outcome = { code: 0, successes: 0, variants: batch.map((item) => item.variant) };
|
|
264
|
+
context.print(RUN_NONE);
|
|
265
|
+
if (!isYes(await context.terminal.ask(APPLY_PROMPT))) return outcome;
|
|
266
|
+
for (const [index, item] of batch.entries()) {
|
|
267
|
+
if (item.variant === INSTALL_VARIANT && !isYes(await context.terminal.ask('the sandbox install may ask for your sudo password: y runs it, Enter skips: '))) continue;
|
|
268
|
+
const changed = findChanged(record, context.io);
|
|
269
|
+
if (changed) {
|
|
270
|
+
for (const later of batch.slice(index)) context.print(`not applied: ${later.variant} — ${changed} changed outside this batch`);
|
|
271
|
+
outcome.code = 1;
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
const result = await launch(context, item.applyArgv, ['inherit', 'pipe', 'inherit']);
|
|
275
|
+
if (result.stdout) context.print(result.stdout);
|
|
276
|
+
const success = result.valid && (result.status === 0 || (result.status === 5 && result.stdout.includes('install completed')));
|
|
277
|
+
if (!success) {
|
|
278
|
+
context.print(`apply failed (exit ${result.status}): ${item.variant}`);
|
|
279
|
+
if (result.stderr) context.print(result.stderr);
|
|
280
|
+
for (const later of batch.slice(index + 1)) context.print(`not applied: ${later.variant} — an earlier apply failed`);
|
|
281
|
+
outcome.code = 1;
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
outcome.successes += 1;
|
|
285
|
+
refreshWrites(record, item.writes, context.io);
|
|
286
|
+
}
|
|
287
|
+
if (outcome.successes > 0) {
|
|
288
|
+
const afterPaths = [...collectPaths(context.facts, context.io), ...record.keys()];
|
|
289
|
+
context.restart ||= afterPaths.some((path) => isConfiguration(path, context.facts.root)
|
|
290
|
+
&& refreshState(path, context.io) !== (before.get(path) ?? 'absent'));
|
|
291
|
+
}
|
|
292
|
+
return outcome;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const selectRoot = async (context) => {
|
|
296
|
+
const { candidates } = findProject({ cwd: context.cwd, lstat: context.io.lstat });
|
|
297
|
+
if (candidates.length === 0) {
|
|
298
|
+
context.print("no agent-workflow project here — the project items apply when init runs from that project's folder");
|
|
299
|
+
return { root: null };
|
|
300
|
+
}
|
|
301
|
+
if (candidates.length === 1) return candidates[0];
|
|
302
|
+
candidates.forEach(({ root }, index) => context.print(` ${index + 1}. ${root}`));
|
|
303
|
+
const answer = await context.terminal.ask(`which project? 1-${candidates.length}, Enter skips: `);
|
|
304
|
+
return candidates.find((_, index) => String(index + 1) === answer) ?? null;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const gateStamp = (candidate, context) => {
|
|
308
|
+
if (candidate.root === null) return null;
|
|
309
|
+
const stamp = (() => {
|
|
310
|
+
try {
|
|
311
|
+
return String(context.io.readFile(join(candidate.root, 'docs/ai/.workflow-version'), 'utf8')).trim();
|
|
312
|
+
} catch {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
})();
|
|
316
|
+
if (candidate.shape === 'stamped' && stamp === EXPECTED_WORKFLOW_VERSION) return candidate.root;
|
|
317
|
+
if (compareSemver(stamp, EXPECTED_WORKFLOW_VERSION) === 1) {
|
|
318
|
+
context.print(`this project's stamp ${stamp} is ahead of this kit's lineage ${EXPECTED_WORKFLOW_VERSION} — update the kit, then init again`);
|
|
319
|
+
} else {
|
|
320
|
+
context.print('run /agent-workflow-kit upgrade, then init again');
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
export const runInitProject = async (deps) => {
|
|
326
|
+
const { terminal } = deps;
|
|
327
|
+
if (terminal.isTTY !== true) {
|
|
328
|
+
terminal.print("project step skipped: no terminal — run init in a console from the project's folder");
|
|
329
|
+
return { code: 0 };
|
|
330
|
+
}
|
|
331
|
+
const io = { ...DEFAULT_IO, ...deps.io };
|
|
332
|
+
const print = (line) => terminal.print(line);
|
|
333
|
+
if (!preflightInit({ ...deps, root: null, io }).ok) {
|
|
334
|
+
print(INSIDE_LINE);
|
|
335
|
+
return { code: 0 };
|
|
336
|
+
}
|
|
337
|
+
const spawnEnv = { ...deps.env };
|
|
338
|
+
delete spawnEnv.GIT_DIR;
|
|
339
|
+
delete spawnEnv.GIT_WORK_TREE;
|
|
340
|
+
const context = { ...deps, io, print, spawnEnv, masks: new Set(), keySet: false, restart: false };
|
|
341
|
+
const candidate = await selectRoot(context);
|
|
342
|
+
if (candidate === null) return { code: 0 };
|
|
343
|
+
if (!preflightInit({ ...deps, root: candidate.root, io }).ok) {
|
|
344
|
+
print(INSIDE_LINE);
|
|
345
|
+
return { code: 0 };
|
|
346
|
+
}
|
|
347
|
+
const root = gateStamp(candidate, context);
|
|
348
|
+
// The hooks dir git itself reports (a linked worktree's or core.hooksPath), as the advisor reads it.
|
|
349
|
+
const hooksDir = root === null ? null : (deps.gitHooksPath ?? resolveGitHooksPath)(root) ?? join(root, '.git/hooks');
|
|
350
|
+
context.facts = { toolsDir: deps.toolsDir, root, env: deps.env, home: deps.home, platform: deps.platform, tuple: undefined, hooksDir };
|
|
351
|
+
context.recommend ??= (root, facts) => buildRecommendations({ cwd: root ?? deps.cwd,
|
|
352
|
+
deps: { ...io, env: deps.env, getenv: deps.env, home: deps.home, platform: deps.platform,
|
|
353
|
+
jevHost: { ...hostFacts(), home: deps.home, platform: deps.platform }, ...facts } });
|
|
354
|
+
const queue = await getQueue(context);
|
|
355
|
+
const maskPaths = [...collectPaths(context.facts, io), ...queue.flatMap(({ variant }) => writesOf(variant, context.facts) ?? [])];
|
|
356
|
+
context.masks = findMasks(maskPaths, { ...context.facts, cwd: deps.cwd }, io, print);
|
|
357
|
+
const state = { queue: queue.filter(({ variant }) => !isMasked(variant, context)) };
|
|
358
|
+
for (const item of state.queue.filter(({ variant }) => consoleArgv(variant, context.facts)?.preview === null)) {
|
|
359
|
+
print(item.what);
|
|
360
|
+
print(`it writes: ${writesOf(item.variant, context.facts).join(', ')}`);
|
|
361
|
+
printNote(context, item.key);
|
|
362
|
+
if (!isYes(await terminal.ask('y runs its command on this terminal, Enter skips: '))) continue;
|
|
363
|
+
const result = await launch(context, consoleArgv(item.variant, context.facts).apply, 'inherit');
|
|
364
|
+
if (result.valid && result.status === 0) {
|
|
365
|
+
context.keySet = true;
|
|
366
|
+
state.queue = await getQueue(context);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
state.queue = state.queue.filter(({ variant }) => consoleArgv(variant, context.facts)?.preview !== null);
|
|
370
|
+
printNotPending(context);
|
|
371
|
+
const first = await runPass(context, state.queue);
|
|
372
|
+
const outcome = { code: first.code };
|
|
373
|
+
if (first.code === 0 && first.successes > 0) {
|
|
374
|
+
const next = (await getQueue(context)).filter(({ variant }) => !first.variants.includes(variant) && consoleArgv(variant, context.facts)?.preview);
|
|
375
|
+
outcome.code = (await runPass(context, next)).code;
|
|
376
|
+
}
|
|
377
|
+
if (context.restart) print(RESTART_LINE);
|
|
378
|
+
return outcome;
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
export const main = async (argv = process.argv.slice(2), deps = {}) => {
|
|
382
|
+
if (argv.length > 0) {
|
|
383
|
+
(deps.errlog ?? console.error)('Usage: node init-project.mjs');
|
|
384
|
+
return 2;
|
|
385
|
+
}
|
|
386
|
+
const readline = deps.terminal ? null : createInterface({ input: process.stdin, output: process.stdout });
|
|
387
|
+
const terminal = deps.terminal ?? { isTTY: process.stdin.isTTY === true && process.stdout.isTTY === true,
|
|
388
|
+
ask: (prompt) => readline.question(prompt), print: (line) => console.log(line) };
|
|
389
|
+
try {
|
|
390
|
+
const result = await runInitProject({ cwd: process.cwd(), env: process.env, home: homedir(), platform: process.platform,
|
|
391
|
+
toolsDir: dirname(fileURLToPath(import.meta.url)), terminal,
|
|
392
|
+
spawn: (args, options) => spawnSync(process.execPath, args.slice(1), { ...options, encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER }),
|
|
393
|
+
...deps });
|
|
394
|
+
return result.code;
|
|
395
|
+
} finally {
|
|
396
|
+
readline?.close();
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
if (isDirectRun(import.meta.url)) process.exitCode = await main();
|
package/tools/mcp.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// would be a client error on every startup;
|
|
17
17
|
// • merge-don't-clobber — foreign servers, foreign settings keys and existing allow rules are
|
|
18
18
|
// preserved; re-apply adds nothing twice; the file's EOL is kept;
|
|
19
|
-
// • a same-name entry that STRUCTURALLY DIFFERS is REFUSED unwritten (key order is ignored, so a
|
|
19
|
+
// • without `--replace`, a same-name entry that STRUCTURALLY DIFFERS is REFUSED unwritten (key order is ignored, so a
|
|
20
20
|
// re-serialized identical entry is the SAME registration) — silently changing what an MCP server
|
|
21
21
|
// launches is exactly what consent must not slide past; the recovery is named;
|
|
22
22
|
// • the preflight is READ-ONLY: an absent `.claude/` is a NAMED state, and the dir is created on
|
|
@@ -32,11 +32,16 @@
|
|
|
32
32
|
// Exit codes: 0 done / dry-run (incl. the hand-apply masked state); 1 precondition STOP; 2 usage.
|
|
33
33
|
// Dependency-free beyond the kit's own exports, Node >= 22. No side effects on import.
|
|
34
34
|
|
|
35
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
lstatSync, openSync, closeSync, fstatSync, writeFileSync, ftruncateSync, fsyncSync,
|
|
37
|
+
renameSync, constants,
|
|
38
|
+
} from 'node:fs';
|
|
36
39
|
import { join, resolve } from 'node:path';
|
|
37
40
|
import { fileURLToPath } from 'node:url';
|
|
38
41
|
import { assertCreatableDirSafe, writeContainedFileAtomic } from './atomic-write.mjs';
|
|
39
42
|
import { isDirectRun } from './direct-run.mjs';
|
|
43
|
+
import { describeNonRegular } from './fs-read-nofollow.mjs';
|
|
44
|
+
import { assertContainedRealPath } from './fs-safe.mjs';
|
|
40
45
|
import {
|
|
41
46
|
CLAUDE_DIR_REL,
|
|
42
47
|
ENABLED_KEY,
|
|
@@ -67,16 +72,16 @@ const LF = '\n';
|
|
|
67
72
|
const JSON_INDENT = 2;
|
|
68
73
|
|
|
69
74
|
export const MCP_TOOL = fileURLToPath(import.meta.url);
|
|
70
|
-
export const applyMcpCommand = (root) => `node ${q(MCP_TOOL)} --apply --cwd ${q(root)}`;
|
|
75
|
+
export const applyMcpCommand = (root, replace = false) => `node ${q(MCP_TOOL)}${replace ? ' --replace' : ''} --apply --cwd ${q(root)}`;
|
|
71
76
|
|
|
72
|
-
const USAGE = `usage: mcp [--dry-run | --apply] [--cwd <dir>] [--help]
|
|
77
|
+
const USAGE = `usage: mcp [--dry-run | --apply] [--replace] [--cwd <dir>] [--help]
|
|
73
78
|
|
|
74
79
|
Registers this kit's stdio MCP server in ONE project: the "${SERVER_NAME}" entry in
|
|
75
80
|
${MCP_JSON_REL}, and "${ENABLED_KEY}" + the two tool allow rules in ${SETTINGS_REL}.
|
|
76
81
|
Default is --dry-run (a preview that prints the exact entry and writes nothing).
|
|
77
|
-
--apply writes: ${MCP_JSON_REL} first, then ${SETTINGS_REL}; merge-don't-clobber, EOL kept.
|
|
82
|
+
--apply writes: ${MCP_JSON_REL} first, then ${SETTINGS_REL}; merge-don't-clobber, EOL kept. An existing target is written in place; --replace replaces a differing entry only with --apply.
|
|
78
83
|
|
|
79
|
-
|
|
84
|
+
Without --replace, an existing "${SERVER_NAME}" entry that STRUCTURALLY DIFFERS is refused unwritten (key
|
|
80
85
|
order is ignored). Where ${MCP_JSON_REL} is a device node, FIFO or socket — an OS sandbox
|
|
81
86
|
mask is the usual cause — the entry to merge is printed and nothing is written.
|
|
82
87
|
Never writes settings.local.json; never commits.`;
|
|
@@ -122,7 +127,7 @@ const assertTargetUsable = (target, { maskedAllowed = false } = {}) => {
|
|
|
122
127
|
}
|
|
123
128
|
};
|
|
124
129
|
|
|
125
|
-
export const preflightMcp = ({ cwd }, deps = {}) => {
|
|
130
|
+
export const preflightMcp = ({ cwd, replace = false }, deps = {}) => {
|
|
126
131
|
const root = resolve(cwd ?? deps.cwd ?? process.cwd());
|
|
127
132
|
// The ROOT is judged BEFORE the registration is read, not merely before the first write: reading
|
|
128
133
|
// first would follow the very link this refuses, which is exactly what the shipped claim denies.
|
|
@@ -152,7 +157,7 @@ export const preflightMcp = ({ cwd }, deps = {}) => {
|
|
|
152
157
|
}
|
|
153
158
|
assertTargetUsable(registration.mcpJson, { maskedAllowed: true });
|
|
154
159
|
assertTargetUsable(registration.settings);
|
|
155
|
-
if (registration.mcpJson.differs) {
|
|
160
|
+
if (registration.mcpJson.differs && !replace) {
|
|
156
161
|
throw makeMcpError(
|
|
157
162
|
MCP_DIFFERS,
|
|
158
163
|
`${MCP_JSON_REL} already carries an "${SERVER_NAME}" server entry that STRUCTURALLY DIFFERS from this kit copy's registration — refusing to change what it launches; review that entry and remove or rename it, then re-run`,
|
|
@@ -173,8 +178,53 @@ export const planMcp = (registration) => ({
|
|
|
173
178
|
|
|
174
179
|
// ── the writer ─────────────────────────────────────────────────────────────────────────
|
|
175
180
|
|
|
176
|
-
|
|
177
|
-
const
|
|
181
|
+
const writeTarget = (root, target, body, deps, stop) => {
|
|
182
|
+
const leaf = lstatNoFollow(target.abs, deps.lstat);
|
|
183
|
+
if (target.state === STATE.ABSENT) {
|
|
184
|
+
const assertAbsent = (dst) => {
|
|
185
|
+
if (lstatNoFollow(dst, deps.lstat) !== null) {
|
|
186
|
+
throw Object.assign(new Error('EEXIST'), { code: 'EEXIST' });
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
assertAbsent(target.abs);
|
|
190
|
+
const rename = (tmp, dst) => {
|
|
191
|
+
assertAbsent(dst);
|
|
192
|
+
(deps.rename ?? renameSync)(tmp, dst);
|
|
193
|
+
};
|
|
194
|
+
return writeContainedFileAtomic(root, target.abs, body, { ...deps, rename }, { stop, label: target.rel });
|
|
195
|
+
}
|
|
196
|
+
if (leaf?.isSymbolicLink()) {
|
|
197
|
+
throw stop(`${target.rel} is a symlink — refusing to replace it (a write would clobber the link target)`);
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
assertContainedRealPath(root, target.abs, deps);
|
|
201
|
+
} catch (err) {
|
|
202
|
+
throw stop(String(err?.message ?? err).replace(/^\[agent-workflow-kit\] /, ''));
|
|
203
|
+
}
|
|
204
|
+
const flags = deps.constants ?? constants;
|
|
205
|
+
if (!flags.O_NOFOLLOW || !flags.O_NONBLOCK) {
|
|
206
|
+
throw stop('this platform exposes no usable O_NOFOLLOW|O_NONBLOCK open flags — refusing to open (fail closed)');
|
|
207
|
+
}
|
|
208
|
+
const fd = (deps.open ?? openSync)(target.abs, flags.O_WRONLY | flags.O_NOFOLLOW | flags.O_NONBLOCK);
|
|
209
|
+
try {
|
|
210
|
+
const stat = (deps.fstat ?? fstatSync)(fd);
|
|
211
|
+
if (!stat.isFile()) {
|
|
212
|
+
throw stop(`${target.rel} is a ${describeNonRegular(stat)} — refusing to write through it`);
|
|
213
|
+
}
|
|
214
|
+
if (leaf === null || stat.dev !== leaf.dev || stat.ino !== leaf.ino) {
|
|
215
|
+
throw stop('the leaf changed identity between lstat and open (fail closed)');
|
|
216
|
+
}
|
|
217
|
+
const bytes = Buffer.from(body, 'utf8');
|
|
218
|
+
(deps.writeFile ?? writeFileSync)(fd, bytes);
|
|
219
|
+
(deps.ftruncate ?? ftruncateSync)(fd, bytes.length);
|
|
220
|
+
(deps.fsync ?? fsyncSync)(fd);
|
|
221
|
+
} finally {
|
|
222
|
+
(deps.close ?? closeSync)(fd);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
export const writeMcp = ({ cwd, dryRun = true, replace = false } = {}, deps = {}) => {
|
|
227
|
+
const preflight = preflightMcp({ cwd: cwd ?? deps.cwd ?? process.cwd(), replace }, deps);
|
|
178
228
|
const base = { ...preflight, dryRun, wrote: false };
|
|
179
229
|
if (preflight.masked) return { ...base, fragments: renderFragments(preflight.registration) };
|
|
180
230
|
if (dryRun) return base;
|
|
@@ -182,7 +232,7 @@ export const writeMcp = ({ cwd, dryRun = true } = {}, deps = {}) => {
|
|
|
182
232
|
const { root, registration, plan } = preflight;
|
|
183
233
|
const stop = (message) => makeMcpError(MCP_SYMLINK, message);
|
|
184
234
|
if (plan.writeMcpJson) {
|
|
185
|
-
|
|
235
|
+
writeTarget(root, registration.mcpJson, plan.mcpBody, deps, stop);
|
|
186
236
|
}
|
|
187
237
|
if (plan.writeSettings) {
|
|
188
238
|
// A settings failure AFTER the entry landed leaves a STANDING registration on disk that never
|
|
@@ -194,12 +244,12 @@ export const writeMcp = ({ cwd, dryRun = true } = {}, deps = {}) => {
|
|
|
194
244
|
// The ONE write the preflight deliberately does not do: creating `.claude/` is a mutation, so
|
|
195
245
|
// it belongs on the apply lane only — a preview that made a directory would not be a preview.
|
|
196
246
|
assertCreatableDirSafe(join(root, CLAUDE_DIR_REL), deps, { stop, noun: SETTINGS_REL });
|
|
197
|
-
|
|
247
|
+
writeTarget(root, registration.settings, plan.settingsBody, deps, stop);
|
|
198
248
|
} catch (err) {
|
|
199
249
|
// Reaching here always leaves a STANDING registration on disk — either this run wrote the
|
|
200
250
|
// entry (`writeMcpJson`) or the preflight found it already current (`matches`), and those two
|
|
201
251
|
// are exhaustive, so the note is unconditional rather than guarded by a branch nothing can
|
|
202
|
-
// take.
|
|
252
|
+
// take. Without --replace, a differing entry STOPs in the preflight.
|
|
203
253
|
throw Object.assign(err, { message: `${err.message}\n${HIDDEN_MODE_LINE}` });
|
|
204
254
|
}
|
|
205
255
|
}
|
|
@@ -221,7 +271,9 @@ const indented = (text) => text.trimEnd().split(LF).map((line) => ` ${line}`)
|
|
|
221
271
|
|
|
222
272
|
const mcpJsonLine = (result) => {
|
|
223
273
|
if (!result.plan.writeMcpJson) return ` - ${MCP_JSON_REL}: already current`;
|
|
224
|
-
const verb = result.
|
|
274
|
+
const verb = result.registration.mcpJson.differs
|
|
275
|
+
? (result.dryRun ? 'would replace' : 'replaced')
|
|
276
|
+
: (result.dryRun ? 'would add' : 'added');
|
|
225
277
|
return ` - ${MCP_JSON_REL}: ${verb} the "${SERVER_NAME}" stdio entry`;
|
|
226
278
|
};
|
|
227
279
|
|
|
@@ -264,24 +316,29 @@ export const formatResult = (result) => {
|
|
|
264
316
|
: 'agent-workflow MCP registration — APPLY',
|
|
265
317
|
mcpJsonLine(result),
|
|
266
318
|
settingsLine(result),
|
|
319
|
+
...(result.registration.mcpJson.differs ? [
|
|
320
|
+
' the entry standing in .mcp.json now:',
|
|
321
|
+
indented(JSON.stringify(result.registration.mcpJson.existing, null, JSON_INDENT)),
|
|
322
|
+
] : []),
|
|
267
323
|
' the entry this registration declares (re-serialized here; the same structured value goes into the file):',
|
|
268
324
|
indented(JSON.stringify(result.registration.entry, null, JSON_INDENT)),
|
|
269
325
|
POSTURE_LINE,
|
|
270
326
|
HIDDEN_MODE_LINE,
|
|
271
327
|
];
|
|
272
|
-
if (result.dryRun) lines.push(` to apply: ${applyMcpCommand(result.root)}`);
|
|
328
|
+
if (result.dryRun) lines.push(` to apply: ${applyMcpCommand(result.root, result.registration.mcpJson.differs)}`);
|
|
273
329
|
return lines.join(LF);
|
|
274
330
|
};
|
|
275
331
|
|
|
276
332
|
// ── CLI ────────────────────────────────────────────────────────────────────────────────
|
|
277
333
|
|
|
278
334
|
export const parseArgs = (argv) => {
|
|
279
|
-
const opts = { dryRunFlag: false, apply: false, cwd: undefined, help: false };
|
|
335
|
+
const opts = { dryRunFlag: false, apply: false, replace: false, cwd: undefined, help: false };
|
|
280
336
|
for (let i = 0; i < argv.length; i += 1) {
|
|
281
337
|
const arg = argv[i];
|
|
282
338
|
if (arg === '--help' || arg === '-h') opts.help = true;
|
|
283
339
|
else if (arg === '--dry-run') opts.dryRunFlag = true;
|
|
284
340
|
else if (arg === '--apply') opts.apply = true;
|
|
341
|
+
else if (arg === '--replace') opts.replace = true;
|
|
285
342
|
else if (arg === '--cwd') {
|
|
286
343
|
i += 1;
|
|
287
344
|
// An EMPTY (or whitespace) value passes both guards above, and `resolve('')` silently means the
|
|
@@ -296,7 +353,7 @@ export const parseArgs = (argv) => {
|
|
|
296
353
|
}
|
|
297
354
|
}
|
|
298
355
|
if (opts.dryRunFlag && opts.apply) throw fail(EXIT_USAGE, '--dry-run and --apply cannot be used together');
|
|
299
|
-
return { help: opts.help, dryRun: !opts.apply, cwd: opts.cwd };
|
|
356
|
+
return { help: opts.help, dryRun: !opts.apply, replace: opts.replace, cwd: opts.cwd };
|
|
300
357
|
};
|
|
301
358
|
|
|
302
359
|
export const main = (argv = process.argv.slice(2), deps = {}) => {
|
|
@@ -308,10 +365,11 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
|
|
|
308
365
|
log(USAGE);
|
|
309
366
|
return EXIT_OK;
|
|
310
367
|
}
|
|
311
|
-
log(formatResult(writeMcp({ cwd: args.cwd ?? deps.cwd ?? process.cwd(), dryRun: args.dryRun }, deps)));
|
|
368
|
+
log(formatResult(writeMcp({ cwd: args.cwd ?? deps.cwd ?? process.cwd(), dryRun: args.dryRun, replace: args.replace }, deps)));
|
|
312
369
|
return EXIT_OK;
|
|
313
370
|
} catch (err) {
|
|
314
|
-
|
|
371
|
+
const message = err?.message ?? String(err);
|
|
372
|
+
errlog(err?.code && err.name !== 'McpError' && !message.includes(err.code) ? `${err.code}: ${message}` : message);
|
|
315
373
|
if (err?.exitCode === EXIT_USAGE) errlog(USAGE);
|
|
316
374
|
return err?.exitCode ?? EXIT_PRECONDITION;
|
|
317
375
|
}
|