archgen-skill 0.0.5 → 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 +20 -7
- package/bin/archgen.mjs +142 -15
- package/lib/block.js +52 -11
- package/lib/bootstrap.js +107 -0
- package/lib/config.js +3 -2
- package/lib/doctor.js +197 -77
- package/lib/init.js +77 -19
- package/lib/install.js +79 -7
- package/lib/mcp-server.js +241 -0
- 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 +99 -0
- package/lib/store.js +175 -28
- package/lib/uninstall-project.js +38 -7
- package/lib/version.js +20 -4
- package/package.json +3 -3
- package/vendor/skills/archgen/SKILL.md +363 -213
- package/vendor/skills/archgen/archgen.config.json +1 -1
- package/vendor/skills/archgen/references/code-standards.md +2 -2
- package/vendor/skills/archgen/assets/mcp-template.json +0 -42
- package/vendor/skills/archgen/assets/skill-registry.json +0 -24
- package/vendor/skills/archgen/references/artifact-templates.md +0 -406
- package/vendor/skills/archgen/references/interview.md +0 -275
- package/vendor/skills/archgen/references/mcp-and-skills.md +0 -117
- package/vendor/skills/archgen/references/orchestration.md +0 -292
- package/vendor/skills/archgen/references/platforms.md +0 -138
- package/vendor/skills/archgen/scripts/doc-index.mjs +0 -457
- package/vendor/skills/archgen/scripts/impact.mjs +0 -77
- package/vendor/skills/archgen/scripts/lib/graph.mjs +0 -222
- package/vendor/skills/archgen/scripts/lib/plan-coverage.mjs +0 -32
- package/vendor/skills/archgen/scripts/lib/scope.mjs +0 -65
- package/vendor/skills/archgen/scripts/lib/yaml.mjs +0 -492
- package/vendor/skills/archgen/scripts/next-tasks.mjs +0 -55
- package/vendor/skills/archgen/scripts/plan-graph.mjs +0 -329
- package/vendor/skills/archgen/scripts/set-status.mjs +0 -82
- package/vendor/skills/archgen/scripts/update-agents.mjs +0 -213
- package/vendor/skills/archgen/scripts/validate.mjs +0 -87
- package/vendor/skills/archgen/scripts/verify-plan.mjs +0 -70
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,9 +22,10 @@ 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, hashDir, loadManifest, pruneDevOnly, 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
|
+
import { mcpDoctor } from './mcp-server.js';
|
|
28
29
|
|
|
29
30
|
function lstatSafe(p) {
|
|
30
31
|
try { return lstatSync(p); } catch { return null; }
|
|
@@ -47,8 +48,7 @@ function relJoin(rel) {
|
|
|
47
48
|
* mutations; home overrides the homedir scanned for global installs (tests)
|
|
48
49
|
* @returns {{root: string, checks: {status: 'OK'|'FIXED'|'WOULD-FIX'|'WARN'|'FAIL', msg: string}[],
|
|
49
50
|
* tally: Record<string, number>, failures: number}} `failures` counts
|
|
50
|
-
*
|
|
51
|
-
* but never fail the run.
|
|
51
|
+
* the FAIL rows — a broken state a human must fix always fails the run.
|
|
52
52
|
*/
|
|
53
53
|
export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
54
54
|
const root = resolve(projectDir);
|
|
@@ -56,16 +56,20 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
|
56
56
|
const homeDir = opts.home ?? homedir();
|
|
57
57
|
const version = cliVersion(packageRoot);
|
|
58
58
|
const checks = [];
|
|
59
|
-
|
|
60
|
-
|
|
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
|
+
};
|
|
61
66
|
// Run one repair step; a throw becomes a FAIL row instead of aborting the
|
|
62
|
-
// whole doctor pass
|
|
67
|
+
// whole doctor pass.
|
|
63
68
|
const attempt = (fn, okStatus, okMsg, failLabel) => {
|
|
64
69
|
try {
|
|
65
70
|
fn();
|
|
66
71
|
add(okStatus, okMsg);
|
|
67
72
|
} catch (e) {
|
|
68
|
-
opFailures++;
|
|
69
73
|
add('FAIL', `${failLabel}: ${e.message} - fix manually`);
|
|
70
74
|
}
|
|
71
75
|
};
|
|
@@ -83,7 +87,39 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
|
83
87
|
if (!existsSync(join(storeAbs, 'SKILL.md'))) {
|
|
84
88
|
add('FAIL', 'store .agents/skills/archgen missing or incomplete - run `archgen init`');
|
|
85
89
|
} else {
|
|
86
|
-
|
|
90
|
+
// MIGRATION: the skill no longer ships code. An install carried over from
|
|
91
|
+
// the pre-MCP layout still has a scripts/ tree — dead weight that would
|
|
92
|
+
// otherwise read as "diverges from packaged skill (customized?)".
|
|
93
|
+
// Pruned BEFORE the divergence check so an upgrade is not misreported.
|
|
94
|
+
const packaged = (() => {
|
|
95
|
+
try { return resolveSkillSource(packageRoot); } catch { return null; }
|
|
96
|
+
})();
|
|
97
|
+
if (packaged) {
|
|
98
|
+
// --check reports WITHOUT mutating; repair mode prunes.
|
|
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
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const missingCore = ['references'].filter((d) => !existsSync(join(storeAbs, d)));
|
|
87
123
|
if (missingCore.length > 0) {
|
|
88
124
|
// Deferred so the managed-block FAIL rows below stay FIRST: existing
|
|
89
125
|
// consumers read checks.find(FAIL) expecting the marker verdict.
|
|
@@ -109,7 +145,12 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
|
109
145
|
if (checkOnly) {
|
|
110
146
|
add('WOULD-FIX', `store carries dev-only ${devOnlyPresent.join(', ')} -> pruned`);
|
|
111
147
|
} else {
|
|
112
|
-
|
|
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',
|
|
113
154
|
`dev-only ${devOnlyPresent.join(', ')} pruned from store`, 'cannot prune dev-only artifacts');
|
|
114
155
|
}
|
|
115
156
|
}
|
|
@@ -137,6 +178,10 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
|
137
178
|
return;
|
|
138
179
|
}
|
|
139
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));
|
|
140
185
|
try { unlinkSync(claudeAbs); } catch { /* absent */ }
|
|
141
186
|
mkdirSync(dirname(claudeAbs), { recursive: true });
|
|
142
187
|
symlinkSync('../../.agents/skills/archgen', claudeAbs);
|
|
@@ -169,60 +214,66 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
|
169
214
|
'AGENTS.md created with managed block', 'cannot create AGENTS.md managed block');
|
|
170
215
|
}
|
|
171
216
|
} else {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
} else if (starts > 1 || ends > 1) {
|
|
182
|
-
add('FAIL', `AGENTS.md has ${starts} start / ${ends} end markers - fix manually`);
|
|
183
|
-
} else if (starts === 0 || ends === 0 || raw.indexOf(START) > raw.indexOf(END)) {
|
|
184
|
-
// Orphan/inverted markers: repairing through upsertBlock would throw, so
|
|
185
|
-
// classify as FAIL instead of attempting the write.
|
|
186
|
-
add('FAIL', 'AGENTS.md has broken archgen markers - fix manually');
|
|
187
|
-
} else {
|
|
188
|
-
// Block-format currency: an unversioned or older-stamped managed block is
|
|
189
|
-
// upgraded in place through the normal upsert path (everything outside the
|
|
190
|
-
// markers survives byte-for-byte by design). Newer stamps are left alone.
|
|
191
|
-
const det = detectBlockVersion(raw);
|
|
192
|
-
const parsed = det.version ? parseSemver(det.version) : null;
|
|
193
|
-
const stale = !det.present || !parsed || compareSemver(det.version, version) < 0;
|
|
194
|
-
if (stale) {
|
|
195
|
-
const from = det.version ? 'v' + det.version : 'unversioned';
|
|
196
|
-
if (checkOnly) {
|
|
197
|
-
add('WOULD-FIX', `WOULD-UPGRADE: AGENTS.md block ${from} -> v${version}`);
|
|
198
|
-
} else {
|
|
199
|
-
let upgraded = false;
|
|
200
|
-
attempt(() => { upsertManagedFile(agentsAbs, renderBlock(STORE_REL).split('\n')); upgraded = true; },
|
|
201
|
-
'FIXED', `UPGRADED: AGENTS.md block ${from} -> v${version}`, 'cannot upgrade AGENTS.md block');
|
|
202
|
-
if (upgraded) raw = readFileSync(agentsAbs, 'utf8');
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
const fsCount = countOccurrences(raw, FEATURES_START);
|
|
206
|
-
const feCount = countOccurrences(raw, FEATURES_END);
|
|
207
|
-
const fsIdx = raw.indexOf(FEATURES_START);
|
|
208
|
-
const feIdx = raw.indexOf(FEATURES_END);
|
|
209
|
-
if (fsCount === 1 && feCount === 1 && fsIdx < feIdx) {
|
|
210
|
-
add('OK', 'AGENTS.md block + features registry present');
|
|
211
|
-
} else if (fsCount === 1 && feCount === 1) {
|
|
212
|
-
// Counts are right but ORDER is inverted (end before start): any
|
|
213
|
-
// registry write would throw (upsertFeaturesRegistry requires
|
|
214
|
-
// end > start), so flag the state instead of blessing it as OK.
|
|
215
|
-
if (checkOnly) add('WOULD-FIX', 'AGENTS.md archgen:features markers are reversed -> swap them');
|
|
216
|
-
else add('FAIL', 'AGENTS.md archgen:features markers are reversed - fix manually');
|
|
217
|
-
} else if (fsCount === 0 && feCount === 0) {
|
|
218
|
-
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');
|
|
219
225
|
else {
|
|
220
|
-
attempt(() =>
|
|
221
|
-
'AGENTS.md
|
|
226
|
+
attempt(() => upsertManagedFile(agentsAbs, renderBlock(STORE_REL).split('\n')), 'FIXED',
|
|
227
|
+
'AGENTS.md managed block added', 'cannot add AGENTS.md managed block');
|
|
222
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');
|
|
223
235
|
} else {
|
|
224
|
-
|
|
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
|
+
}
|
|
225
274
|
}
|
|
275
|
+
} catch (e) {
|
|
276
|
+
add('FAIL', 'cannot read AGENTS.md: ' + e.message + ' - fix manually');
|
|
226
277
|
}
|
|
227
278
|
}
|
|
228
279
|
|
|
@@ -234,18 +285,44 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
|
234
285
|
'CLAUDE.md created with @AGENTS.md bridge', 'cannot create CLAUDE.md bridge');
|
|
235
286
|
}
|
|
236
287
|
} else {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
+
}
|
|
249
326
|
}
|
|
250
327
|
}
|
|
251
328
|
|
|
@@ -256,8 +333,23 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
|
256
333
|
if (!manifest) {
|
|
257
334
|
if (existsSync(storeAbs)) add('WARN', `no install manifest at ${MANIFEST_REL} (re-run init to regenerate)`);
|
|
258
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`);
|
|
259
340
|
} else {
|
|
260
|
-
|
|
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
|
+
};
|
|
261
353
|
const stale = manifest.entries.filter((e) => !resolves(e));
|
|
262
354
|
if (stale.length === 0) {
|
|
263
355
|
add('OK', `manifest: all ${manifest.entries.length} entr${manifest.entries.length === 1 ? 'y' : 'ies'} resolve`);
|
|
@@ -296,7 +388,35 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
|
296
388
|
add(v ? 'OK' : 'WARN', `skill ${p}: ${slot}`);
|
|
297
389
|
}
|
|
298
390
|
|
|
391
|
+
// 8. MCP server wiring — the archgen MCP server is what actually gives the
|
|
392
|
+
// agent its tools. A skill with no MCP entry is a skill the agent cannot
|
|
393
|
+
// use, so this is a first-class check, not a footnote.
|
|
394
|
+
//
|
|
395
|
+
// READ-ONLY and NEVER FATAL: doctor must work on a machine with no npm, no
|
|
396
|
+
// network, and no MCP server installed. The MCP server is asked for its own
|
|
397
|
+
// report; if it cannot run, that is a WARN with the remediation command.
|
|
398
|
+
const mcp = mcpDoctor({ projectRoot: root, env: { ...process.env, HOME: homeDir } });
|
|
399
|
+
if (!mcp.report) {
|
|
400
|
+
add('WARN', `archgen MCP server not reachable${mcp.detail ? ` — ${mcp.detail}` : ''} — install with: npx archgen-mcp bootstrap --project . --yes`);
|
|
401
|
+
} else {
|
|
402
|
+
const rep = mcp.report;
|
|
403
|
+
add(
|
|
404
|
+
rep.skill ? 'OK' : 'WARN',
|
|
405
|
+
rep.skill
|
|
406
|
+
? `archgen MCP server v${rep.server} ready (skill ${rep.skill.version ?? 'unknown'})`
|
|
407
|
+
: 'archgen MCP server ready, but no skill was found',
|
|
408
|
+
);
|
|
409
|
+
const wired = (rep.mcpTargets ?? []).filter((t) => t.exists).length;
|
|
410
|
+
if (wired === 0) {
|
|
411
|
+
add('WARN', 'archgen MCP is not wired into any harness config — run: npx archgen-mcp bootstrap --project . --yes');
|
|
412
|
+
} else {
|
|
413
|
+
add('OK', `archgen MCP wiring present in ${wired} harness config(s)`);
|
|
414
|
+
}
|
|
415
|
+
if (rep.codegraph) add('OK', `codegraph ${rep.codegraph.version}`);
|
|
416
|
+
else add('WARN', 'codegraph not installed — run: npx archgen-mcp bootstrap --project . --yes');
|
|
417
|
+
}
|
|
418
|
+
|
|
299
419
|
const tally = { OK: 0, FIXED: 0, 'WOULD-FIX': 0, WARN: 0, FAIL: 0 };
|
|
300
420
|
for (const c of checks) tally[c.status]++;
|
|
301
|
-
return { root, checks, tally, failures
|
|
421
|
+
return { root, checks, tally, failures };
|
|
302
422
|
}
|
package/lib/init.js
CHANGED
|
@@ -17,12 +17,13 @@
|
|
|
17
17
|
// to a symlink; a customized one is left untouched with a warning;
|
|
18
18
|
// - installs are atomic: built in a temp sibling dir, then renamed.
|
|
19
19
|
|
|
20
|
-
import { cpSync, existsSync, lstatSync, mkdirSync,
|
|
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
|
+
import { bootstrapServers } from './bootstrap.js';
|
|
26
27
|
|
|
27
28
|
const CONTEXT_FILES = ['AGENTS.md', 'CLAUDE.md'];
|
|
28
29
|
|
|
@@ -56,6 +57,12 @@ function relJoin(rel) {
|
|
|
56
57
|
function installClaudeAdapter(root, source, warnings) {
|
|
57
58
|
const claudeAbs = join(root, relJoin(CLAUDE_LINK_REL));
|
|
58
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
|
+
|
|
59
66
|
const st = lstatSafe(claudeAbs);
|
|
60
67
|
let migrating = false;
|
|
61
68
|
|
|
@@ -80,9 +87,6 @@ function installClaudeAdapter(root, source, warnings) {
|
|
|
80
87
|
return 'kept-divergent';
|
|
81
88
|
}
|
|
82
89
|
|
|
83
|
-
// Escape check: a symlinked .claude/.claude/skills parent must not redirect
|
|
84
|
-
// the adapter creation outside the project root.
|
|
85
|
-
assertWithinRoot(root, claudeAbs);
|
|
86
90
|
try {
|
|
87
91
|
mkdirSync(dirname(claudeAbs), { recursive: true });
|
|
88
92
|
symlinkSync('../../.agents/skills/archgen', claudeAbs);
|
|
@@ -99,8 +103,11 @@ function installClaudeAdapter(root, source, warnings) {
|
|
|
99
103
|
* claude adapter, and write context pointers.
|
|
100
104
|
* @param {string} projectDir target repo root
|
|
101
105
|
* @param {string} packageRoot dir containing this CLI package
|
|
102
|
-
* @param {{force?: boolean
|
|
103
|
-
* copies are always auto-backed-up
|
|
106
|
+
* @param {{force?: boolean, codegraph?: boolean, mcp?: boolean, log?: (s: string) => void, env?: NodeJS.ProcessEnv}} opts
|
|
107
|
+
* --force proceeds automatically; divergent copies are always auto-backed-up
|
|
108
|
+
* (never refused, never destroyed). opts.codegraph === false opts OUT of the
|
|
109
|
+
* automatic CodeGraph bootstrap (install + MCP wiring + index build);
|
|
110
|
+
* opts.mcp === false opts OUT of the archgen MCP wiring.
|
|
104
111
|
* @returns {{storePath: string, claudeLink: 'created'|'existing'|'migrated'|'skipped'|'kept-divergent',
|
|
105
112
|
* backups: string[], contextFiles: string[], createdContextFiles: string[], warnings: string[]}}
|
|
106
113
|
*/
|
|
@@ -110,6 +117,9 @@ export function initProject(projectDir, packageRoot, opts = {}) {
|
|
|
110
117
|
// both plain and --force runs take the same safe path.
|
|
111
118
|
const force = opts.force === true;
|
|
112
119
|
void force;
|
|
120
|
+
const codegraphAllowed = opts.codegraph !== false;
|
|
121
|
+
const mcpAllowed = opts.mcp !== false;
|
|
122
|
+
const log = opts.log ?? console.log;
|
|
113
123
|
|
|
114
124
|
const root = resolve(projectDir);
|
|
115
125
|
const source = resolveSkillSource(packageRoot);
|
|
@@ -127,21 +137,44 @@ export function initProject(projectDir, packageRoot, opts = {}) {
|
|
|
127
137
|
|
|
128
138
|
// 1. Canonical store — fingerprint before mutating (ownership check).
|
|
129
139
|
const prev = lstatSafe(storeAbs);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
&& hashDir(storeAbs, { ignore: [VERSION_FILE] }) === hashDir(source);
|
|
133
|
-
if (!identical) backups.push(moveToBackup(root, STORE_REL));
|
|
134
|
-
}
|
|
140
|
+
const identical = !!(prev && prev.isDirectory() && !prev.isSymbolicLink()
|
|
141
|
+
&& hashDir(storeAbs, { ignore: [VERSION_FILE] }) === hashDir(source));
|
|
135
142
|
|
|
136
|
-
// 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.
|
|
137
147
|
const skillsParent = dirname(storeAbs);
|
|
138
148
|
mkdirSync(skillsParent, { recursive: true });
|
|
139
149
|
const tmp = join(skillsParent, '.archgen-tmp-' + randomBytes(6).toString('hex'));
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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 });
|
|
145
178
|
appendEntry(root, 'dir', STORE_REL);
|
|
146
179
|
|
|
147
180
|
// 2. Claude adapter — relative symlink, never a second copy.
|
|
@@ -152,7 +185,10 @@ export function initProject(projectDir, packageRoot, opts = {}) {
|
|
|
152
185
|
const createdContextFiles = [];
|
|
153
186
|
for (const name of CONTEXT_FILES) {
|
|
154
187
|
const abs = join(root, name);
|
|
155
|
-
|
|
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)) {
|
|
156
192
|
warnings.push('CLAUDE.md already imports @AGENTS.md - skipped (already-imports)');
|
|
157
193
|
continue;
|
|
158
194
|
}
|
|
@@ -165,5 +201,27 @@ export function initProject(projectDir, packageRoot, opts = {}) {
|
|
|
165
201
|
appendEntry(root, 'block', name);
|
|
166
202
|
}
|
|
167
203
|
|
|
204
|
+
// 4. Server bootstrap — wire the archgen MCP server into every detected
|
|
205
|
+
// harness AND ensure CodeGraph (its CLI, its MCP entry, the project
|
|
206
|
+
// index). Non-fatal by design: an offline machine still gets the skill
|
|
207
|
+
// installed.
|
|
208
|
+
if (codegraphAllowed || mcpAllowed) {
|
|
209
|
+
const r = bootstrapServers({
|
|
210
|
+
projectDir: root,
|
|
211
|
+
location: 'both',
|
|
212
|
+
log,
|
|
213
|
+
env: opts.env,
|
|
214
|
+
noCodegraph: !codegraphAllowed,
|
|
215
|
+
noMcp: !mcpAllowed,
|
|
216
|
+
});
|
|
217
|
+
if (!r.ok) {
|
|
218
|
+
warnings.push(`server bootstrap incomplete${r.detail ? ': ' + r.detail : ''} — install later with: npx archgen-mcp bootstrap --project . --yes`);
|
|
219
|
+
} else if (r.skipped) {
|
|
220
|
+
warnings.push(`server bootstrap skipped (${r.skipped})`);
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
warnings.push('server bootstrap skipped (--no-codegraph --no-mcp)');
|
|
224
|
+
}
|
|
225
|
+
|
|
168
226
|
return { storePath: storeAbs, claudeLink, backups, contextFiles, createdContextFiles, warnings };
|
|
169
227
|
}
|
package/lib/install.js
CHANGED
|
@@ -10,6 +10,7 @@ import { resolveSkillSource } from './init.js';
|
|
|
10
10
|
import { assertNotSymlink, assertWithinRoot, cliVersion, hashDir, moveToBackupInto, pruneDevOnly, refuseSymlinkMessage } from './store.js';
|
|
11
11
|
import { writeStamp } from './version-stamp.js';
|
|
12
12
|
import { loadConfig } from './config.js';
|
|
13
|
+
import { bootstrapServers } from './bootstrap.js';
|
|
13
14
|
|
|
14
15
|
// Module-anchored default: works from the npm package (cli/lib -> cli/vendor)
|
|
15
16
|
// AND from a monorepo checkout (packages/cli/lib -> ../../skill) without depending on cwd.
|
|
@@ -58,7 +59,7 @@ function nextBackupTs(tdir) {
|
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
/**
|
|
61
|
-
* @param {{copy?: boolean, home?: string, packageRoot?: string}} opts
|
|
62
|
+
* @param {{copy?: boolean, home?: string, packageRoot?: string, codegraph?: boolean, mcp?: boolean, log?: (s: string) => void, env?: NodeJS.ProcessEnv}} opts
|
|
62
63
|
* @returns {{rows: Array<[string, string, string]>, failures: number, manifest: string,
|
|
63
64
|
* backups: string[]}} rows are [STATUS, MODE, PATH]; `backups` lists
|
|
64
65
|
* absolute vault locations written by this run.
|
|
@@ -165,6 +166,24 @@ export function installGlobal(opts = {}) {
|
|
|
165
166
|
rows.push(['FAILED', '-', dest + ' (' + e.message + ')']);
|
|
166
167
|
}
|
|
167
168
|
}
|
|
169
|
+
// Server bootstrap — GLOBAL wiring only (project-local configs belong to
|
|
170
|
+
// `init`). Wires the archgen MCP server and CodeGraph into every DETECTED
|
|
171
|
+
// global harness. Non-fatal: an offline machine still gets the skill
|
|
172
|
+
// installed.
|
|
173
|
+
if (opts.codegraph !== false || opts.mcp !== false) {
|
|
174
|
+
const r = bootstrapServers({
|
|
175
|
+
projectDir: process.cwd(),
|
|
176
|
+
location: 'global',
|
|
177
|
+
log: opts.log ?? console.log,
|
|
178
|
+
env: opts.env,
|
|
179
|
+
noCodegraph: opts.codegraph === false,
|
|
180
|
+
noMcp: opts.mcp === false,
|
|
181
|
+
});
|
|
182
|
+
if (!r.ok) {
|
|
183
|
+
rows.push(['WARN', '-', 'server bootstrap incomplete' + (r.detail ? ` (${r.detail})` : '') + ' — run: npx archgen-mcp bootstrap --location global --yes']);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
168
187
|
return { rows, failures, manifest, backups };
|
|
169
188
|
}
|
|
170
189
|
|
|
@@ -177,17 +196,70 @@ function stampNote(rows, dest, write) {
|
|
|
177
196
|
}
|
|
178
197
|
}
|
|
179
198
|
|
|
180
|
-
|
|
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 = {}) {
|
|
181
241
|
const manifest = join(home, MANIFEST_NAME);
|
|
182
242
|
if (!existsSync(manifest)) return { removed: 0, failed: 0, noop: true };
|
|
243
|
+
const packageRoot = opts.packageRoot ?? PACKAGE_ROOT;
|
|
244
|
+
const allowed = allowedGlobalDestinations(home);
|
|
183
245
|
let removed = 0, failed = 0;
|
|
246
|
+
const kept = [];
|
|
247
|
+
const backups = [];
|
|
184
248
|
for (const line of readFileSync(manifest, 'utf8').split('\n')) {
|
|
185
249
|
if (!line.trim()) continue;
|
|
186
250
|
const [kind, p] = line.split('\t');
|
|
187
|
-
if (!p || !
|
|
188
|
-
try {
|
|
189
|
-
|
|
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++; }
|
|
190
263
|
}
|
|
191
|
-
|
|
192
|
-
return { removed, failed, noop: false };
|
|
264
|
+
return { removed, failed, noop: false, backups };
|
|
193
265
|
}
|