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.
Files changed (37) hide show
  1. package/README.md +39 -45
  2. package/bin/archgen.mjs +30 -6
  3. package/lib/block.js +55 -9
  4. package/lib/bootstrap.js +103 -0
  5. package/lib/doctor.js +67 -5
  6. package/lib/init.js +43 -4
  7. package/lib/install.js +40 -4
  8. package/lib/mcp-server.js +159 -0
  9. package/lib/migrations/001-stamp-provenance.mjs +3 -3
  10. package/lib/spawn-npm.js +54 -0
  11. package/lib/store.js +174 -9
  12. package/lib/uninstall-project.js +16 -3
  13. package/lib/version-stamp.js +3 -1
  14. package/lib/version.js +11 -3
  15. package/package.json +5 -4
  16. package/vendor/skills/archgen/SKILL.md +370 -315
  17. package/vendor/skills/archgen/archgen.config.json +1 -1
  18. package/vendor/skills/archgen/references/code-standards.md +2 -2
  19. package/vendor/skills/archgen/assets/mcp-template.json +0 -42
  20. package/vendor/skills/archgen/assets/skill-registry.json +0 -24
  21. package/vendor/skills/archgen/references/artifact-templates.md +0 -349
  22. package/vendor/skills/archgen/references/interview.md +0 -230
  23. package/vendor/skills/archgen/references/mcp-and-skills.md +0 -117
  24. package/vendor/skills/archgen/references/orchestration.md +0 -169
  25. package/vendor/skills/archgen/references/platforms.md +0 -83
  26. package/vendor/skills/archgen/scripts/doc-index.mjs +0 -457
  27. package/vendor/skills/archgen/scripts/impact.mjs +0 -77
  28. package/vendor/skills/archgen/scripts/lib/graph.mjs +0 -207
  29. package/vendor/skills/archgen/scripts/lib/plan-coverage.mjs +0 -32
  30. package/vendor/skills/archgen/scripts/lib/scope.mjs +0 -65
  31. package/vendor/skills/archgen/scripts/lib/yaml.mjs +0 -492
  32. package/vendor/skills/archgen/scripts/next-tasks.mjs +0 -55
  33. package/vendor/skills/archgen/scripts/plan-graph.mjs +0 -329
  34. package/vendor/skills/archgen/scripts/set-status.mjs +0 -60
  35. package/vendor/skills/archgen/scripts/update-agents.mjs +0 -204
  36. package/vendor/skills/archgen/scripts/validate.mjs +0 -87
  37. 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, readFileSync, readlinkSync, renameSync, rmSync, symlinkSync, unlinkSync } from 'node:fs';
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}} opts --force proceeds automatically; divergent
100
- * copies are always auto-backed-up (never refused, never destroyed)
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 = [...globalTargets(home), join(process.cwd(), '.github', 'skills')];
77
- for (const tdir of targets) {
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
- // skill's zero-dep parser records them positionally (set-status.mjs preserves
8
- // them verbatim), verify-plan.mjs never sees them as data, and the VS Code
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
@@ -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
- (function walk(abs, rel) {
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()) hash.update('l ' + r + ' -> ' + readlinkSync(p) + '\n');
72
- else if (ent.isDirectory()) { hash.update('d ' + r + '\n'); walk(p, r); }
73
- else { hash.update('f ' + r + '\0'); hash.update(readFileSync(p)); hash.update('\n'); }
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
- })(dir, '');
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
  }