archgen-skill 0.0.4 → 0.0.6
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 +39 -45
- package/bin/archgen.mjs +30 -6
- package/lib/block.js +55 -9
- package/lib/bootstrap.js +103 -0
- package/lib/doctor.js +67 -5
- package/lib/init.js +43 -4
- package/lib/install.js +40 -4
- package/lib/mcp-server.js +159 -0
- package/lib/migrations/001-stamp-provenance.mjs +3 -3
- package/lib/spawn-npm.js +54 -0
- package/lib/store.js +174 -9
- package/lib/uninstall-project.js +16 -3
- package/lib/version-stamp.js +3 -1
- package/lib/version.js +11 -3
- package/package.json +5 -4
- package/vendor/skills/archgen/SKILL.md +370 -315
- 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 -349
- package/vendor/skills/archgen/references/interview.md +0 -230
- package/vendor/skills/archgen/references/mcp-and-skills.md +0 -117
- package/vendor/skills/archgen/references/orchestration.md +0 -169
- package/vendor/skills/archgen/references/platforms.md +0 -83
- 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 -207
- 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 -60
- package/vendor/skills/archgen/scripts/update-agents.mjs +0 -204
- package/vendor/skills/archgen/scripts/validate.mjs +0 -87
- package/vendor/skills/archgen/scripts/verify-plan.mjs +0 -70
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
23
|
import { importsAgents, renderBlock, START, END, upsertManagedFile } from './block.js';
|
|
24
|
-
import { CLAUDE_LINK_REL, STORE_REL, VERSION_FILE, appendEntry, cliVersion, hashDir, moveToBackup } from './store.js';
|
|
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
|
|
|
@@ -80,6 +81,9 @@ function installClaudeAdapter(root, source, warnings) {
|
|
|
80
81
|
return 'kept-divergent';
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
// Escape check: a symlinked .claude/.claude/skills parent must not redirect
|
|
85
|
+
// the adapter creation outside the project root.
|
|
86
|
+
assertWithinRoot(root, claudeAbs);
|
|
83
87
|
try {
|
|
84
88
|
mkdirSync(dirname(claudeAbs), { recursive: true });
|
|
85
89
|
symlinkSync('../../.agents/skills/archgen', claudeAbs);
|
|
@@ -96,8 +100,11 @@ function installClaudeAdapter(root, source, warnings) {
|
|
|
96
100
|
* claude adapter, and write context pointers.
|
|
97
101
|
* @param {string} projectDir target repo root
|
|
98
102
|
* @param {string} packageRoot dir containing this CLI package
|
|
99
|
-
* @param {{force?: boolean
|
|
100
|
-
* copies are always auto-backed-up
|
|
103
|
+
* @param {{force?: boolean, codegraph?: boolean, mcp?: boolean, log?: (s: string) => void, env?: NodeJS.ProcessEnv}} opts
|
|
104
|
+
* --force proceeds automatically; divergent copies are always auto-backed-up
|
|
105
|
+
* (never refused, never destroyed). opts.codegraph === false opts OUT of the
|
|
106
|
+
* automatic CodeGraph bootstrap (install + MCP wiring + index build);
|
|
107
|
+
* opts.mcp === false opts OUT of the archgen MCP wiring.
|
|
101
108
|
* @returns {{storePath: string, claudeLink: 'created'|'existing'|'migrated'|'skipped'|'kept-divergent',
|
|
102
109
|
* backups: string[], contextFiles: string[], createdContextFiles: string[], warnings: string[]}}
|
|
103
110
|
*/
|
|
@@ -107,6 +114,9 @@ export function initProject(projectDir, packageRoot, opts = {}) {
|
|
|
107
114
|
// both plain and --force runs take the same safe path.
|
|
108
115
|
const force = opts.force === true;
|
|
109
116
|
void force;
|
|
117
|
+
const codegraphAllowed = opts.codegraph !== false;
|
|
118
|
+
const mcpAllowed = opts.mcp !== false;
|
|
119
|
+
const log = opts.log ?? console.log;
|
|
110
120
|
|
|
111
121
|
const root = resolve(projectDir);
|
|
112
122
|
const source = resolveSkillSource(packageRoot);
|
|
@@ -116,6 +126,12 @@ export function initProject(projectDir, packageRoot, opts = {}) {
|
|
|
116
126
|
|
|
117
127
|
const storeAbs = join(root, relJoin(STORE_REL));
|
|
118
128
|
|
|
129
|
+
// Supply-chain guards BEFORE any mutation: a symlinked store (a malicious
|
|
130
|
+
// clone can ship .agents/skills/archgen as a link to an unrelated dir) is
|
|
131
|
+
// refused, and the resolved real path must stay inside the project root.
|
|
132
|
+
assertNotSymlink(storeAbs);
|
|
133
|
+
assertWithinRoot(root, storeAbs);
|
|
134
|
+
|
|
119
135
|
// 1. Canonical store — fingerprint before mutating (ownership check).
|
|
120
136
|
const prev = lstatSafe(storeAbs);
|
|
121
137
|
if (prev) {
|
|
@@ -129,6 +145,7 @@ export function initProject(projectDir, packageRoot, opts = {}) {
|
|
|
129
145
|
mkdirSync(skillsParent, { recursive: true });
|
|
130
146
|
const tmp = join(skillsParent, '.archgen-tmp-' + randomBytes(6).toString('hex'));
|
|
131
147
|
cpSync(source, tmp, { recursive: true });
|
|
148
|
+
pruneDevOnly(tmp); // dev-only artifacts (scripts/test) never reach the installed store
|
|
132
149
|
writeStamp(tmp, version);
|
|
133
150
|
rmSync(storeAbs, { recursive: true, force: true });
|
|
134
151
|
renameSync(tmp, storeAbs);
|
|
@@ -155,5 +172,27 @@ export function initProject(projectDir, packageRoot, opts = {}) {
|
|
|
155
172
|
appendEntry(root, 'block', name);
|
|
156
173
|
}
|
|
157
174
|
|
|
175
|
+
// 4. Server bootstrap — wire the archgen MCP server into every detected
|
|
176
|
+
// harness AND ensure CodeGraph (its CLI, its MCP entry, the project
|
|
177
|
+
// index). Non-fatal by design: an offline machine still gets the skill
|
|
178
|
+
// installed.
|
|
179
|
+
if (codegraphAllowed || mcpAllowed) {
|
|
180
|
+
const r = bootstrapServers({
|
|
181
|
+
projectDir: root,
|
|
182
|
+
location: 'both',
|
|
183
|
+
log,
|
|
184
|
+
env: opts.env,
|
|
185
|
+
noCodegraph: !codegraphAllowed,
|
|
186
|
+
noMcp: !mcpAllowed,
|
|
187
|
+
});
|
|
188
|
+
if (!r.ok) {
|
|
189
|
+
warnings.push(`server bootstrap incomplete${r.detail ? ': ' + r.detail : ''} — install later with: npx archgen-mcp bootstrap --project . --yes`);
|
|
190
|
+
} else if (r.skipped) {
|
|
191
|
+
warnings.push(`server bootstrap skipped (${r.skipped})`);
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
warnings.push('server bootstrap skipped (--no-codegraph --no-mcp)');
|
|
195
|
+
}
|
|
196
|
+
|
|
158
197
|
return { storePath: storeAbs, claudeLink, backups, contextFiles, createdContextFiles, warnings };
|
|
159
198
|
}
|
package/lib/install.js
CHANGED
|
@@ -7,9 +7,10 @@ import { homedir } from 'node:os';
|
|
|
7
7
|
import { dirname, join, resolve } from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { resolveSkillSource } from './init.js';
|
|
10
|
-
import { cliVersion, hashDir, moveToBackupInto } from './store.js';
|
|
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.
|
|
@@ -36,6 +37,7 @@ export function globalTargets(home = homedir()) {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
function record(manifest, kind, path) {
|
|
40
|
+
assertNotSymlink(manifest);
|
|
39
41
|
const line = `${kind}\t${path}`;
|
|
40
42
|
let dup = false;
|
|
41
43
|
if (existsSync(manifest)) {
|
|
@@ -57,7 +59,7 @@ function nextBackupTs(tdir) {
|
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
/**
|
|
60
|
-
* @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
|
|
61
63
|
* @returns {{rows: Array<[string, string, string]>, failures: number, manifest: string,
|
|
62
64
|
* backups: string[]}} rows are [STATUS, MODE, PATH]; `backups` lists
|
|
63
65
|
* absolute vault locations written by this run.
|
|
@@ -73,17 +75,31 @@ export function installGlobal(opts = {}) {
|
|
|
73
75
|
const backups = [];
|
|
74
76
|
let failures = 0;
|
|
75
77
|
|
|
76
|
-
const targets = [
|
|
77
|
-
|
|
78
|
+
const targets = [
|
|
79
|
+
...globalTargets(home).map((d) => [d, home]),
|
|
80
|
+
[join(process.cwd(), '.github', 'skills'), process.cwd()],
|
|
81
|
+
];
|
|
82
|
+
for (const [tdir, base] of targets) {
|
|
78
83
|
const dest = join(tdir, 'archgen');
|
|
79
84
|
if (!existsSync(tdir)) { rows.push(['SKIP', '-', tdir + ' (does not exist)']); continue; }
|
|
80
85
|
try {
|
|
86
|
+
// Realpath-escape guard: a symlinked harness dir (e.g. ~/.claude -> /etc)
|
|
87
|
+
// must not redirect the install outside the base it belongs to.
|
|
88
|
+
assertWithinRoot(base, tdir);
|
|
81
89
|
mkdirSync(tdir, { recursive: true }); // no-op when present
|
|
82
90
|
if (opts.copy) {
|
|
83
91
|
// Safety invariant: fingerprint dest BEFORE mutating; identical
|
|
84
92
|
// (stamp-ignoring) skips like link mode, divergent moves into the
|
|
85
93
|
// vault first — nothing is rmSync'd without a prior backup of it.
|
|
86
94
|
const prev = lstatSafe(dest);
|
|
95
|
+
if (prev && prev.isSymbolicLink()) {
|
|
96
|
+
// A symlink at dest is only ours when it points at this source (a
|
|
97
|
+
// previous link-mode install). Any other link is foreign: replacing
|
|
98
|
+
// it would let a planted symlink steer the copy, so refuse.
|
|
99
|
+
let linkTarget = null;
|
|
100
|
+
try { linkTarget = resolve(dirname(dest), readlinkSync(dest)); } catch { /* unreadable */ }
|
|
101
|
+
if (linkTarget !== source) throw new Error(refuseSymlinkMessage(dest));
|
|
102
|
+
}
|
|
87
103
|
let identical = false;
|
|
88
104
|
if (prev && prev.isDirectory() && !prev.isSymbolicLink()) {
|
|
89
105
|
try { identical = hashDir(dest) === hashDir(source); } catch { identical = false; }
|
|
@@ -100,7 +116,9 @@ export function installGlobal(opts = {}) {
|
|
|
100
116
|
rows.push(['BACKED-UP', '-', abs]);
|
|
101
117
|
}
|
|
102
118
|
rmSync(dest, { force: true, recursive: true }); // no-op after a move; guards exotic-FS fallbacks
|
|
119
|
+
assertNotSymlink(dest); // TOCTOU re-check: nothing may have re-linked dest between backup and copy
|
|
103
120
|
cpSync(source, dest, { recursive: true });
|
|
121
|
+
pruneDevOnly(dest); // dev-only artifacts (scripts/test) never reach global --copy trees
|
|
104
122
|
stampNote(rows, dest, () => writeStamp(dest, version));
|
|
105
123
|
record(manifest, 'copy', dest);
|
|
106
124
|
rows.push(['OK', 'copy', dest]);
|
|
@@ -148,6 +166,24 @@ export function installGlobal(opts = {}) {
|
|
|
148
166
|
rows.push(['FAILED', '-', dest + ' (' + e.message + ')']);
|
|
149
167
|
}
|
|
150
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 — run: npx archgen-mcp bootstrap --location global --yes']);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
151
187
|
return { rows, failures, manifest, backups };
|
|
152
188
|
}
|
|
153
189
|
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// mcp-server.js — locate and drive the `archgen-mcp` server from the CLI.
|
|
2
|
+
//
|
|
3
|
+
// WHY THE CLI DELEGATES: archgen's deterministic capability now lives in ONE
|
|
4
|
+
// place — the MCP server. The CLI used to shell out to the skill's
|
|
5
|
+
// scripts/codegraph.mjs; it now shells out to `archgen-mcp bootstrap`, which
|
|
6
|
+
// runs the same code the agent's tools run. There is no second implementation
|
|
7
|
+
// of harness wiring, CodeGraph bootstrap, or doctor logic.
|
|
8
|
+
//
|
|
9
|
+
// NEVER FATAL: no npm, no network, a read-only config, or a missing MCP
|
|
10
|
+
// server must still leave a working skill install. Failures are REPORTED,
|
|
11
|
+
// never thrown — the same posture bootstrapCodegraph has always had.
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { join, resolve } from 'node:path';
|
|
16
|
+
|
|
17
|
+
export const MCP_PACKAGE = 'archgen-mcp';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Locate the archgen-mcp binary.
|
|
21
|
+
*
|
|
22
|
+
* Resolution order (first hit wins):
|
|
23
|
+
* 1. $ARCHGEN_MCP_BIN — explicit override
|
|
24
|
+
* 2. <project>/node_modules/.bin/archgen-mcp
|
|
25
|
+
* 3. `archgen-mcp` on PATH — a global npm install
|
|
26
|
+
* 4. `npx -y archgen-mcp` — network fallback (npx caches it)
|
|
27
|
+
*
|
|
28
|
+
* @param {{projectRoot?: string, home?: string, env?: Record<string, string|undefined>}} opts
|
|
29
|
+
* @returns {{bin: string|null, source: 'override'|'local'|'path'|'npx', command: string, args: string[]}}
|
|
30
|
+
*/
|
|
31
|
+
export function resolveMcpBinary(opts = {}) {
|
|
32
|
+
const { projectRoot = process.cwd(), home = homedir(), env = process.env } = opts;
|
|
33
|
+
|
|
34
|
+
const override = env.ARCHGEN_MCP_BIN;
|
|
35
|
+
if (override && existsSync(override)) {
|
|
36
|
+
return { bin: override, source: 'override', command: override, args: [] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const local = join(
|
|
40
|
+
resolve(projectRoot),
|
|
41
|
+
'node_modules',
|
|
42
|
+
'.bin',
|
|
43
|
+
process.platform === 'win32' ? 'archgen-mcp.cmd' : 'archgen-mcp',
|
|
44
|
+
);
|
|
45
|
+
if (existsSync(local)) return { bin: local, source: 'local', command: local, args: [] };
|
|
46
|
+
|
|
47
|
+
const onPath = whichOnPath('archgen-mcp', env);
|
|
48
|
+
if (onPath) return { bin: onPath, source: 'path', command: onPath, args: [] };
|
|
49
|
+
|
|
50
|
+
return { bin: null, source: 'npx', command: 'npx', args: ['-y', MCP_PACKAGE] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Locate an executable on PATH (cross-platform, no shell).
|
|
55
|
+
* @param {string} bin
|
|
56
|
+
* @param {Record<string, string|undefined>} env
|
|
57
|
+
* @returns {string|null}
|
|
58
|
+
*/
|
|
59
|
+
export function whichOnPath(bin, env = process.env) {
|
|
60
|
+
const exts = process.platform === 'win32' ? (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';') : [''];
|
|
61
|
+
const dirs = (env.PATH ?? '').split(process.platform === 'win32' ? ';' : ':').filter(Boolean);
|
|
62
|
+
for (const d of dirs) {
|
|
63
|
+
for (const ext of exts) {
|
|
64
|
+
const p = join(d, bin + ext);
|
|
65
|
+
try {
|
|
66
|
+
if (existsSync(p) && !p.endsWith('/')) return p;
|
|
67
|
+
} catch {
|
|
68
|
+
/* not here */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Run one `archgen-mcp` subcommand and return its JSON output.
|
|
77
|
+
*
|
|
78
|
+
* @param {string[]} args the subcommand + flags
|
|
79
|
+
* @param {{projectRoot?: string, timeout?: number, env?: Record<string, string|undefined>}} opts
|
|
80
|
+
* @returns {{ok: boolean, code: number|null, data: any, stdout: string, stderr: string, detail?: string}}
|
|
81
|
+
*/
|
|
82
|
+
export function runMcp(args, opts = {}) {
|
|
83
|
+
const { projectRoot = process.cwd(), timeout = 600000, env = process.env } = opts;
|
|
84
|
+
const resolved = resolveMcpBinary({ projectRoot, env });
|
|
85
|
+
|
|
86
|
+
let r;
|
|
87
|
+
try {
|
|
88
|
+
r = spawnSync(resolved.command, [...resolved.args, ...args], {
|
|
89
|
+
encoding: 'utf8',
|
|
90
|
+
timeout,
|
|
91
|
+
env,
|
|
92
|
+
cwd: projectRoot,
|
|
93
|
+
shell: false,
|
|
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 } : {}),
|
|
96
|
+
});
|
|
97
|
+
} catch (e) {
|
|
98
|
+
return { ok: false, code: null, data: null, stdout: '', stderr: '', detail: String(e?.message ?? e) };
|
|
99
|
+
}
|
|
100
|
+
if (r.error) {
|
|
101
|
+
return { ok: false, code: null, data: null, stdout: '', stderr: '', detail: String(r.error.message ?? r.error) };
|
|
102
|
+
}
|
|
103
|
+
const stdout = r.stdout ?? '';
|
|
104
|
+
let data = null;
|
|
105
|
+
try {
|
|
106
|
+
data = JSON.parse(stdout);
|
|
107
|
+
} catch {
|
|
108
|
+
/* not JSON — the caller renders the raw output */
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
ok: r.status === 0,
|
|
112
|
+
code: r.status ?? null,
|
|
113
|
+
data,
|
|
114
|
+
stdout,
|
|
115
|
+
stderr: r.stderr ?? '',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Wire the archgen MCP server into the detected harnesses and ensure CodeGraph.
|
|
121
|
+
*
|
|
122
|
+
* @param {{projectRoot: string, location?: 'project'|'global'|'both', approved?: boolean, env?: Record<string, string|undefined>}} opts
|
|
123
|
+
* @returns {{ok: boolean, skipped?: string, detail?: string, report?: any}}
|
|
124
|
+
*/
|
|
125
|
+
export function bootstrapMcp(opts) {
|
|
126
|
+
const { projectRoot, location = 'both', approved = true, env = process.env } = opts;
|
|
127
|
+
const args = ['bootstrap', '--project', projectRoot, '--location', location, '--json'];
|
|
128
|
+
if (approved) args.push('--yes');
|
|
129
|
+
|
|
130
|
+
const r = runMcp(args, { projectRoot, env });
|
|
131
|
+
if (r.detail) {
|
|
132
|
+
return { ok: false, detail: `cannot run ${MCP_PACKAGE}: ${r.detail}` };
|
|
133
|
+
}
|
|
134
|
+
if (r.data === null) {
|
|
135
|
+
const firstLine = (r.stderr || r.stdout || '').trim().split('\n')[0];
|
|
136
|
+
return { ok: false, detail: firstLine || `${MCP_PACKAGE} bootstrap produced no output` };
|
|
137
|
+
}
|
|
138
|
+
return { ok: r.ok, report: r.data, detail: r.ok ? undefined : r.stderr.trim().split('\n')[0] };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Health-check the archgen MCP install.
|
|
143
|
+
* @param {{projectRoot: string, env?: Record<string, string|undefined>}} opts
|
|
144
|
+
*/
|
|
145
|
+
export function mcpDoctor(opts) {
|
|
146
|
+
const { projectRoot, env = process.env } = opts;
|
|
147
|
+
const r = runMcp(['doctor', '--project', projectRoot, '--json'], { projectRoot, env, timeout: 60000 });
|
|
148
|
+
return { ok: r.ok, report: r.data, detail: r.detail ?? (r.data ? undefined : r.stderr.trim().split('\n')[0]) };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Remove the archgen MCP entries.
|
|
153
|
+
* @param {{projectRoot: string, env?: Record<string, string|undefined>}} opts
|
|
154
|
+
*/
|
|
155
|
+
export function uninstallMcp(opts) {
|
|
156
|
+
const { projectRoot, env = process.env } = opts;
|
|
157
|
+
const r = runMcp(['uninstall-mcp', '--project', projectRoot, '--yes', '--json'], { projectRoot, env, timeout: 60000 });
|
|
158
|
+
return { ok: r.ok, report: r.data, detail: r.detail };
|
|
159
|
+
}
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
//
|
|
5
5
|
// What it writes: three YAML COMMENT lines at the very top of tasks.yaml and
|
|
6
6
|
// architecture.yaml. Comments are inert to every consumer by contract — the
|
|
7
|
-
//
|
|
8
|
-
// them verbatim),
|
|
9
|
-
// extension port behaves identically. Additive-only: no existing key is
|
|
7
|
+
// MCP core's parser records them positionally (`archgen_tasks{set_status}`
|
|
8
|
+
// preserves them verbatim), the verifier never sees them as data, and the VS
|
|
9
|
+
// Code extension port behaves identically. Additive-only: no existing key is
|
|
10
10
|
// touched, nothing becomes required.
|
|
11
11
|
//
|
|
12
12
|
// # schema_version: 1
|
package/lib/spawn-npm.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// spawn-npm.js — run npm on every platform without a shell.
|
|
2
|
+
// An unshelled spawnSync("npm", …) fails with EINVAL on Windows since the
|
|
3
|
+
// CVE-2024-27980 hardening, so npm's JS entry point is invoked with the
|
|
4
|
+
// current node binary instead.
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
/** npm-cli.js path: `npm_execpath` first, then node-relative layouts. */
|
|
9
|
+
export function npmCliPath() {
|
|
10
|
+
const fromEnv = process.env.npm_execpath;
|
|
11
|
+
if (typeof fromEnv === 'string' && fromEnv.endsWith('.js') && existsSync(fromEnv)) {
|
|
12
|
+
return fromEnv;
|
|
13
|
+
}
|
|
14
|
+
const dir = dirname(process.execPath);
|
|
15
|
+
const candidates =
|
|
16
|
+
process.platform === 'win32'
|
|
17
|
+
? [join(dir, 'node_modules', 'npm', 'bin', 'npm-cli.js')] // Windows: node.exe sits beside node_modules/
|
|
18
|
+
: [join(dir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')]; // POSIX: bin/../lib/...
|
|
19
|
+
for (const c of candidates) if (existsSync(c)) return c;
|
|
20
|
+
// Homebrew cellar layout: /opt/homebrew/{bin,lib}/...
|
|
21
|
+
if (process.platform !== 'win32') {
|
|
22
|
+
const brew = join(dir, '..', '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js');
|
|
23
|
+
if (existsSync(brew)) return brew;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Spawn npm with { command, args, shell }. Args MUST be registry-verified
|
|
30
|
+
* constants (the shell fallback on Windows interpolates them).
|
|
31
|
+
*/
|
|
32
|
+
export function npmInvocation(npmArgs) {
|
|
33
|
+
const cli = npmCliPath();
|
|
34
|
+
if (cli) return { command: process.execPath, args: [cli, ...npmArgs], shell: false };
|
|
35
|
+
const isWin = process.platform === 'win32';
|
|
36
|
+
return { command: isWin ? 'npm.cmd' : 'npm', args: npmArgs, shell: isWin };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Cross-platform `which` (PATHEXT on Windows). */
|
|
40
|
+
export function whichOnPath(bin) {
|
|
41
|
+
const exts = process.platform === 'win32'
|
|
42
|
+
? (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';')
|
|
43
|
+
: [''];
|
|
44
|
+
const dirs = (process.env.PATH ?? '').split(process.platform === 'win32' ? ';' : ':').filter(Boolean);
|
|
45
|
+
for (const d of dirs) {
|
|
46
|
+
for (const ext of exts) {
|
|
47
|
+
const p = join(d, bin + ext);
|
|
48
|
+
try {
|
|
49
|
+
if (existsSync(p)) return p;
|
|
50
|
+
} catch { /* next */ }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
package/lib/store.js
CHANGED
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
// - hashDir() fingerprints a tree (stable-order walk of relpaths + contents,
|
|
12
12
|
// sha256) so mutations only ever happen after verifying ownership/divergence;
|
|
13
13
|
// - GUARDED_RELPATHS (.claude, .agents, .claude/skills, .agents/skills) are
|
|
14
|
-
// never deleted: parent pruning stops before touching those levels
|
|
14
|
+
// never deleted: parent pruning stops before touching those levels;
|
|
15
|
+
// - assertNotSymlink()/assertWithinRoot() lstat BEFORE every write: a
|
|
16
|
+
// symlinked target (supply-chain attack: a malicious clone shipping
|
|
17
|
+
// .agents/skills/archgen or AGENTS.md as a link to an unrelated directory)
|
|
18
|
+
// is refused instead of written through.
|
|
15
19
|
|
|
16
20
|
import { createHash } from 'node:crypto';
|
|
17
21
|
import {
|
|
@@ -22,11 +26,12 @@ import {
|
|
|
22
26
|
readFileSync,
|
|
23
27
|
readdirSync,
|
|
24
28
|
readlinkSync,
|
|
29
|
+
realpathSync,
|
|
25
30
|
renameSync,
|
|
26
31
|
rmSync,
|
|
27
32
|
writeFileSync,
|
|
28
33
|
} from 'node:fs';
|
|
29
|
-
import { dirname, join } from 'node:path';
|
|
34
|
+
import { dirname, join, sep } from 'node:path';
|
|
30
35
|
import { loadConfig } from './config.js';
|
|
31
36
|
|
|
32
37
|
export const STORE_REL = '.agents/skills/archgen';
|
|
@@ -41,6 +46,120 @@ export const VERSION_FILE = CONFIG_FILES.stamp;
|
|
|
41
46
|
|
|
42
47
|
const GUARDED_RELPATHS = new Set(['.claude', '.agents', '.claude/skills', '.agents/skills']);
|
|
43
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Dev-only skill subtrees that must NEVER be distributed to end users. The
|
|
51
|
+
* repo's own node:test suite (scripts/test) is repo-only CI tooling: nothing
|
|
52
|
+
* imports it at runtime and it references repo-only fixtures
|
|
53
|
+
* (fixtures/yaml-corpus), so it cannot run in an installed project — shipping
|
|
54
|
+
* it into installed stores or the npm payload is dead weight. It is excluded
|
|
55
|
+
* consistently from the vendor mirror, every installed-store copy, and BOTH
|
|
56
|
+
* sides of hashDir comparisons (so its presence/absence never reads as drift).
|
|
57
|
+
* scripts/lib is deliberately NOT here — it is a runtime dependency of the
|
|
58
|
+
* skill scripts (imported by next-tasks/plan-graph/set-status/doc-index/etc).
|
|
59
|
+
*/
|
|
60
|
+
export const DEV_ONLY_RELPATHS = ['scripts/test'];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Subtrees that the CURRENT packaged skill no longer ships.
|
|
64
|
+
*
|
|
65
|
+
* The skill used to ship a `scripts/` tree (11 scripts + a lib/ folder). It
|
|
66
|
+
* does any more — every capability moved into the archgen MCP server. An
|
|
67
|
+
* existing install still carries that tree, and it is dead weight: nothing
|
|
68
|
+
* reads it, and it makes the store hash as DIVERGED from the packaged skill.
|
|
69
|
+
*
|
|
70
|
+
* Pruning it is a MIGRATION, not a repair: the tree is ours (it shipped in a
|
|
71
|
+
* previous version), so removing it is safe. It is never removed from a store
|
|
72
|
+
* whose packaged skill still ships the same path — the check is always
|
|
73
|
+
* "packaged skill has no such directory".
|
|
74
|
+
*/
|
|
75
|
+
export const OBSOLETE_RELPATHS = ['scripts'];
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The subtrees the packaged skill no longer ships that are still present in
|
|
79
|
+
* an installed tree (report-only — nothing is touched).
|
|
80
|
+
*
|
|
81
|
+
* @param {string} absRoot the installed skill tree
|
|
82
|
+
* @param {string} packagedRoot the packaged (vendor) skill tree
|
|
83
|
+
* @returns {string[]} the relpaths that are obsolete
|
|
84
|
+
*/
|
|
85
|
+
export function findObsolete(absRoot, packagedRoot) {
|
|
86
|
+
const found = [];
|
|
87
|
+
for (const rel of OBSOLETE_RELPATHS) {
|
|
88
|
+
if (existsSync(join(packagedRoot, ...rel.split('/')))) continue; // still shipped — not obsolete
|
|
89
|
+
if (!existsSync(join(absRoot, ...rel.split('/')))) continue;
|
|
90
|
+
found.push(rel);
|
|
91
|
+
}
|
|
92
|
+
return found;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Remove the subtrees the packaged skill no longer ships.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} absRoot the installed skill tree
|
|
99
|
+
* @param {string} packagedRoot the packaged (vendor) skill tree
|
|
100
|
+
* @returns {string[]} the relpaths actually removed
|
|
101
|
+
*/
|
|
102
|
+
export function pruneObsolete(absRoot, packagedRoot) {
|
|
103
|
+
const removed = [];
|
|
104
|
+
for (const rel of findObsolete(absRoot, packagedRoot)) {
|
|
105
|
+
rmSync(join(absRoot, ...rel.split('/')), { recursive: true, force: true });
|
|
106
|
+
removed.push(rel);
|
|
107
|
+
}
|
|
108
|
+
return removed;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** True when relpath `r` is (or lives inside) a dev-only excluded subtree. */
|
|
112
|
+
function isDevOnlyRelPath(r) {
|
|
113
|
+
for (const prefix of DEV_ONLY_RELPATHS) {
|
|
114
|
+
if (r === prefix || r.startsWith(prefix + '/')) return true;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Canonical refusal message for every symlink-write guard (CLI + install.sh). */
|
|
120
|
+
export function refuseSymlinkMessage(absPath) {
|
|
121
|
+
return 'Refusing to write through symlink at ' + absPath + ' — use --copy or remove symlink';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Supply-chain guard: lstat (never stat — the LINK itself, not its target) a
|
|
126
|
+
* path before writing it. Absent paths pass (safe to create); a symlink at
|
|
127
|
+
* the target throws so no write can ever be redirected through the link.
|
|
128
|
+
*/
|
|
129
|
+
export function assertNotSymlink(absPath) {
|
|
130
|
+
let st = null;
|
|
131
|
+
try { st = lstatSync(absPath); } catch { return; }
|
|
132
|
+
if (st.isSymbolicLink()) throw new Error(refuseSymlinkMessage(absPath));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Realpath-escape guard: resolve `absPath` (climbing to the nearest existing
|
|
137
|
+
* ancestor when it does not exist yet, so a symlinked PARENT dir is caught
|
|
138
|
+
* before anything is created beneath it) and refuse when the real location
|
|
139
|
+
* lands outside `rootAbs`. Catches `.agents -> /elsewhere` style redirects
|
|
140
|
+
* that a final-component lstat cannot see.
|
|
141
|
+
*/
|
|
142
|
+
export function assertWithinRoot(rootAbs, absPath) {
|
|
143
|
+
let rootReal;
|
|
144
|
+
try { rootReal = realpathSync(rootAbs); } catch { return; }
|
|
145
|
+
let probe = absPath;
|
|
146
|
+
for (;;) {
|
|
147
|
+
let exists = true;
|
|
148
|
+
try { lstatSync(probe); } catch { exists = false; }
|
|
149
|
+
if (exists) {
|
|
150
|
+
let real;
|
|
151
|
+
try { real = realpathSync(probe); } catch { return; } // broken chain: nothing writable through it
|
|
152
|
+
if (real !== rootReal && !real.startsWith(rootReal + sep)) {
|
|
153
|
+
throw new Error(refuseSymlinkMessage(absPath));
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const parent = dirname(probe);
|
|
158
|
+
if (parent === probe) return;
|
|
159
|
+
probe = parent;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
44
163
|
/** Version string of this CLI package (used for the in-store stamp). */
|
|
45
164
|
export function cliVersion(packageRoot) {
|
|
46
165
|
return JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version;
|
|
@@ -56,26 +175,65 @@ function byName(a, b) {
|
|
|
56
175
|
* The version stamp is ALWAYS ignored on both sides of a comparison: it is
|
|
57
176
|
* CLI metadata written at destination time (global symlink installs stamp the
|
|
58
177
|
* shared real copy), never skill content, so it must not read as divergence.
|
|
178
|
+
* DEV_ONLY_RELPATHS (scripts/test) are ALSO always ignored on both sides: they
|
|
179
|
+
* are pruned from every distributed copy, so a source tree that still carries
|
|
180
|
+
* them (the dev-checkout skill/ fallback) must hash equal to an installed store
|
|
181
|
+
* that does not — while a real edit anywhere else must still read as divergence.
|
|
59
182
|
* @param {string} dir absolute directory to hash
|
|
60
183
|
* @param {{ignore?: string[]}} opts EXTRA relpaths to skip
|
|
61
184
|
*/
|
|
62
185
|
export function hashDir(dir, opts = {}) {
|
|
63
186
|
const ignore = new Set([VERSION_FILE, ...(opts.ignore ?? [])]);
|
|
64
187
|
const hash = createHash('sha256');
|
|
65
|
-
|
|
188
|
+
// A directory contributes its marker ONLY when its subtree contributes
|
|
189
|
+
// something. Without this, a directory holding nothing but excluded
|
|
190
|
+
// dev-only content hashed a 'd <rel>' marker on one side and nothing on the
|
|
191
|
+
// other — the two trees then read as divergent even though they are
|
|
192
|
+
// identical once the exclusions are applied.
|
|
193
|
+
//
|
|
194
|
+
// File contents are hashed as BYTES, never as strings: a lossy string
|
|
195
|
+
// round-trip would change the digest for any non-UTF-8 file.
|
|
196
|
+
const walk = (abs, rel) => {
|
|
66
197
|
const entries = readdirSync(abs, { withFileTypes: true }).sort((a, b) => byName(a.name, b.name));
|
|
198
|
+
const chunks = [];
|
|
67
199
|
for (const ent of entries) {
|
|
68
200
|
const r = rel ? rel + '/' + ent.name : ent.name;
|
|
69
|
-
if (ignore.has(r)) continue;
|
|
201
|
+
if (ignore.has(r) || isDevOnlyRelPath(r)) continue;
|
|
70
202
|
const p = join(abs, ent.name);
|
|
71
|
-
if (ent.isSymbolicLink())
|
|
72
|
-
|
|
73
|
-
else
|
|
203
|
+
if (ent.isSymbolicLink()) {
|
|
204
|
+
chunks.push(Buffer.from('l ' + r + ' -> ' + readlinkSync(p) + '\n'));
|
|
205
|
+
} else if (ent.isDirectory()) {
|
|
206
|
+
const sub = walk(p, r);
|
|
207
|
+
if (sub.length > 0) {
|
|
208
|
+
chunks.push(Buffer.from('d ' + r + '\n'));
|
|
209
|
+
chunks.push(...sub);
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
chunks.push(Buffer.from('f ' + r + '\0'));
|
|
213
|
+
chunks.push(readFileSync(p));
|
|
214
|
+
chunks.push(Buffer.from('\n'));
|
|
215
|
+
}
|
|
74
216
|
}
|
|
75
|
-
|
|
217
|
+
return chunks;
|
|
218
|
+
};
|
|
219
|
+
for (const chunk of walk(dir, '')) hash.update(chunk);
|
|
76
220
|
return hash.digest('hex');
|
|
77
221
|
}
|
|
78
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Remove every dev-only subtree (DEV_ONLY_RELPATHS) from an absolute skill-tree
|
|
225
|
+
* root; idempotent no-op when a subtree is absent. Called after EVERY copy of
|
|
226
|
+
* the skill into a distributed location (installed store, global --copy tree,
|
|
227
|
+
* vendor mirror) so dev-only artifacts never reach end users regardless of which
|
|
228
|
+
* source resolveSkillSource picked — vendor is already pruned by sync-vendor,
|
|
229
|
+
* but the dev-checkout skill/ fallback is not, and this normalizes both.
|
|
230
|
+
*/
|
|
231
|
+
export function pruneDevOnly(absRoot) {
|
|
232
|
+
for (const rel of DEV_ONLY_RELPATHS) {
|
|
233
|
+
rmSync(join(absRoot, ...rel.split('/')), { recursive: true, force: true });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
79
237
|
/** Load `.archgen/.install-manifest.json`; null when absent/corrupt. */
|
|
80
238
|
export function loadManifest(root) {
|
|
81
239
|
const p = join(root, ...MANIFEST_REL.split('/'));
|
|
@@ -91,6 +249,7 @@ export function loadManifest(root) {
|
|
|
91
249
|
export function saveManifest(root, manifest) {
|
|
92
250
|
const p = join(root, ...MANIFEST_REL.split('/'));
|
|
93
251
|
mkdirSync(dirname(p), { recursive: true });
|
|
252
|
+
assertNotSymlink(p);
|
|
94
253
|
writeFileSync(p, JSON.stringify(manifest, null, 2) + '\n');
|
|
95
254
|
}
|
|
96
255
|
|
|
@@ -121,7 +280,13 @@ export function moveToBackupInto(root, relPath, backupRootRel, ts = new Date().t
|
|
|
121
280
|
const srcAbs = join(root, ...relPath.split('/'));
|
|
122
281
|
try {
|
|
123
282
|
renameSync(srcAbs, destAbs);
|
|
124
|
-
} catch {
|
|
283
|
+
} catch (err) {
|
|
284
|
+
// Fallback copy+remove must NEVER run on a symlinked source: cpSync would
|
|
285
|
+
// read through the link and rmSync would remove it — rethrow instead so
|
|
286
|
+
// the caller refuses the replace rather than touching the link's target.
|
|
287
|
+
let st = null;
|
|
288
|
+
try { st = lstatSync(srcAbs); } catch { /* vanished mid-move */ }
|
|
289
|
+
if (st && st.isSymbolicLink()) throw err;
|
|
125
290
|
cpSync(srcAbs, destAbs, { recursive: true });
|
|
126
291
|
rmSync(srcAbs, { recursive: true, force: true });
|
|
127
292
|
}
|