@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.
@@ -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 { lstatSync } from 'node:fs';
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
- An existing "${SERVER_NAME}" entry that STRUCTURALLY DIFFERS is refused unwritten (key
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
- export const writeMcp = ({ cwd, dryRun = true } = {}, deps = {}) => {
177
- const preflight = preflightMcp({ cwd: cwd ?? deps.cwd ?? process.cwd() }, deps);
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
- writeContainedFileAtomic(root, registration.mcpJson.abs, plan.mcpBody, deps, { stop, label: MCP_JSON_REL });
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
- writeContainedFileAtomic(root, registration.settings.abs, plan.settingsBody, deps, { stop, label: SETTINGS_REL });
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. A differing entry never reaches the writer at all; it STOPs in the preflight.
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.dryRun ? 'would add' : 'added';
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
- errlog(err?.message ?? String(err));
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
  }