archgen-skill 0.0.6 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/bin/archgen.mjs +123 -13
- package/lib/block.js +51 -10
- package/lib/bootstrap.js +8 -4
- package/lib/config.js +3 -2
- package/lib/doctor.js +156 -79
- package/lib/init.js +45 -16
- package/lib/install.js +60 -7
- package/lib/mcp-server.js +88 -6
- package/lib/migrations/001-stamp-provenance.mjs +12 -5
- package/lib/migrations/index.mjs +19 -8
- package/lib/restore.js +52 -16
- package/lib/spawn-npm.js +49 -4
- package/lib/store.js +100 -23
- package/lib/uninstall-project.js +22 -4
- package/lib/version.js +9 -1
- package/package.json +1 -1
- package/vendor/skills/archgen/SKILL.md +2 -2
- package/vendor/skills/archgen/archgen.config.json +1 -1
package/lib/doctor.js
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
// Verifies a project install: canonical store integrity, version stamp,
|
|
4
4
|
// claude link resolution, managed blocks (exactly once each), and manifest
|
|
5
5
|
// entry resolution. Safe issues are repaired in place unless --check is given
|
|
6
|
-
// (report-only).
|
|
7
|
-
// reported
|
|
8
|
-
//
|
|
6
|
+
// (report-only). A FAIL row is a broken state a human must fix: it is
|
|
7
|
+
// reported AND it fails the run (the process exits non-zero whenever any
|
|
8
|
+
// FAIL row was produced).
|
|
9
9
|
|
|
10
10
|
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, readlinkSync, symlinkSync, unlinkSync } from 'node:fs';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
|
-
import { dirname, join, resolve } from 'node:path';
|
|
12
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
13
13
|
import { globalTargets } from './install.js';
|
|
14
14
|
import {
|
|
15
15
|
END,
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
upsertManagedFile,
|
|
23
23
|
} from './block.js';
|
|
24
24
|
import { resolveSkillSource } from './init.js';
|
|
25
|
-
import { CLAUDE_LINK_REL, DEV_ONLY_RELPATHS, MANIFEST_REL, STORE_REL, VERSION_FILE, cliVersion, findObsolete, hashDir, loadManifest, pruneDevOnly, pruneObsolete, saveManifest } from './store.js';
|
|
25
|
+
import { CLAUDE_LINK_REL, DEV_ONLY_RELPATHS, MANIFEST_REL, STORE_REL, VERSION_FILE, assertNotSymlink, assertWithinRoot, cliVersion, findObsolete, hashDir, loadManifest, pruneDevOnly, pruneObsolete, saveManifest } from './store.js';
|
|
26
26
|
import { compareSemver, parseSemver } from './version.js';
|
|
27
27
|
import { readStamp, writeStamp } from './version-stamp.js';
|
|
28
28
|
import { mcpDoctor } from './mcp-server.js';
|
|
@@ -48,8 +48,7 @@ function relJoin(rel) {
|
|
|
48
48
|
* mutations; home overrides the homedir scanned for global installs (tests)
|
|
49
49
|
* @returns {{root: string, checks: {status: 'OK'|'FIXED'|'WOULD-FIX'|'WARN'|'FAIL', msg: string}[],
|
|
50
50
|
* tally: Record<string, number>, failures: number}} `failures` counts
|
|
51
|
-
*
|
|
52
|
-
* but never fail the run.
|
|
51
|
+
* the FAIL rows — a broken state a human must fix always fails the run.
|
|
53
52
|
*/
|
|
54
53
|
export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
55
54
|
const root = resolve(projectDir);
|
|
@@ -57,16 +56,20 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
|
57
56
|
const homeDir = opts.home ?? homedir();
|
|
58
57
|
const version = cliVersion(packageRoot);
|
|
59
58
|
const checks = [];
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
let failures = 0;
|
|
60
|
+
// A FAIL row is a broken state a human must fix, so it MUST fail the run:
|
|
61
|
+
// the counter below is what the CLI turns into a non-zero exit code.
|
|
62
|
+
const add = (status, msg) => {
|
|
63
|
+
checks.push({ status, msg });
|
|
64
|
+
if (status === 'FAIL') failures++;
|
|
65
|
+
};
|
|
62
66
|
// Run one repair step; a throw becomes a FAIL row instead of aborting the
|
|
63
|
-
// whole doctor pass
|
|
67
|
+
// whole doctor pass.
|
|
64
68
|
const attempt = (fn, okStatus, okMsg, failLabel) => {
|
|
65
69
|
try {
|
|
66
70
|
fn();
|
|
67
71
|
add(okStatus, okMsg);
|
|
68
72
|
} catch (e) {
|
|
69
|
-
opFailures++;
|
|
70
73
|
add('FAIL', `${failLabel}: ${e.message} - fix manually`);
|
|
71
74
|
}
|
|
72
75
|
};
|
|
@@ -93,9 +96,27 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
|
93
96
|
})();
|
|
94
97
|
if (packaged) {
|
|
95
98
|
// --check reports WITHOUT mutating; repair mode prunes.
|
|
96
|
-
const obsolete =
|
|
97
|
-
|
|
98
|
-
|
|
99
|
+
const obsolete = findObsolete(storeAbs, packaged);
|
|
100
|
+
if (obsolete.length > 0) {
|
|
101
|
+
if (checkOnly) {
|
|
102
|
+
for (const rel of obsolete) {
|
|
103
|
+
add('WOULD-FIX', `store carried the obsolete ${rel}/ tree (pre-MCP layout) -> would prune`);
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
// Pruning DELETES inside the store: refuse when the store (or one of
|
|
107
|
+
// its parents) is a symlink — the prune would otherwise reach
|
|
108
|
+
// outside the project through the link.
|
|
109
|
+
try {
|
|
110
|
+
assertNotSymlink(storeAbs);
|
|
111
|
+
assertWithinRoot(root, storeAbs);
|
|
112
|
+
pruneObsolete(storeAbs, packaged);
|
|
113
|
+
for (const rel of obsolete) {
|
|
114
|
+
add('FIXED', `store carried the obsolete ${rel}/ tree (pre-MCP layout) -> pruned`);
|
|
115
|
+
}
|
|
116
|
+
} catch (e) {
|
|
117
|
+
add('FAIL', `cannot prune the obsolete store tree: ${e.message} - fix manually`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
99
120
|
}
|
|
100
121
|
}
|
|
101
122
|
const missingCore = ['references'].filter((d) => !existsSync(join(storeAbs, d)));
|
|
@@ -124,7 +145,12 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
|
124
145
|
if (checkOnly) {
|
|
125
146
|
add('WOULD-FIX', `store carries dev-only ${devOnlyPresent.join(', ')} -> pruned`);
|
|
126
147
|
} else {
|
|
127
|
-
|
|
148
|
+
// Same rule as the obsolete prune: never delete through a symlink.
|
|
149
|
+
attempt(() => {
|
|
150
|
+
assertNotSymlink(storeAbs);
|
|
151
|
+
assertWithinRoot(root, storeAbs);
|
|
152
|
+
pruneDevOnly(storeAbs);
|
|
153
|
+
}, 'FIXED',
|
|
128
154
|
`dev-only ${devOnlyPresent.join(', ')} pruned from store`, 'cannot prune dev-only artifacts');
|
|
129
155
|
}
|
|
130
156
|
}
|
|
@@ -152,6 +178,10 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
|
152
178
|
return;
|
|
153
179
|
}
|
|
154
180
|
try {
|
|
181
|
+
// Escape check FIRST, exactly as init does: a symlinked .claude/
|
|
182
|
+
// .claude/skills parent must not redirect the recreated link outside
|
|
183
|
+
// the project root.
|
|
184
|
+
assertWithinRoot(root, dirname(claudeAbs));
|
|
155
185
|
try { unlinkSync(claudeAbs); } catch { /* absent */ }
|
|
156
186
|
mkdirSync(dirname(claudeAbs), { recursive: true });
|
|
157
187
|
symlinkSync('../../.agents/skills/archgen', claudeAbs);
|
|
@@ -184,60 +214,66 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
|
184
214
|
'AGENTS.md created with managed block', 'cannot create AGENTS.md managed block');
|
|
185
215
|
}
|
|
186
216
|
} else {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
} else if (starts > 1 || ends > 1) {
|
|
197
|
-
add('FAIL', `AGENTS.md has ${starts} start / ${ends} end markers - fix manually`);
|
|
198
|
-
} else if (starts === 0 || ends === 0 || raw.indexOf(START) > raw.indexOf(END)) {
|
|
199
|
-
// Orphan/inverted markers: repairing through upsertBlock would throw, so
|
|
200
|
-
// classify as FAIL instead of attempting the write.
|
|
201
|
-
add('FAIL', 'AGENTS.md has broken archgen markers - fix manually');
|
|
202
|
-
} else {
|
|
203
|
-
// Block-format currency: an unversioned or older-stamped managed block is
|
|
204
|
-
// upgraded in place through the normal upsert path (everything outside the
|
|
205
|
-
// markers survives byte-for-byte by design). Newer stamps are left alone.
|
|
206
|
-
const det = detectBlockVersion(raw);
|
|
207
|
-
const parsed = det.version ? parseSemver(det.version) : null;
|
|
208
|
-
const stale = !det.present || !parsed || compareSemver(det.version, version) < 0;
|
|
209
|
-
if (stale) {
|
|
210
|
-
const from = det.version ? 'v' + det.version : 'unversioned';
|
|
211
|
-
if (checkOnly) {
|
|
212
|
-
add('WOULD-FIX', `WOULD-UPGRADE: AGENTS.md block ${from} -> v${version}`);
|
|
213
|
-
} else {
|
|
214
|
-
let upgraded = false;
|
|
215
|
-
attempt(() => { upsertManagedFile(agentsAbs, renderBlock(STORE_REL).split('\n')); upgraded = true; },
|
|
216
|
-
'FIXED', `UPGRADED: AGENTS.md block ${from} -> v${version}`, 'cannot upgrade AGENTS.md block');
|
|
217
|
-
if (upgraded) raw = readFileSync(agentsAbs, 'utf8');
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
const fsCount = countOccurrences(raw, FEATURES_START);
|
|
221
|
-
const feCount = countOccurrences(raw, FEATURES_END);
|
|
222
|
-
const fsIdx = raw.indexOf(FEATURES_START);
|
|
223
|
-
const feIdx = raw.indexOf(FEATURES_END);
|
|
224
|
-
if (fsCount === 1 && feCount === 1 && fsIdx < feIdx) {
|
|
225
|
-
add('OK', 'AGENTS.md block + features registry present');
|
|
226
|
-
} else if (fsCount === 1 && feCount === 1) {
|
|
227
|
-
// Counts are right but ORDER is inverted (end before start): any
|
|
228
|
-
// registry write would throw (upsertFeaturesRegistry requires
|
|
229
|
-
// end > start), so flag the state instead of blessing it as OK.
|
|
230
|
-
if (checkOnly) add('WOULD-FIX', 'AGENTS.md archgen:features markers are reversed -> swap them');
|
|
231
|
-
else add('FAIL', 'AGENTS.md archgen:features markers are reversed - fix manually');
|
|
232
|
-
} else if (fsCount === 0 && feCount === 0) {
|
|
233
|
-
if (checkOnly) add('WOULD-FIX', 'AGENTS.md missing features registry -> inserted');
|
|
217
|
+
// An unreadable managed file is a FAIL row, not a reason to abort the
|
|
218
|
+
// whole doctor pass.
|
|
219
|
+
try {
|
|
220
|
+
let raw = readFileSync(agentsAbs, 'utf8');
|
|
221
|
+
const starts = countOccurrences(raw, START);
|
|
222
|
+
const ends = countOccurrences(raw, END);
|
|
223
|
+
if (starts === 0 && ends === 0) {
|
|
224
|
+
if (checkOnly) add('WOULD-FIX', 'AGENTS.md has no archgen block -> added');
|
|
234
225
|
else {
|
|
235
|
-
attempt(() =>
|
|
236
|
-
'AGENTS.md
|
|
226
|
+
attempt(() => upsertManagedFile(agentsAbs, renderBlock(STORE_REL).split('\n')), 'FIXED',
|
|
227
|
+
'AGENTS.md managed block added', 'cannot add AGENTS.md managed block');
|
|
237
228
|
}
|
|
229
|
+
} else if (starts > 1 || ends > 1) {
|
|
230
|
+
add('FAIL', `AGENTS.md has ${starts} start / ${ends} end markers - fix manually`);
|
|
231
|
+
} else if (starts === 0 || ends === 0 || raw.indexOf(START) > raw.indexOf(END)) {
|
|
232
|
+
// Orphan/inverted markers: repairing through upsertBlock would throw, so
|
|
233
|
+
// classify as FAIL instead of attempting the write.
|
|
234
|
+
add('FAIL', 'AGENTS.md has broken archgen markers - fix manually');
|
|
238
235
|
} else {
|
|
239
|
-
|
|
236
|
+
// Block-format currency: an unversioned or older-stamped managed block is
|
|
237
|
+
// upgraded in place through the normal upsert path (everything outside the
|
|
238
|
+
// markers survives byte-for-byte by design). Newer stamps are left alone.
|
|
239
|
+
const det = detectBlockVersion(raw);
|
|
240
|
+
const parsed = det.version ? parseSemver(det.version) : null;
|
|
241
|
+
const stale = !det.present || !parsed || compareSemver(det.version, version) < 0;
|
|
242
|
+
if (stale) {
|
|
243
|
+
const from = det.version ? 'v' + det.version : 'unversioned';
|
|
244
|
+
if (checkOnly) {
|
|
245
|
+
add('WOULD-FIX', `WOULD-UPGRADE: AGENTS.md block ${from} -> v${version}`);
|
|
246
|
+
} else {
|
|
247
|
+
let upgraded = false;
|
|
248
|
+
attempt(() => { upsertManagedFile(agentsAbs, renderBlock(STORE_REL).split('\n')); upgraded = true; },
|
|
249
|
+
'FIXED', `UPGRADED: AGENTS.md block ${from} -> v${version}`, 'cannot upgrade AGENTS.md block');
|
|
250
|
+
if (upgraded) raw = readFileSync(agentsAbs, 'utf8');
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const fsCount = countOccurrences(raw, FEATURES_START);
|
|
254
|
+
const feCount = countOccurrences(raw, FEATURES_END);
|
|
255
|
+
const fsIdx = raw.indexOf(FEATURES_START);
|
|
256
|
+
const feIdx = raw.indexOf(FEATURES_END);
|
|
257
|
+
if (fsCount === 1 && feCount === 1 && fsIdx < feIdx) {
|
|
258
|
+
add('OK', 'AGENTS.md block + features registry present');
|
|
259
|
+
} else if (fsCount === 1 && feCount === 1) {
|
|
260
|
+
// Counts are right but ORDER is inverted (end before start): any
|
|
261
|
+
// registry write would throw (upsertFeaturesRegistry requires
|
|
262
|
+
// end > start), so flag the state instead of blessing it as OK.
|
|
263
|
+
if (checkOnly) add('WOULD-FIX', 'AGENTS.md archgen:features markers are reversed -> swap them');
|
|
264
|
+
else add('FAIL', 'AGENTS.md archgen:features markers are reversed - fix manually');
|
|
265
|
+
} else if (fsCount === 0 && feCount === 0) {
|
|
266
|
+
if (checkOnly) add('WOULD-FIX', 'AGENTS.md missing features registry -> inserted');
|
|
267
|
+
else {
|
|
268
|
+
attempt(() => upsertFeaturesRegistry(agentsAbs, []), 'FIXED',
|
|
269
|
+
'AGENTS.md features registry inserted', 'cannot insert AGENTS.md features registry');
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
add('FAIL', 'AGENTS.md has broken archgen:features markers - fix manually');
|
|
273
|
+
}
|
|
240
274
|
}
|
|
275
|
+
} catch (e) {
|
|
276
|
+
add('FAIL', 'cannot read AGENTS.md: ' + e.message + ' - fix manually');
|
|
241
277
|
}
|
|
242
278
|
}
|
|
243
279
|
|
|
@@ -249,18 +285,44 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
|
249
285
|
'CLAUDE.md created with @AGENTS.md bridge', 'cannot create CLAUDE.md bridge');
|
|
250
286
|
}
|
|
251
287
|
} else {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
288
|
+
// An unreadable managed file is a FAIL row, not a reason to abort the
|
|
289
|
+
// whole doctor pass.
|
|
290
|
+
let raw = null;
|
|
291
|
+
try {
|
|
292
|
+
raw = readFileSync(claudeMdAbs, 'utf8');
|
|
293
|
+
} catch (e) {
|
|
294
|
+
add('FAIL', 'cannot read CLAUDE.md: ' + e.message + ' - fix manually');
|
|
295
|
+
}
|
|
296
|
+
if (raw !== null) {
|
|
297
|
+
const body = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
|
|
298
|
+
const imports = body.split(/\r?\n/).some((l) => l.trim() === '@AGENTS.md');
|
|
299
|
+
const starts = countOccurrences(raw, START);
|
|
300
|
+
const ends = countOccurrences(raw, END);
|
|
301
|
+
if (starts > 1 || ends > 1) add('FAIL', 'CLAUDE.md has multiple archgen blocks - fix manually');
|
|
302
|
+
else if (starts !== ends) add('FAIL', 'CLAUDE.md has broken archgen markers - fix manually');
|
|
303
|
+
else if (starts === 1) {
|
|
304
|
+
// The managed block is ours: keep its provenance stamp current, the
|
|
305
|
+
// same way the AGENTS.md block is kept current.
|
|
306
|
+
const det = detectBlockVersion(raw);
|
|
307
|
+
const parsed = det.version ? parseSemver(det.version) : null;
|
|
308
|
+
const stale = !det.present || !parsed || compareSemver(det.version, version) < 0;
|
|
309
|
+
if (stale) {
|
|
310
|
+
const from = det.version ? 'v' + det.version : 'unversioned';
|
|
311
|
+
if (checkOnly) {
|
|
312
|
+
add('WOULD-FIX', `WOULD-UPGRADE: CLAUDE.md block ${from} -> v${version}`);
|
|
313
|
+
} else {
|
|
314
|
+
attempt(() => upsertManagedFile(claudeMdAbs, [START, '@AGENTS.md', END]), 'FIXED',
|
|
315
|
+
`UPGRADED: CLAUDE.md block ${from} -> v${version}`, 'cannot upgrade CLAUDE.md block');
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
add('OK', 'CLAUDE.md bridges to AGENTS.md');
|
|
319
|
+
}
|
|
320
|
+
} else if (imports) add('OK', 'CLAUDE.md bridges to AGENTS.md');
|
|
321
|
+
else if (checkOnly) add('WOULD-FIX', 'CLAUDE.md missing @AGENTS.md bridge -> written');
|
|
322
|
+
else {
|
|
323
|
+
attempt(() => upsertManagedFile(claudeMdAbs, [START, '@AGENTS.md', END]), 'FIXED',
|
|
324
|
+
'CLAUDE.md @AGENTS.md bridge written', 'cannot write CLAUDE.md bridge');
|
|
325
|
+
}
|
|
264
326
|
}
|
|
265
327
|
}
|
|
266
328
|
|
|
@@ -271,8 +333,23 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
|
271
333
|
if (!manifest) {
|
|
272
334
|
if (existsSync(storeAbs)) add('WARN', `no install manifest at ${MANIFEST_REL} (re-run init to regenerate)`);
|
|
273
335
|
else add('OK', 'no manifest (nothing installed)');
|
|
336
|
+
} else if (!Array.isArray(manifest.entries)) {
|
|
337
|
+
// A manifest without an entries array is malformed: report it instead of
|
|
338
|
+
// crashing the whole doctor pass on it.
|
|
339
|
+
add('FAIL', `manifest ${MANIFEST_REL} has no entries array - fix manually`);
|
|
274
340
|
} else {
|
|
275
|
-
|
|
341
|
+
// An entry only "resolves" when its path stays inside the project root:
|
|
342
|
+
// a manifest path like `../../etc/passwd` must never count as resolved.
|
|
343
|
+
const entryAbs = (rel) => {
|
|
344
|
+
if (typeof rel !== 'string' || rel === '') return null;
|
|
345
|
+
const abs = resolve(root, ...rel.split('/'));
|
|
346
|
+
if (abs !== root && !abs.startsWith(root + sep)) return null;
|
|
347
|
+
return abs;
|
|
348
|
+
};
|
|
349
|
+
const resolves = (e) => {
|
|
350
|
+
const abs = entryAbs(e && e.path);
|
|
351
|
+
return abs !== null && existsSync(abs);
|
|
352
|
+
};
|
|
276
353
|
const stale = manifest.entries.filter((e) => !resolves(e));
|
|
277
354
|
if (stale.length === 0) {
|
|
278
355
|
add('OK', `manifest: all ${manifest.entries.length} entr${manifest.entries.length === 1 ? 'y' : 'ies'} resolve`);
|
|
@@ -341,5 +418,5 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
|
341
418
|
|
|
342
419
|
const tally = { OK: 0, FIXED: 0, 'WOULD-FIX': 0, WARN: 0, FAIL: 0 };
|
|
343
420
|
for (const c of checks) tally[c.status]++;
|
|
344
|
-
return { root, checks, tally, failures
|
|
421
|
+
return { root, checks, tally, failures };
|
|
345
422
|
}
|
package/lib/init.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import { cpSync, existsSync, lstatSync, mkdirSync, readlinkSync, renameSync, rmSync, symlinkSync, unlinkSync } from 'node:fs';
|
|
21
21
|
import { randomBytes } from 'node:crypto';
|
|
22
22
|
import { dirname, join, resolve } from 'node:path';
|
|
23
|
-
import { importsAgents, renderBlock, START, END, upsertManagedFile } from './block.js';
|
|
23
|
+
import { importsAgents, hasManagedBlock, renderBlock, START, END, upsertManagedFile } from './block.js';
|
|
24
24
|
import { CLAUDE_LINK_REL, STORE_REL, VERSION_FILE, appendEntry, assertNotSymlink, assertWithinRoot, cliVersion, hashDir, moveToBackup, pruneDevOnly } from './store.js';
|
|
25
25
|
import { writeStamp } from './version-stamp.js';
|
|
26
26
|
import { bootstrapServers } from './bootstrap.js';
|
|
@@ -57,6 +57,12 @@ function relJoin(rel) {
|
|
|
57
57
|
function installClaudeAdapter(root, source, warnings) {
|
|
58
58
|
const claudeAbs = join(root, relJoin(CLAUDE_LINK_REL));
|
|
59
59
|
const storeAbs = join(root, relJoin(STORE_REL));
|
|
60
|
+
|
|
61
|
+
// Escape check FIRST — before anything is removed or created: a symlinked
|
|
62
|
+
// .claude/.claude/skills parent must not redirect the adapter (nor the
|
|
63
|
+
// removal of a legacy copy) outside the project root.
|
|
64
|
+
assertWithinRoot(root, dirname(claudeAbs));
|
|
65
|
+
|
|
60
66
|
const st = lstatSafe(claudeAbs);
|
|
61
67
|
let migrating = false;
|
|
62
68
|
|
|
@@ -81,9 +87,6 @@ function installClaudeAdapter(root, source, warnings) {
|
|
|
81
87
|
return 'kept-divergent';
|
|
82
88
|
}
|
|
83
89
|
|
|
84
|
-
// Escape check: a symlinked .claude/.claude/skills parent must not redirect
|
|
85
|
-
// the adapter creation outside the project root.
|
|
86
|
-
assertWithinRoot(root, claudeAbs);
|
|
87
90
|
try {
|
|
88
91
|
mkdirSync(dirname(claudeAbs), { recursive: true });
|
|
89
92
|
symlinkSync('../../.agents/skills/archgen', claudeAbs);
|
|
@@ -134,21 +137,44 @@ export function initProject(projectDir, packageRoot, opts = {}) {
|
|
|
134
137
|
|
|
135
138
|
// 1. Canonical store — fingerprint before mutating (ownership check).
|
|
136
139
|
const prev = lstatSafe(storeAbs);
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
&& hashDir(storeAbs, { ignore: [VERSION_FILE] }) === hashDir(source);
|
|
140
|
-
if (!identical) backups.push(moveToBackup(root, STORE_REL));
|
|
141
|
-
}
|
|
140
|
+
const identical = !!(prev && prev.isDirectory() && !prev.isSymbolicLink()
|
|
141
|
+
&& hashDir(storeAbs, { ignore: [VERSION_FILE] }) === hashDir(source));
|
|
142
142
|
|
|
143
|
-
// Atomic install: build in a temp sibling dir, then
|
|
143
|
+
// Atomic install: build in a temp sibling dir, then swap it into place.
|
|
144
|
+
// The current store is renamed aside (identical) or moved into the backup
|
|
145
|
+
// vault (divergent) so the swap can be undone: a failed swap puts the old
|
|
146
|
+
// store back, and a failed copy never leaves an orphan temp directory.
|
|
144
147
|
const skillsParent = dirname(storeAbs);
|
|
145
148
|
mkdirSync(skillsParent, { recursive: true });
|
|
146
149
|
const tmp = join(skillsParent, '.archgen-tmp-' + randomBytes(6).toString('hex'));
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
150
|
+
const aside = join(skillsParent, '.archgen-old-' + randomBytes(6).toString('hex'));
|
|
151
|
+
let asideUsed = false;
|
|
152
|
+
try {
|
|
153
|
+
cpSync(source, tmp, { recursive: true });
|
|
154
|
+
pruneDevOnly(tmp); // dev-only artifacts (scripts/test) never reach the installed store
|
|
155
|
+
writeStamp(tmp, version);
|
|
156
|
+
if (lstatSafe(storeAbs)) {
|
|
157
|
+
if (identical) {
|
|
158
|
+
renameSync(storeAbs, aside);
|
|
159
|
+
asideUsed = true;
|
|
160
|
+
} else {
|
|
161
|
+
backups.push(moveToBackup(root, STORE_REL));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
renameSync(tmp, storeAbs);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
if (asideUsed) {
|
|
167
|
+
// Put the previous store back: a failed swap must never leave the
|
|
168
|
+
// project without a store.
|
|
169
|
+
try {
|
|
170
|
+
renameSync(aside, storeAbs);
|
|
171
|
+
asideUsed = false;
|
|
172
|
+
} catch { /* keep it aside rather than deleting the user's data */ }
|
|
173
|
+
}
|
|
174
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
175
|
+
throw e;
|
|
176
|
+
}
|
|
177
|
+
if (asideUsed) rmSync(aside, { recursive: true, force: true });
|
|
152
178
|
appendEntry(root, 'dir', STORE_REL);
|
|
153
179
|
|
|
154
180
|
// 2. Claude adapter — relative symlink, never a second copy.
|
|
@@ -159,7 +185,10 @@ export function initProject(projectDir, packageRoot, opts = {}) {
|
|
|
159
185
|
const createdContextFiles = [];
|
|
160
186
|
for (const name of CONTEXT_FILES) {
|
|
161
187
|
const abs = join(root, name);
|
|
162
|
-
|
|
188
|
+
// A hand-written `@AGENTS.md` import is the user's — leave it alone. A
|
|
189
|
+
// managed block is ours: refresh it so its provenance stamp tracks the
|
|
190
|
+
// running CLI instead of freezing at the first-ever install.
|
|
191
|
+
if (name === 'CLAUDE.md' && !hasManagedBlock(abs) && importsAgents(abs)) {
|
|
163
192
|
warnings.push('CLAUDE.md already imports @AGENTS.md - skipped (already-imports)');
|
|
164
193
|
continue;
|
|
165
194
|
}
|
package/lib/install.js
CHANGED
|
@@ -180,7 +180,7 @@ export function installGlobal(opts = {}) {
|
|
|
180
180
|
noMcp: opts.mcp === false,
|
|
181
181
|
});
|
|
182
182
|
if (!r.ok) {
|
|
183
|
-
rows.push(['WARN', '-', 'server bootstrap incomplete — run: npx archgen-mcp bootstrap --location global --yes']);
|
|
183
|
+
rows.push(['WARN', '-', 'server bootstrap incomplete' + (r.detail ? ` (${r.detail})` : '') + ' — run: npx archgen-mcp bootstrap --location global --yes']);
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
186
|
|
|
@@ -196,17 +196,70 @@ function stampNote(rows, dest, write) {
|
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
-
|
|
199
|
+
/**
|
|
200
|
+
* The exact destinations a global install may create.
|
|
201
|
+
*
|
|
202
|
+
* A manifest entry is acted on ONLY when it names one of them. A plain
|
|
203
|
+
* suffix test (`endsWith('skills/archgen')`) would let any path with that
|
|
204
|
+
* ending be removed — including paths outside the known harness dirs.
|
|
205
|
+
*/
|
|
206
|
+
function allowedGlobalDestinations(home) {
|
|
207
|
+
const set = new Set(globalTargets(home).map((d) => resolve(join(d, 'archgen'))));
|
|
208
|
+
set.add(resolve(join(process.cwd(), '.github', 'skills', 'archgen')));
|
|
209
|
+
return set;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Move an edited copy-mode install into the global backup vault before it is
|
|
214
|
+
* removed: the uninstall must never be the last place those edits exist.
|
|
215
|
+
* An install that still matches the packaged skill needs no backup.
|
|
216
|
+
*/
|
|
217
|
+
function backupEditedCopy(dest, packageRoot, backups) {
|
|
218
|
+
let identical = false;
|
|
219
|
+
try {
|
|
220
|
+
identical = hashDir(dest) === hashDir(resolveSkillSource(packageRoot));
|
|
221
|
+
} catch {
|
|
222
|
+
identical = false; // unverifiable -> preserve it
|
|
223
|
+
}
|
|
224
|
+
if (identical) return;
|
|
225
|
+
const tdir = dirname(dest);
|
|
226
|
+
const loc = moveToBackupInto(tdir, 'archgen', GLOBAL_BACKUP_REL, nextBackupTs(tdir));
|
|
227
|
+
backups.push(join(tdir, ...loc.split('/')));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Remove the globally-installed copies recorded in the manifest.
|
|
232
|
+
*
|
|
233
|
+
* Only entries that name a known harness destination are removed. An entry
|
|
234
|
+
* that cannot be verified is SKIPPED and KEPT in the manifest — the manifest
|
|
235
|
+
* is only removed when nothing was left behind.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} home home directory holding the manifest
|
|
238
|
+
* @param {{packageRoot?: string}} opts
|
|
239
|
+
*/
|
|
240
|
+
export function uninstallGlobal(home = homedir(), opts = {}) {
|
|
200
241
|
const manifest = join(home, MANIFEST_NAME);
|
|
201
242
|
if (!existsSync(manifest)) return { removed: 0, failed: 0, noop: true };
|
|
243
|
+
const packageRoot = opts.packageRoot ?? PACKAGE_ROOT;
|
|
244
|
+
const allowed = allowedGlobalDestinations(home);
|
|
202
245
|
let removed = 0, failed = 0;
|
|
246
|
+
const kept = [];
|
|
247
|
+
const backups = [];
|
|
203
248
|
for (const line of readFileSync(manifest, 'utf8').split('\n')) {
|
|
204
249
|
if (!line.trim()) continue;
|
|
205
250
|
const [kind, p] = line.split('\t');
|
|
206
|
-
if (!p || !
|
|
207
|
-
try {
|
|
208
|
-
|
|
251
|
+
if (!p || !allowed.has(resolve(p))) { failed++; kept.push(line); continue; } // safety guard
|
|
252
|
+
try {
|
|
253
|
+
if (kind === 'copy') backupEditedCopy(p, packageRoot, backups);
|
|
254
|
+
rmSync(p, { force: true, recursive: true }); removed++;
|
|
255
|
+
} catch { failed++; kept.push(line); }
|
|
256
|
+
}
|
|
257
|
+
if (kept.length === 0) {
|
|
258
|
+
try { rmSync(manifest, { force: true }); } catch { failed++; }
|
|
259
|
+
} else {
|
|
260
|
+
// The skipped entries stay recorded: the manifest is the only thing that
|
|
261
|
+
// knows they were never removed.
|
|
262
|
+
try { writeFileSync(manifest, kept.join('\n') + '\n'); } catch { failed++; }
|
|
209
263
|
}
|
|
210
|
-
|
|
211
|
-
return { removed, failed, noop: false };
|
|
264
|
+
return { removed, failed, noop: false, backups };
|
|
212
265
|
}
|
package/lib/mcp-server.js
CHANGED
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
// NEVER FATAL: no npm, no network, a read-only config, or a missing MCP
|
|
10
10
|
// server must still leave a working skill install. Failures are REPORTED,
|
|
11
11
|
// never thrown — the same posture bootstrapCodegraph has always had.
|
|
12
|
-
import { existsSync } from 'node:fs';
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
13
13
|
import { spawnSync } from 'node:child_process';
|
|
14
14
|
import { homedir } from 'node:os';
|
|
15
|
-
import { join, resolve } from 'node:path';
|
|
15
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
16
16
|
|
|
17
17
|
export const MCP_PACKAGE = 'archgen-mcp';
|
|
18
18
|
|
|
@@ -72,6 +72,89 @@ export function whichOnPath(bin, env = process.env) {
|
|
|
72
72
|
return null;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Quote one argument for cmd.exe.
|
|
77
|
+
*
|
|
78
|
+
* cmd.exe parses `&`, `|`, `<`, `>`, `^` and whitespace as syntax even in an
|
|
79
|
+
* unquoted argument — an unquoted project path is split on spaces and its
|
|
80
|
+
* `&`/`|` are executed. Double quotes neutralize all of them. `%` is the one
|
|
81
|
+
* character cmd expands even inside quotes, so an argument carrying one is
|
|
82
|
+
* refused rather than silently rewritten.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} arg
|
|
85
|
+
* @returns {string} the quoted argument
|
|
86
|
+
*/
|
|
87
|
+
export function quoteWindowsArg(arg) {
|
|
88
|
+
const s = String(arg);
|
|
89
|
+
if (s === '') return '""';
|
|
90
|
+
if (/["%]/.test(s)) {
|
|
91
|
+
throw new Error(`refusing to pass an unsafe character through the Windows shell: ${JSON.stringify(s)}`);
|
|
92
|
+
}
|
|
93
|
+
return '"' + s + '"';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Find the JS entry point a Windows npm shim launches.
|
|
98
|
+
*
|
|
99
|
+
* npm writes `<name>.cmd` beside the package it points at, and the package's
|
|
100
|
+
* package.json names the real script. Resolving it lets the script run with
|
|
101
|
+
* the current node binary — no shell, no quoting, nothing to inject.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} cmdPath path of the .cmd/.bat shim
|
|
104
|
+
* @returns {string|null} absolute path of the JS entry point
|
|
105
|
+
*/
|
|
106
|
+
function resolveShimEntry(cmdPath) {
|
|
107
|
+
const name = basename(cmdPath).replace(/\.(cmd|bat)$/i, '');
|
|
108
|
+
const dir = dirname(cmdPath);
|
|
109
|
+
const candidates = [
|
|
110
|
+
join(dir, 'node_modules', MCP_PACKAGE), // global npm prefix
|
|
111
|
+
join(dir, '..', MCP_PACKAGE), // node_modules/.bin -> node_modules/<pkg>
|
|
112
|
+
join(dir, '..', 'node_modules', MCP_PACKAGE), // prefix/lib layout
|
|
113
|
+
];
|
|
114
|
+
for (const pkgDir of candidates) {
|
|
115
|
+
let pkg;
|
|
116
|
+
try {
|
|
117
|
+
pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
|
|
118
|
+
} catch {
|
|
119
|
+
continue; // not this layout
|
|
120
|
+
}
|
|
121
|
+
const bin = pkg && pkg.bin;
|
|
122
|
+
const rel = typeof bin === 'string' ? bin : bin && typeof bin === 'object' ? bin[name] : null;
|
|
123
|
+
if (!rel) continue;
|
|
124
|
+
const js = resolve(pkgDir, rel);
|
|
125
|
+
if (existsSync(js)) return js;
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Build the spawn invocation for the MCP server.
|
|
132
|
+
*
|
|
133
|
+
* On Windows an npm-installed binary is a `.cmd` shim that only cmd.exe can
|
|
134
|
+
* run — and cmd.exe parses unquoted arguments as shell syntax. The shim's JS
|
|
135
|
+
* entry point is preferred (run with the current node binary, no shell at
|
|
136
|
+
* all); when it cannot be resolved, every argument is quoted for cmd.exe.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} command executable to run
|
|
139
|
+
* @param {string[]} args arguments
|
|
140
|
+
* @param {string} [platform] process.platform (injectable for tests)
|
|
141
|
+
* @param {Record<string, string|undefined>} [env] environment used for lookups
|
|
142
|
+
* @returns {{command: string, args: string[], shell: boolean}}
|
|
143
|
+
*/
|
|
144
|
+
export function mcpInvocation(command, args, platform = process.platform, env = process.env) {
|
|
145
|
+
if (platform !== 'win32') return { command, args, shell: false };
|
|
146
|
+
let cmd = command;
|
|
147
|
+
if (!/\.(cmd|bat)$/i.test(cmd) && !/[\\/]/.test(cmd)) {
|
|
148
|
+
// A bare name (e.g. 'npx') resolves to a .cmd shim on Windows.
|
|
149
|
+
const shim = whichOnPath(cmd + '.cmd', env);
|
|
150
|
+
if (shim) cmd = shim;
|
|
151
|
+
}
|
|
152
|
+
if (!/\.(cmd|bat)$/i.test(cmd)) return { command, args, shell: false };
|
|
153
|
+
const js = resolveShimEntry(cmd);
|
|
154
|
+
if (js) return { command: process.execPath, args: [js, ...args], shell: false };
|
|
155
|
+
return { command: quoteWindowsArg(cmd), args: args.map(quoteWindowsArg), shell: true };
|
|
156
|
+
}
|
|
157
|
+
|
|
75
158
|
/**
|
|
76
159
|
* Run one `archgen-mcp` subcommand and return its JSON output.
|
|
77
160
|
*
|
|
@@ -82,17 +165,16 @@ export function whichOnPath(bin, env = process.env) {
|
|
|
82
165
|
export function runMcp(args, opts = {}) {
|
|
83
166
|
const { projectRoot = process.cwd(), timeout = 600000, env = process.env } = opts;
|
|
84
167
|
const resolved = resolveMcpBinary({ projectRoot, env });
|
|
168
|
+
const inv = mcpInvocation(resolved.command, [...resolved.args, ...args], process.platform, env);
|
|
85
169
|
|
|
86
170
|
let r;
|
|
87
171
|
try {
|
|
88
|
-
r = spawnSync(
|
|
172
|
+
r = spawnSync(inv.command, inv.args, {
|
|
89
173
|
encoding: 'utf8',
|
|
90
174
|
timeout,
|
|
91
175
|
env,
|
|
92
176
|
cwd: projectRoot,
|
|
93
|
-
shell:
|
|
94
|
-
// On Windows the npx shim is a .cmd file, which needs a shell.
|
|
95
|
-
...(process.platform === 'win32' && /\.(cmd|bat)$/i.test(resolved.command) ? { shell: true } : {}),
|
|
177
|
+
shell: inv.shell,
|
|
96
178
|
});
|
|
97
179
|
} catch (e) {
|
|
98
180
|
return { ok: false, code: null, data: null, stdout: '', stderr: '', detail: String(e?.message ?? e) };
|