archgen-skill 0.0.6 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -10
- package/bin/archgen.mjs +135 -13
- package/lib/block.js +51 -10
- package/lib/bootstrap.js +33 -7
- package/lib/config.js +3 -2
- package/lib/doctor.js +159 -82
- package/lib/global-server.js +154 -0
- package/lib/init.js +53 -17
- package/lib/install.js +60 -7
- package/lib/mcp-server.js +149 -27
- package/lib/migrations/001-stamp-provenance.mjs +15 -8
- package/lib/migrations/index.mjs +19 -8
- package/lib/restore.js +52 -16
- package/lib/spawn-npm.js +63 -8
- package/lib/store.js +100 -23
- package/lib/uninstall-project.js +47 -9
- package/lib/version-stamp.js +2 -2
- package/lib/version.js +9 -1
- package/package.json +3 -3
- package/vendor/skills/archgen/SKILL.md +156 -375
- package/vendor/skills/archgen/archgen.config.json +1 -1
- package/vendor/skills/archgen/references/code-standards.md +28 -44
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`);
|
|
@@ -286,9 +363,9 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
|
286
363
|
|
|
287
364
|
// 7. Version-stamp inventory — every archgen skill tree visible from this
|
|
288
365
|
// project/machine, one line each, for tools that probe
|
|
289
|
-
// <skillRoot>/.archgen-version (the
|
|
290
|
-
//
|
|
291
|
-
// section 2, never global installs.
|
|
366
|
+
// <skillRoot>/.archgen-version (the MCP server reads it to report the
|
|
367
|
+
// installed skill's version). Report-only: doctor repairs the project
|
|
368
|
+
// stamp in section 2, never global installs.
|
|
292
369
|
const seen = new Set();
|
|
293
370
|
const installs = [];
|
|
294
371
|
const noteInstall = (p) => {
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// global-server.js — ONE archgen MCP server for every harness, kept current.
|
|
2
|
+
//
|
|
3
|
+
// WHY A GLOBAL INSTALL.
|
|
4
|
+
//
|
|
5
|
+
// The harness configs name the server's executable by ABSOLUTE PATH, so the
|
|
6
|
+
// path has to keep working. The npx fallback points into npm's npx CACHE
|
|
7
|
+
// (~/.npm/_npx/<hash>/…), which `npm cache clean --force` removes — and the
|
|
8
|
+
// harness then fails to start a server that was wired minutes earlier.
|
|
9
|
+
//
|
|
10
|
+
// A global install (`npm install -g archgen-mcp`) puts the binary in the
|
|
11
|
+
// npm prefix's bin directory: one copy, shared by every harness, at a path
|
|
12
|
+
// that does not move when the registry publishes a new version.
|
|
13
|
+
//
|
|
14
|
+
// THE UPGRADE.
|
|
15
|
+
//
|
|
16
|
+
// The CLI and the server are released TOGETHER, so the CLI knows exactly
|
|
17
|
+
// which server version it was tested against. An installed server that is
|
|
18
|
+
// OLDER is refreshed to that version; one that is NEWER (a dev build, or a
|
|
19
|
+
// version published after this CLI) is left alone — downgrading it would
|
|
20
|
+
// break a working setup.
|
|
21
|
+
//
|
|
22
|
+
// The install is BEST EFFORT. A machine without a writable npm prefix, an
|
|
23
|
+
// offline machine, or a user who does not want a global package must still
|
|
24
|
+
// get a working skill install: the npx fallback covers all of them, and the
|
|
25
|
+
// OLD server keeps working until it is replaced.
|
|
26
|
+
import { spawnSync } from 'node:child_process';
|
|
27
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
28
|
+
import { dirname, join } from 'node:path';
|
|
29
|
+
import { npmInvocation, whichOnPath } from './spawn-npm.js';
|
|
30
|
+
import { compareSemver } from './version.js';
|
|
31
|
+
|
|
32
|
+
export const MCP_PACKAGE = 'archgen-mcp';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The version of the server this CLI ships with.
|
|
36
|
+
*
|
|
37
|
+
* The CLI and the server are released together, so the installer can ask for
|
|
38
|
+
* exactly the version it was tested against instead of "whatever is latest".
|
|
39
|
+
*
|
|
40
|
+
* @param {string} packageRoot the CLI's own package root
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
export function serverVersion(packageRoot) {
|
|
44
|
+
try {
|
|
45
|
+
const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'));
|
|
46
|
+
return typeof pkg.version === 'string' ? pkg.version : 'latest';
|
|
47
|
+
} catch {
|
|
48
|
+
return 'latest';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The version of an INSTALLED server, read from its own package.
|
|
54
|
+
*
|
|
55
|
+
* The binary is a symlink into the global package's bin directory, so the
|
|
56
|
+
* package root is one level above the script's directory.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} bin the resolved executable
|
|
59
|
+
* @returns {string|null} the version, or null when it cannot be read
|
|
60
|
+
*/
|
|
61
|
+
export function installedServerVersion(bin) {
|
|
62
|
+
try {
|
|
63
|
+
const real = realpathSync(bin);
|
|
64
|
+
const pkg = JSON.parse(readFileSync(join(dirname(real), '..', 'package.json'), 'utf8'));
|
|
65
|
+
return typeof pkg.version === 'string' ? pkg.version : null;
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Make sure `archgen-mcp` is installed globally, at the right version.
|
|
73
|
+
*
|
|
74
|
+
* @param {{env?: Record<string, string|undefined>, version?: string}} opts
|
|
75
|
+
* @returns {{installed: boolean, bin: string|null, upgraded: boolean,
|
|
76
|
+
* note: string, detail?: string}}
|
|
77
|
+
*/
|
|
78
|
+
export function ensureGlobalServer(opts = {}) {
|
|
79
|
+
const { env = process.env, version = 'latest' } = opts;
|
|
80
|
+
|
|
81
|
+
const onPath = whichOnPath(MCP_PACKAGE, env);
|
|
82
|
+
if (onPath) {
|
|
83
|
+
const installed = installedServerVersion(onPath);
|
|
84
|
+
if (installed === version) {
|
|
85
|
+
return {
|
|
86
|
+
installed: true,
|
|
87
|
+
bin: onPath,
|
|
88
|
+
upgraded: false,
|
|
89
|
+
note: `already installed at ${onPath} (v${installed})`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (installed && version !== 'latest' && compareSemver(installed, version) > 0) {
|
|
93
|
+
// A newer server is already there — a dev build, or a release published
|
|
94
|
+
// after this CLI. Downgrading it would break a working setup.
|
|
95
|
+
return {
|
|
96
|
+
installed: true,
|
|
97
|
+
bin: onPath,
|
|
98
|
+
upgraded: false,
|
|
99
|
+
note: `installed v${installed} is newer than this CLI's v${version} — left as it is`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const refreshed = installGlobal(version, env);
|
|
103
|
+
if (refreshed.installed) {
|
|
104
|
+
return {
|
|
105
|
+
installed: true,
|
|
106
|
+
bin: refreshed.bin,
|
|
107
|
+
upgraded: true,
|
|
108
|
+
note: `upgraded the archgen MCP server to v${version} at ${refreshed.bin}`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
// The upgrade failed — the OLD server still works, so keep it and say so.
|
|
112
|
+
return {
|
|
113
|
+
installed: true,
|
|
114
|
+
bin: onPath,
|
|
115
|
+
upgraded: false,
|
|
116
|
+
note: `could not upgrade the archgen MCP server to v${version} — the installed v${installed} stays`,
|
|
117
|
+
detail: refreshed.detail,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const fresh = installGlobal(version, env);
|
|
122
|
+
return {
|
|
123
|
+
installed: fresh.installed,
|
|
124
|
+
bin: fresh.bin,
|
|
125
|
+
upgraded: false,
|
|
126
|
+
note: fresh.installed
|
|
127
|
+
? `installed the archgen MCP server globally at ${fresh.bin}`
|
|
128
|
+
: `could not install the archgen MCP server globally — the npx fallback will be used`,
|
|
129
|
+
detail: fresh.detail,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** `npm install -g archgen-mcp@<version>`. */
|
|
134
|
+
function installGlobal(version, env) {
|
|
135
|
+
const spec = version === 'latest' ? MCP_PACKAGE : `${MCP_PACKAGE}@${version}`;
|
|
136
|
+
const inv = npmInvocation(['install', '-g', spec], env);
|
|
137
|
+
const r = spawnSync(inv.command, inv.args, {
|
|
138
|
+
encoding: 'utf8',
|
|
139
|
+
timeout: 300000,
|
|
140
|
+
env,
|
|
141
|
+
shell: inv.shell,
|
|
142
|
+
});
|
|
143
|
+
if (r.status !== 0) {
|
|
144
|
+
const detail = (r.stderr || r.stdout || '').trim().split('\n').pop();
|
|
145
|
+
return { installed: false, bin: null, detail: detail || `npm exited ${r.status}` };
|
|
146
|
+
}
|
|
147
|
+
// npm succeeded — but the install only COUNTS if the binary is now
|
|
148
|
+
// findable. A prefix that is not on PATH, or a package that shipped no
|
|
149
|
+
// bin, would otherwise be reported as a success.
|
|
150
|
+
const bin = whichOnPath(MCP_PACKAGE, env);
|
|
151
|
+
return bin
|
|
152
|
+
? { installed: true, bin, detail: undefined }
|
|
153
|
+
: { installed: false, bin: null, detail: 'npm reported success but the binary is not on PATH' };
|
|
154
|
+
}
|
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
|
}
|
|
@@ -176,10 +205,17 @@ export function initProject(projectDir, packageRoot, opts = {}) {
|
|
|
176
205
|
// harness AND ensure CodeGraph (its CLI, its MCP entry, the project
|
|
177
206
|
// index). Non-fatal by design: an offline machine still gets the skill
|
|
178
207
|
// installed.
|
|
208
|
+
//
|
|
209
|
+
// The wiring is GLOBAL by default: the server is one binary for every
|
|
210
|
+
// harness, and the harness configs live in the user's home. A project
|
|
211
|
+
// that carries its own `.mcp.json`, `.codex/` and `.gemini/` is a
|
|
212
|
+
// project that has to be re-wired in every clone. `--location project`
|
|
213
|
+
// opts into the per-project wiring.
|
|
179
214
|
if (codegraphAllowed || mcpAllowed) {
|
|
180
215
|
const r = bootstrapServers({
|
|
181
216
|
projectDir: root,
|
|
182
|
-
|
|
217
|
+
packageRoot: packageRoot,
|
|
218
|
+
location: opts.location ?? 'global',
|
|
183
219
|
log,
|
|
184
220
|
env: opts.env,
|
|
185
221
|
noCodegraph: !codegraphAllowed,
|