archgen-skill 0.0.4 → 0.0.5

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 CHANGED
@@ -32,15 +32,15 @@ What `init` writes (nothing is ever duplicated):
32
32
 
33
33
  | Path | What |
34
34
  | --- | --- |
35
- | `.agents/skills/archgen/` | **The only real copy** — the agentskills.io standard location read natively by OpenCode, Cursor, Codex, Gemini CLI, Copilot and Antigravity |
36
- | `.claude/skills/archgen → ../../.agents/skills/archgen` | Relative symlink for Claude Code (the one harness needing it). Skipped gracefully where symlinks need privileges — Claude still works via the bridge below |
37
- | `AGENTS.md` | Managed hub block: skill pointer + quick triggers + a features registry that stays current as you add features |
38
- | `CLAUDE.md` | One-line bridge: `@AGENTS.md` (existing files keep all their content; if it already imports AGENTS.md we write nothing) |
35
+ | `.agents/skills/archgen/` | **The only real copy** — the agentskills.io standard location, read natively by OpenCode, Cursor, Codex, Gemini CLI, Copilot, and Antigravity |
36
+ | `.claude/skills/archgen → ../../.agents/skills/archgen` | Relative symlink for Claude Code (the one harness that needs it); skipped gracefully where symlinks require privileges |
37
+ | `AGENTS.md` | Managed hub block: skill pointer, quick triggers, and a features registry that stays current as you add features |
38
+ | `CLAUDE.md` | One-line bridge: `@AGENTS.md` (existing content is preserved; nothing is written if it already imports AGENTS.md) |
39
39
 
40
- Existing projects with user content are safe: managed blocks upsert between
41
- markers, modified skill copies are backed up to `.archgen/.backup/<timestamp>/`
42
- before refresh, and legacy dual-copy layouts are migrated to symlinks
43
- automatically. Verify anytime with `archgen-skill doctor`.
40
+ Existing projects are safe: managed blocks upsert between markers, modified skill
41
+ copies are backed up to `.archgen/.backup/<timestamp>/` before refresh, and legacy
42
+ dual-copy layouts are migrated to symlinks automatically. Verify anytime with
43
+ `archgen-skill doctor`.
44
44
 
45
45
  **Or install globally** into every detected harness:
46
46
 
@@ -49,12 +49,13 @@ npx archgen-skill install # symlinks; --copy for real copies
49
49
  ```
50
50
 
51
51
  Prefer shell? Clone the repo and run `./install.sh` (same behavior, plus
52
- `--init`, `--project`, `--uninstall`). Requires Node.js ≥ 18.
53
- The skill itself has zero npm dependencies.
52
+ `--init`, `--project`, `--uninstall`). Requires Node.js ≥ 18. The skill itself
53
+ has zero npm dependencies.
54
54
 
55
55
  ## Quick start
56
56
 
57
- After `npx archgen-skill init`, open your project in any supported agent and talk naturally:
57
+ After `npx archgen-skill init`, open your project in any supported agent and talk
58
+ naturally:
58
59
 
59
60
  | You say | What runs |
60
61
  | --- | --- |
@@ -64,39 +65,33 @@ After `npx archgen-skill init`, open your project in any supported agent and tal
64
65
  | *"roll back the auth changes"* | Impact analysis → reverse-order revert plan → approval |
65
66
 
66
67
  Everything archgen produces lives in one reviewable `.archgen/<slug>/` folder —
67
- architecture contract, docs, ADRs, plans, and a `tasks.yaml` task graph — versioned by git.
68
+ architecture contract, docs, ADRs, plans, and a `tasks.yaml` task graph — versioned
69
+ by git.
68
70
 
69
71
  ## Commands
70
72
 
71
73
  | Command | What it does |
72
74
  | --- | --- |
73
- | `npx archgen-skill init [dir]` | Install the single canonical skill store at `.agents/skills/archgen`, add the Claude symlink adapter, write the AGENTS.md hub + CLAUDE.md `@AGENTS.md` bridge |
75
+ | `npx archgen-skill init [dir]` | Prepare a project: canonical skill store at `.agents/skills/archgen`, Claude symlink adapter, AGENTS.md hub + CLAUDE.md bridge |
74
76
  | `npx archgen-skill install [--copy]` | Install into global harness skill dirs (symlinks by default, real copies with `--copy`) |
75
77
  | `npx archgen-skill uninstall` | Remove globally-installed copies (manifest-recorded, safe removal) |
76
- | `npx archgen-skill uninstall --project [dir]` | Remove project install: strips managed blocks, our symlink and the store **only if unmodified** — feature folders in `.archgen/` and all user content are preserved |
77
- | `npx archgen-skill doctor [dir] [--check]` | Verify + auto-repair an installation: store integrity, version stamp, Claude link, blocks present exactly once, manifest resolution (`--check` reports without fixing) |
78
- | `npx archgen-skill --help` | Help |
79
-
80
- ### Version stamp (`.archgen-version`)
81
-
82
- Every skill tree the CLI writes carries a one-line `MAJOR.MINOR.PATCH\n` stamp
83
- at its root — the canonical store from `init`, each `install --copy` tree, and
84
- the real directory behind global symlink installs. `update` refreshes it via
85
- its init/doctor pass. `doctor` lists every detected install with its stamped
86
- version (`missing` / `unknown (pre-0.1 stamp)` when absent or unparseable).
87
- The VS Code extension reads the same file to warn about outdated skills;
88
- absent or corrupt means "unknown (legacy)", never an error.
78
+ | `npx archgen-skill uninstall --project [dir]` | Remove a project install: strips managed blocks, symlink, and store **only if unmodified** — `.archgen/` feature folders and all user content are preserved |
79
+ | `npx archgen-skill doctor [dir] [--check]` | Verify and auto-repair an installation: store integrity, version stamp, Claude link, managed blocks, manifest (`--check` reports without fixing) |
80
+ | `npx archgen-skill update [dir]` | Check npm for a newer `archgen-skill`, upgrade the global install if outdated, then re-init the project and run doctor against the new version |
81
+ | `npx archgen-skill restore` | List backup snapshots; `--snapshot <ts>` restores one (current state is backed up first) |
82
+ | `npx archgen-skill migrate [dir] [--apply]` | Evolve generated-artifact formats in place (`--check` by default; every touched file is backed up) |
83
+ | `npx archgen-skill --version` / `--help` | Print the CLI version / show help |
89
84
 
90
85
  ## Why archgen
91
86
 
92
87
  AI coding agents fail at scale for predictable reasons: plans with hidden cycles,
93
88
  workers editing the same files, tasks without objective acceptance criteria, and no
94
89
  human checkpoint before execution. archgen makes each of these a *structural*
95
- guarantee rather than a hope:
90
+ guarantee:
96
91
 
97
92
  | Guarantee | Mechanism |
98
93
  | --- | --- |
99
- | Plans are valid before work starts | Verifier gate — catches cycles, dangling refs, ownership overlaps, plan↔task coverage gaps |
94
+ | Plans are valid before work starts | Verifier gate — cycles, dangling refs, ownership overlaps, plan↔task coverage |
100
95
  | You approve before code is written | Explicit user gate after verifier approval |
101
96
  | Parallel workers never collide | Disjoint `file_ownership` globs enforced per wave |
102
97
  | Task order is always correct | Topological waves from `depends_on`; chains stay sequential |
@@ -124,23 +119,17 @@ guarantee rather than a hope:
124
119
 
125
120
  ## Supported harnesses
126
121
 
127
- | Harness | Global install | Project-local (`init`) |
128
- | --- | --- | --- |
129
- | Claude Code | ✅ | ✅ |
130
- | OpenCode | ✅ | ✅ |
131
- | Cursor | ✅ | ✅ |
132
- | Codex | ✅ | ✅ |
133
- | Gemini CLI | ✅ | ✅ |
134
- | Antigravity | ✅ | ✅ |
135
- | Any agentskills.io harness | ✅ | ✅ |
122
+ Claude Code, OpenCode, Cursor, Codex, Gemini CLI, Antigravity — and any
123
+ agentskills.io-compatible harness. Both global install and project-local `init`
124
+ are supported for all of them.
136
125
 
137
126
  ## VS Code extension (optional)
138
127
 
139
- A read-only visual layer that renders `.archgen/` as a live task DAG (running tasks
140
- pulse, edges animate), your code-dependency graph, and rendered docs — with a ▶
141
- button that launches your agent on any task. Install the `.vsix` from
142
- [GitHub Releases](https://github.com/akashmaurya99/archgen/releases). It is strictly
143
- a viewer: uninstalling it loses nothing.
128
+ A read-only visual layer that renders `.archgen/` as a live task DAG, your
129
+ code-dependency graph, and rendered docs — with a ▶ button that hands any task to
130
+ your agent. Install it from the VS Code Marketplace or from a `.vsix` on
131
+ [GitHub Releases](https://github.com/akashmaurya99/archgen/releases). It never
132
+ edits anything: uninstalling it loses nothing.
144
133
 
145
134
  ## Documentation
146
135
 
@@ -149,7 +138,6 @@ a viewer: uninstalling it loses nothing.
149
138
  | [Architecture walkthrough](https://github.com/akashmaurya99/archgen/blob/main/docs/architecture.md) | Dual-mode design, gates, wave mechanics, platform detection |
150
139
  | [The skill contract](https://github.com/akashmaurya99/archgen/blob/main/skill/SKILL.md) | The full skill specification agents execute |
151
140
  | [Changelog](https://github.com/akashmaurya99/archgen/blob/main/CHANGELOG.md) | Release history |
152
- | [Contributing](https://github.com/akashmaurya99/archgen/blob/main/CONTRIBUTING.md) | Development setup and PR guide |
153
141
 
154
142
  ## Contributing
155
143
 
package/bin/archgen.mjs CHANGED
@@ -193,7 +193,14 @@ try {
193
193
  break;
194
194
  }
195
195
  console.log('\nre-running init + doctor with the new binary (' + latest + ')…');
196
- spawnSync(freshBin, ['init', dir], { stdio: 'inherit' });
196
+ const upInit = spawnSync(freshBin, ['init', dir], { stdio: 'inherit' });
197
+ if (upInit.status !== 0) {
198
+ // An aborted init (e.g. symlink refusal) must surface as a failure —
199
+ // running doctor over a half-finished install would mask it.
200
+ console.error(' ! init failed during update - doctor skipped (see message above)');
201
+ process.exitCode = upInit.status ?? 1;
202
+ break;
203
+ }
197
204
  spawnSync(freshBin, ['doctor', dir], { stdio: 'inherit' });
198
205
  break;
199
206
  }
package/lib/block.js CHANGED
@@ -18,6 +18,7 @@
18
18
 
19
19
  import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
20
20
  import { loadConfig } from './config.js';
21
+ import { assertNotSymlink } from './store.js';
21
22
 
22
23
  // Markers + block-format version come from the single source of truth.
23
24
  // Module-level load is safe: config resolution is module-relative and
@@ -127,8 +128,31 @@ export function renderClaudeBridgeText() {
127
128
  return [START, provenanceLine(), '@AGENTS.md', END].join('\n');
128
129
  }
129
130
 
131
+ function countOccurrences(haystack, needle) {
132
+ let n = 0;
133
+ for (let i = haystack.indexOf(needle); i !== -1; i = haystack.indexOf(needle, i + needle.length)) n++;
134
+ return n;
135
+ }
136
+
137
+ function stripCompleteBlocks(text) {
138
+ let out = text;
139
+ for (;;) {
140
+ const s = out.indexOf(START);
141
+ if (s === -1) break;
142
+ const e = out.indexOf(END, s + START.length);
143
+ if (e === -1) break;
144
+ out = out.slice(0, s) + out.slice(e + END.length);
145
+ }
146
+ return out;
147
+ }
148
+
130
149
  /**
131
150
  * Insert or replace the managed block inside existing file content.
151
+ * Corrupted input with DUPLICATED markers (more than one START or END, e.g. a
152
+ * twice-appended block) is normalized: the first complete block is replaced
153
+ * in place, every later complete block is removed, and anything outside the
154
+ * markers survives — the result carries exactly one block. Orphan markers
155
+ * left over after normalization still throw, as does a single orphan marker.
132
156
  * @param {string} existing file contents ('' when creating new)
133
157
  * @param {string} block rendered block
134
158
  * @param {string} eol line ending used when appending to empty/missing files
@@ -138,6 +162,22 @@ export function upsertBlock(existing, block, eol = '\n') {
138
162
  // caller rendered — this is what upgrades legacy unversioned blocks in place.
139
163
  const fresh = injectProvenance(block);
140
164
  const trimmed = existing ?? '';
165
+ const starts = countOccurrences(trimmed, START);
166
+ const ends = countOccurrences(trimmed, END);
167
+
168
+ if (starts > 1 || ends > 1) {
169
+ const s = trimmed.indexOf(START);
170
+ const e = s === -1 ? -1 : trimmed.indexOf(END, s + START.length);
171
+ if (s === -1 || e === -1) {
172
+ throw new Error('found only one archgen marker - fix or remove it manually');
173
+ }
174
+ const tail = stripCompleteBlocks(trimmed.slice(e + END.length));
175
+ if (tail.includes(START) || tail.includes(END)) {
176
+ throw new Error('found only one archgen marker - fix or remove it manually');
177
+ }
178
+ return trimmed.slice(0, s) + fresh + tail;
179
+ }
180
+
141
181
  const startIdx = trimmed.indexOf(START);
142
182
  const endIdx = trimmed.indexOf(END);
143
183
  if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
@@ -152,18 +192,16 @@ export function upsertBlock(existing, block, eol = '\n') {
152
192
  }
153
193
 
154
194
  /**
155
- * Strip the managed block from file contents.
195
+ * Strip EVERY managed block from file contents (duplicated markers normalize
196
+ * to zero blocks, matching upsertBlock's one-block guarantee).
156
197
  * @returns {{content: string, hadBlock: boolean}}
157
198
  */
158
199
  export function stripBlock(existing) {
159
200
  const trimmed = existing ?? '';
160
- const startIdx = trimmed.indexOf(START);
161
- const endIdx = trimmed.indexOf(END);
162
- if (startIdx === -1 || endIdx === -1) return { content: trimmed, hadBlock: false };
163
- let out = trimmed.slice(0, startIdx) + trimmed.slice(endIdx + END.length);
201
+ const out = stripCompleteBlocks(trimmed);
202
+ if (out === trimmed) return { content: trimmed, hadBlock: false };
164
203
  // Collapse whitespace left behind; drop file if nothing else remains.
165
- out = out.replace(/^\n+/, '').replace(/\n{3,}/g, '\n\n');
166
- return { content: out, hadBlock: true };
204
+ return { content: out.replace(/^\n+/, '').replace(/\n{3,}/g, '\n\n'), hadBlock: true };
167
205
  }
168
206
 
169
207
  function splitBom(raw) {
@@ -174,18 +212,24 @@ function splitBom(raw) {
174
212
  /** Write via sibling temp file + rename so readers never observe partial content. */
175
213
  function atomicWrite(absPath, data) {
176
214
  const tmp = absPath + '.tmp-' + process.pid + '-' + Date.now();
177
- writeFileSync(tmp, data);
215
+ // 'wx' (O_CREAT|O_EXCL): if the tmp name already exists — e.g. an attacker
216
+ // planted a symlink there to redirect the write — fail instead of following.
217
+ writeFileSync(tmp, data, { flag: 'wx' });
178
218
  renameSync(tmp, absPath);
179
219
  }
180
220
 
181
221
  /**
182
222
  * BOM/EOL-aware upsert of a managed block directly into a file.
183
223
  * Preserves a leading UTF-8 BOM and the file's CRLF/LF convention.
224
+ * Refuses (throws) when the target is a symlink: managed files must never be
225
+ * written through a link (the final rename replaces the link itself, but the
226
+ * read+upsert would otherwise operate on attacker-chosen content).
184
227
  * @param {string} absPath target file (created when missing)
185
228
  * @param {string[]} blockLines lines between the managed markers
186
229
  * @returns {boolean} whether the file existed before the call
187
230
  */
188
231
  export function upsertManagedFile(absPath, blockLines) {
232
+ assertNotSymlink(absPath);
189
233
  const existed = existsSync(absPath);
190
234
  const raw = existed ? readFileSync(absPath, 'utf8') : '';
191
235
  const { bom, body } = splitBom(raw);
@@ -208,6 +252,7 @@ export function importsAgents(absPath) {
208
252
 
209
253
  /** Strip the managed block from a file on disk, preserving BOM. */
210
254
  export function stripManagedFile(absPath) {
255
+ assertNotSymlink(absPath);
211
256
  let raw = '';
212
257
  try { raw = readFileSync(absPath, 'utf8'); } catch { return { hadBlock: false }; }
213
258
  const { bom, body } = splitBom(raw);
@@ -233,6 +278,7 @@ function renderRow(row) {
233
278
  * @returns {string} the new file content
234
279
  */
235
280
  export function upsertFeaturesRegistry(fileAbsPath, rows = []) {
281
+ assertNotSymlink(fileAbsPath);
236
282
  const raw = readFileSync(fileAbsPath, 'utf8');
237
283
  const table = [
238
284
  '| Feature | Status | Updated |',
package/lib/doctor.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // reported and counted in the summary but do NOT fail the run: the process
8
8
  // exits non-zero only when a repair operation itself throws.
9
9
 
10
- import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
10
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, readlinkSync, symlinkSync, unlinkSync } from 'node:fs';
11
11
  import { homedir } from 'node:os';
12
12
  import { dirname, join, resolve } from 'node:path';
13
13
  import { globalTargets } from './install.js';
@@ -22,7 +22,7 @@ import {
22
22
  upsertManagedFile,
23
23
  } from './block.js';
24
24
  import { resolveSkillSource } from './init.js';
25
- import { CLAUDE_LINK_REL, MANIFEST_REL, STORE_REL, VERSION_FILE, cliVersion, hashDir, loadManifest, saveManifest } from './store.js';
25
+ import { CLAUDE_LINK_REL, DEV_ONLY_RELPATHS, MANIFEST_REL, STORE_REL, VERSION_FILE, cliVersion, hashDir, loadManifest, pruneDevOnly, saveManifest } from './store.js';
26
26
  import { compareSemver, parseSemver } from './version.js';
27
27
  import { readStamp, writeStamp } from './version-stamp.js';
28
28
 
@@ -98,6 +98,23 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
98
98
  }
99
99
  }
100
100
 
101
+ // 1b. Prune dev-only artifacts (scripts/test) that older installers shipped
102
+ // into the store. They are repo-only CI tooling and never belong in an
103
+ // installed tree; removing them here cleans existing stores without a
104
+ // full backup+re-copy. hashDir ignores the same subtrees, so this prune
105
+ // neither masks real customization nor reads as divergence.
106
+ if (existsSync(storeAbs)) {
107
+ const devOnlyPresent = DEV_ONLY_RELPATHS.filter((rel) => existsSync(join(storeAbs, ...rel.split('/'))));
108
+ if (devOnlyPresent.length > 0) {
109
+ if (checkOnly) {
110
+ add('WOULD-FIX', `store carries dev-only ${devOnlyPresent.join(', ')} -> pruned`);
111
+ } else {
112
+ attempt(() => pruneDevOnly(storeAbs), 'FIXED',
113
+ `dev-only ${devOnlyPresent.join(', ')} pruned from store`, 'cannot prune dev-only artifacts');
114
+ }
115
+ }
116
+ }
117
+
101
118
  // 2. Version stamp currency.
102
119
  const stampAbs = join(storeAbs, VERSION_FILE);
103
120
  let stamp = null;
@@ -146,8 +163,10 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
146
163
  if (!existsSync(agentsAbs)) {
147
164
  if (checkOnly) add('WOULD-FIX', 'AGENTS.md missing -> created with managed block');
148
165
  else {
149
- writeFileSync(agentsAbs, renderBlock(STORE_REL) + '\n');
150
- add('FIXED', 'AGENTS.md created with managed block');
166
+ // Through the guarded upsert (not raw writeFileSync): existsSync follows
167
+ // symlinks, so a dangling symlink would otherwise be written through.
168
+ attempt(() => upsertManagedFile(agentsAbs, renderBlock(STORE_REL).split('\n')), 'FIXED',
169
+ 'AGENTS.md created with managed block', 'cannot create AGENTS.md managed block');
151
170
  }
152
171
  } else {
153
172
  let raw = readFileSync(agentsAbs, 'utf8');
package/lib/init.js CHANGED
@@ -21,7 +21,7 @@ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, r
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
26
 
27
27
  const CONTEXT_FILES = ['AGENTS.md', 'CLAUDE.md'];
@@ -80,6 +80,9 @@ function installClaudeAdapter(root, source, warnings) {
80
80
  return 'kept-divergent';
81
81
  }
82
82
 
83
+ // Escape check: a symlinked .claude/.claude/skills parent must not redirect
84
+ // the adapter creation outside the project root.
85
+ assertWithinRoot(root, claudeAbs);
83
86
  try {
84
87
  mkdirSync(dirname(claudeAbs), { recursive: true });
85
88
  symlinkSync('../../.agents/skills/archgen', claudeAbs);
@@ -116,6 +119,12 @@ export function initProject(projectDir, packageRoot, opts = {}) {
116
119
 
117
120
  const storeAbs = join(root, relJoin(STORE_REL));
118
121
 
122
+ // Supply-chain guards BEFORE any mutation: a symlinked store (a malicious
123
+ // clone can ship .agents/skills/archgen as a link to an unrelated dir) is
124
+ // refused, and the resolved real path must stay inside the project root.
125
+ assertNotSymlink(storeAbs);
126
+ assertWithinRoot(root, storeAbs);
127
+
119
128
  // 1. Canonical store — fingerprint before mutating (ownership check).
120
129
  const prev = lstatSafe(storeAbs);
121
130
  if (prev) {
@@ -129,6 +138,7 @@ export function initProject(projectDir, packageRoot, opts = {}) {
129
138
  mkdirSync(skillsParent, { recursive: true });
130
139
  const tmp = join(skillsParent, '.archgen-tmp-' + randomBytes(6).toString('hex'));
131
140
  cpSync(source, tmp, { recursive: true });
141
+ pruneDevOnly(tmp); // dev-only artifacts (scripts/test) never reach the installed store
132
142
  writeStamp(tmp, version);
133
143
  rmSync(storeAbs, { recursive: true, force: true });
134
144
  renameSync(tmp, storeAbs);
package/lib/install.js CHANGED
@@ -7,7 +7,7 @@ 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
13
 
@@ -36,6 +36,7 @@ export function globalTargets(home = homedir()) {
36
36
  }
37
37
 
38
38
  function record(manifest, kind, path) {
39
+ assertNotSymlink(manifest);
39
40
  const line = `${kind}\t${path}`;
40
41
  let dup = false;
41
42
  if (existsSync(manifest)) {
@@ -73,17 +74,31 @@ export function installGlobal(opts = {}) {
73
74
  const backups = [];
74
75
  let failures = 0;
75
76
 
76
- const targets = [...globalTargets(home), join(process.cwd(), '.github', 'skills')];
77
- for (const tdir of targets) {
77
+ const targets = [
78
+ ...globalTargets(home).map((d) => [d, home]),
79
+ [join(process.cwd(), '.github', 'skills'), process.cwd()],
80
+ ];
81
+ for (const [tdir, base] of targets) {
78
82
  const dest = join(tdir, 'archgen');
79
83
  if (!existsSync(tdir)) { rows.push(['SKIP', '-', tdir + ' (does not exist)']); continue; }
80
84
  try {
85
+ // Realpath-escape guard: a symlinked harness dir (e.g. ~/.claude -> /etc)
86
+ // must not redirect the install outside the base it belongs to.
87
+ assertWithinRoot(base, tdir);
81
88
  mkdirSync(tdir, { recursive: true }); // no-op when present
82
89
  if (opts.copy) {
83
90
  // Safety invariant: fingerprint dest BEFORE mutating; identical
84
91
  // (stamp-ignoring) skips like link mode, divergent moves into the
85
92
  // vault first — nothing is rmSync'd without a prior backup of it.
86
93
  const prev = lstatSafe(dest);
94
+ if (prev && prev.isSymbolicLink()) {
95
+ // A symlink at dest is only ours when it points at this source (a
96
+ // previous link-mode install). Any other link is foreign: replacing
97
+ // it would let a planted symlink steer the copy, so refuse.
98
+ let linkTarget = null;
99
+ try { linkTarget = resolve(dirname(dest), readlinkSync(dest)); } catch { /* unreadable */ }
100
+ if (linkTarget !== source) throw new Error(refuseSymlinkMessage(dest));
101
+ }
87
102
  let identical = false;
88
103
  if (prev && prev.isDirectory() && !prev.isSymbolicLink()) {
89
104
  try { identical = hashDir(dest) === hashDir(source); } catch { identical = false; }
@@ -100,7 +115,9 @@ export function installGlobal(opts = {}) {
100
115
  rows.push(['BACKED-UP', '-', abs]);
101
116
  }
102
117
  rmSync(dest, { force: true, recursive: true }); // no-op after a move; guards exotic-FS fallbacks
118
+ assertNotSymlink(dest); // TOCTOU re-check: nothing may have re-linked dest between backup and copy
103
119
  cpSync(source, dest, { recursive: true });
120
+ pruneDevOnly(dest); // dev-only artifacts (scripts/test) never reach global --copy trees
104
121
  stampNote(rows, dest, () => writeStamp(dest, version));
105
122
  record(manifest, 'copy', dest);
106
123
  rows.push(['OK', 'copy', dest]);
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,71 @@ 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
+ /** True when relpath `r` is (or lives inside) a dev-only excluded subtree. */
63
+ function isDevOnlyRelPath(r) {
64
+ for (const prefix of DEV_ONLY_RELPATHS) {
65
+ if (r === prefix || r.startsWith(prefix + '/')) return true;
66
+ }
67
+ return false;
68
+ }
69
+
70
+ /** Canonical refusal message for every symlink-write guard (CLI + install.sh). */
71
+ export function refuseSymlinkMessage(absPath) {
72
+ return 'Refusing to write through symlink at ' + absPath + ' — use --copy or remove symlink';
73
+ }
74
+
75
+ /**
76
+ * Supply-chain guard: lstat (never stat — the LINK itself, not its target) a
77
+ * path before writing it. Absent paths pass (safe to create); a symlink at
78
+ * the target throws so no write can ever be redirected through the link.
79
+ */
80
+ export function assertNotSymlink(absPath) {
81
+ let st = null;
82
+ try { st = lstatSync(absPath); } catch { return; }
83
+ if (st.isSymbolicLink()) throw new Error(refuseSymlinkMessage(absPath));
84
+ }
85
+
86
+ /**
87
+ * Realpath-escape guard: resolve `absPath` (climbing to the nearest existing
88
+ * ancestor when it does not exist yet, so a symlinked PARENT dir is caught
89
+ * before anything is created beneath it) and refuse when the real location
90
+ * lands outside `rootAbs`. Catches `.agents -> /elsewhere` style redirects
91
+ * that a final-component lstat cannot see.
92
+ */
93
+ export function assertWithinRoot(rootAbs, absPath) {
94
+ let rootReal;
95
+ try { rootReal = realpathSync(rootAbs); } catch { return; }
96
+ let probe = absPath;
97
+ for (;;) {
98
+ let exists = true;
99
+ try { lstatSync(probe); } catch { exists = false; }
100
+ if (exists) {
101
+ let real;
102
+ try { real = realpathSync(probe); } catch { return; } // broken chain: nothing writable through it
103
+ if (real !== rootReal && !real.startsWith(rootReal + sep)) {
104
+ throw new Error(refuseSymlinkMessage(absPath));
105
+ }
106
+ return;
107
+ }
108
+ const parent = dirname(probe);
109
+ if (parent === probe) return;
110
+ probe = parent;
111
+ }
112
+ }
113
+
44
114
  /** Version string of this CLI package (used for the in-store stamp). */
45
115
  export function cliVersion(packageRoot) {
46
116
  return JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version;
@@ -56,6 +126,10 @@ function byName(a, b) {
56
126
  * The version stamp is ALWAYS ignored on both sides of a comparison: it is
57
127
  * CLI metadata written at destination time (global symlink installs stamp the
58
128
  * shared real copy), never skill content, so it must not read as divergence.
129
+ * DEV_ONLY_RELPATHS (scripts/test) are ALSO always ignored on both sides: they
130
+ * are pruned from every distributed copy, so a source tree that still carries
131
+ * them (the dev-checkout skill/ fallback) must hash equal to an installed store
132
+ * that does not — while a real edit anywhere else must still read as divergence.
59
133
  * @param {string} dir absolute directory to hash
60
134
  * @param {{ignore?: string[]}} opts EXTRA relpaths to skip
61
135
  */
@@ -66,7 +140,7 @@ export function hashDir(dir, opts = {}) {
66
140
  const entries = readdirSync(abs, { withFileTypes: true }).sort((a, b) => byName(a.name, b.name));
67
141
  for (const ent of entries) {
68
142
  const r = rel ? rel + '/' + ent.name : ent.name;
69
- if (ignore.has(r)) continue;
143
+ if (ignore.has(r) || isDevOnlyRelPath(r)) continue;
70
144
  const p = join(abs, ent.name);
71
145
  if (ent.isSymbolicLink()) hash.update('l ' + r + ' -> ' + readlinkSync(p) + '\n');
72
146
  else if (ent.isDirectory()) { hash.update('d ' + r + '\n'); walk(p, r); }
@@ -76,6 +150,20 @@ export function hashDir(dir, opts = {}) {
76
150
  return hash.digest('hex');
77
151
  }
78
152
 
153
+ /**
154
+ * Remove every dev-only subtree (DEV_ONLY_RELPATHS) from an absolute skill-tree
155
+ * root; idempotent no-op when a subtree is absent. Called after EVERY copy of
156
+ * the skill into a distributed location (installed store, global --copy tree,
157
+ * vendor mirror) so dev-only artifacts never reach end users regardless of which
158
+ * source resolveSkillSource picked — vendor is already pruned by sync-vendor,
159
+ * but the dev-checkout skill/ fallback is not, and this normalizes both.
160
+ */
161
+ export function pruneDevOnly(absRoot) {
162
+ for (const rel of DEV_ONLY_RELPATHS) {
163
+ rmSync(join(absRoot, ...rel.split('/')), { recursive: true, force: true });
164
+ }
165
+ }
166
+
79
167
  /** Load `.archgen/.install-manifest.json`; null when absent/corrupt. */
80
168
  export function loadManifest(root) {
81
169
  const p = join(root, ...MANIFEST_REL.split('/'));
@@ -91,6 +179,7 @@ export function loadManifest(root) {
91
179
  export function saveManifest(root, manifest) {
92
180
  const p = join(root, ...MANIFEST_REL.split('/'));
93
181
  mkdirSync(dirname(p), { recursive: true });
182
+ assertNotSymlink(p);
94
183
  writeFileSync(p, JSON.stringify(manifest, null, 2) + '\n');
95
184
  }
96
185
 
@@ -121,7 +210,13 @@ export function moveToBackupInto(root, relPath, backupRootRel, ts = new Date().t
121
210
  const srcAbs = join(root, ...relPath.split('/'));
122
211
  try {
123
212
  renameSync(srcAbs, destAbs);
124
- } catch {
213
+ } catch (err) {
214
+ // Fallback copy+remove must NEVER run on a symlinked source: cpSync would
215
+ // read through the link and rmSync would remove it — rethrow instead so
216
+ // the caller refuses the replace rather than touching the link's target.
217
+ let st = null;
218
+ try { st = lstatSync(srcAbs); } catch { /* vanished mid-move */ }
219
+ if (st && st.isSymbolicLink()) throw err;
125
220
  cpSync(srcAbs, destAbs, { recursive: true });
126
221
  rmSync(srcAbs, { recursive: true, force: true });
127
222
  }
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
15
15
  import { dirname, join } from 'node:path';
16
- import { VERSION_FILE } from './store.js';
16
+ import { VERSION_FILE, assertNotSymlink } from './store.js';
17
17
 
18
18
  const SEMVER_RE = /^\d+\.\d+\.\d+$/;
19
19
 
@@ -42,6 +42,8 @@ export function readStamp(skillRoot) {
42
42
  */
43
43
  export function writeStamp(skillRoot, version) {
44
44
  const p = join(skillRoot, VERSION_FILE);
45
+ assertNotSymlink(skillRoot);
45
46
  mkdirSync(dirname(p), { recursive: true });
47
+ assertNotSymlink(p);
46
48
  writeFileSync(p, version + '\n', 'utf8');
47
49
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archgen-skill",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "Architecture generation & autonomous task execution for coding agents - conversational requirements in, dependency-ordered tasks out, executed wave-by-wave with verifier gates. Works with Claude Code, OpenCode, Cursor, Codex, Gemini CLI and any agentskills.io harness.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,9 +38,10 @@
38
38
  "bugs": {
39
39
  "url": "https://github.com/akashmaurya99/archgen/issues"
40
40
  },
41
- "//": "vendor/skills/archgen is synced from ../skills/archgen by scripts/sync-vendor.mjs (run automatically via prepublishOnly). The extension ships separately as a VS Code vsix.",
41
+ "//": "vendor/skills/archgen is a full mirror of ../../skill synced by scripts/sync-vendor.mjs (run automatically via prepublishOnly). The extension ships separately as a VS Code vsix.",
42
42
  "scripts": {
43
43
  "sync:config": "node scripts/sync-config.mjs",
44
+ "sync:check": "node scripts/sync-config.mjs --check",
44
45
  "sync": "node scripts/sync-config.mjs && node scripts/sync-vendor.mjs",
45
46
  "prepublishOnly": "node scripts/sync-config.mjs && node scripts/sync-vendor.mjs",
46
47
  "test": "node --test test/*.test.mjs"