archgen-skill 0.0.6 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,6 +42,13 @@ copies are backed up to `.archgen/.backup/<timestamp>/` before refresh, and lega
42
42
  dual-copy layouts are migrated to symlinks automatically. Verify anytime with
43
43
  `archgen-skill doctor`.
44
44
 
45
+ Every destructive step is behind a rail: the project root is asserted **before**
46
+ anything is written or deleted, a symlinked target is never written through, the
47
+ store swap is atomic (the old store is moved aside, not deleted, and restored if
48
+ the swap fails), and a restore puts back exactly the paths the snapshot recorded
49
+ — never a sibling skill. `archgen-skill uninstall <dir>` removes that project;
50
+ the global uninstall happens only when no project is named.
51
+
45
52
  **Or install globally** into every detected harness:
46
53
 
47
54
  ```bash
@@ -80,7 +87,7 @@ by git.
80
87
  | `npx archgen-skill init [dir]` | Prepare a project: canonical skill store at `.agents/skills/archgen`, Claude symlink adapter, AGENTS.md hub + CLAUDE.md bridge |
81
88
  | `npx archgen-skill install [--copy]` | Install into global harness skill dirs (symlinks by default, real copies with `--copy`) |
82
89
  | `npx archgen-skill uninstall` | Remove globally-installed copies (manifest-recorded, safe removal) |
83
- | `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 |
90
+ | `npx archgen-skill uninstall [dir]` | Remove a **project** install (same as `--project [dir]`): strips managed blocks, symlink, and store **only if unmodified** — `.archgen/` feature folders and all user content are preserved |
84
91
  | `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) |
85
92
  | `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 |
86
93
  | `npx archgen-skill restore` | List backup snapshots; `--snapshot <ts>` restores one (current state is backed up first) |
package/bin/archgen.mjs CHANGED
@@ -22,7 +22,7 @@ import { uninstallProject } from '../lib/uninstall-project.js';
22
22
  import { restoreMain } from '../lib/restore.js';
23
23
  import { migrateMain } from '../lib/migrate.js';
24
24
  import { compareSemver, fetchLatestVersion } from '../lib/version.js';
25
- import { npmInvocation, whichOnPath } from '../lib/spawn-npm.js';
25
+ import { npmInvocation, whichOnPath, globalBinPath } from '../lib/spawn-npm.js';
26
26
  import { spawnSync } from 'node:child_process';
27
27
  import { readFileSync } from 'node:fs';
28
28
 
@@ -35,7 +35,13 @@ function cliVersion() {
35
35
  function runInitAndDoctor(dir) {
36
36
  const r = initProject(dir, PACKAGE_ROOT);
37
37
  console.log(' = store refreshed at ' + r.storePath);
38
- doctorProject(dir, PACKAGE_ROOT, {});
38
+ const d = doctorProject(dir, PACKAGE_ROOT, {});
39
+ // A failed doctor must fail the update too: the project was NOT brought to
40
+ // the new version.
41
+ if (d.failures > 0) {
42
+ console.error(' ! doctor reported ' + d.failures + ' failure(s)');
43
+ process.exitCode = 1;
44
+ }
39
45
  }
40
46
 
41
47
  const HELP = `archgen — architecture generation & autonomous task execution for coding agents
@@ -45,6 +51,8 @@ Usage:
45
51
  store at .agents/skills/archgen, symlink
46
52
  .claude/skills/archgen -> ../../.agents/skills/archgen,
47
53
  and generate AGENTS.md / CLAUDE.md pointer blocks.
54
+ [--project <dir>] the project directory (same as the positional
55
+ form; give it once, not both)
48
56
  [--force] proceed automatically; divergent copies are
49
57
  backed up to .archgen/.backup/ (never destroyed)
50
58
  [--no-codegraph] skip the CodeGraph bootstrap
@@ -52,13 +60,17 @@ Usage:
52
60
  npx archgen-skill doctor [dir] Health-check a project install: store integrity,
53
61
  version stamp, claude link, managed blocks,
54
62
  manifest. Repairs safe issues in place.
63
+ [--project <dir>] the project directory (same as the positional form)
55
64
  [--check] report only - change nothing
56
65
  npx archgen-skill install Install into global harness skill dirs
57
66
  [--copy] real copies instead of symlinks
58
67
  [--uninstall] remove manifest-recorded entries
59
68
  [--no-codegraph] skip the CodeGraph bootstrap
60
69
  [--no-mcp] skip wiring the archgen MCP server
61
- npx archgen-skill uninstall Remove globally-installed copies (manifest-based)
70
+ npx archgen-skill uninstall [dir] Remove archgen from a project (the same
71
+ as the positional form); without a directory
72
+ the globally-installed copies are removed
73
+ (manifest-based)
62
74
  --project [dir] remove archgen from a project: claude link,
63
75
  managed blocks, canonical store (kept when
64
76
  customized) and manifest; .archgen feature
@@ -89,8 +101,63 @@ function flag(name) {
89
101
  return true;
90
102
  }
91
103
 
92
- function positionalDir() {
93
- return rest[0] ? resolve(rest[0]) : process.cwd();
104
+ /**
105
+ * Consume an option that takes a value: `--name <value>` or `--name=<value>`.
106
+ *
107
+ * Returns null when the option is absent, '' when it is present with no
108
+ * value ("use the default"), and the value otherwise.
109
+ */
110
+ function takeValue(name) {
111
+ const eq = rest.findIndex((a) => a.startsWith(name + '='));
112
+ if (eq !== -1) {
113
+ const v = rest[eq].slice(name.length + 1);
114
+ rest.splice(eq, 1);
115
+ return v;
116
+ }
117
+ const i = rest.indexOf(name);
118
+ if (i === -1) return null;
119
+ rest.splice(i, 1);
120
+ const v = rest[i];
121
+ if (v === undefined || v.startsWith('-')) return '';
122
+ rest.splice(i, 1);
123
+ return v;
124
+ }
125
+
126
+ /**
127
+ * The project directory for a command.
128
+ *
129
+ * Accepts a POSITIONAL dir (`init myproj`) and `--project <dir>` alike. The
130
+ * same flag name is used by `uninstall` and `restore`, and the MCP server's
131
+ * own commands take `--project` too — so a user who typed one form must
132
+ * never get a directory literally named "--project" created in their cwd.
133
+ *
134
+ * Extra positional arguments are REFUSED: silently ignoring the second one
135
+ * would install into a directory the user did not name.
136
+ */
137
+ function targetDir() {
138
+ const explicit = takeValue('--project');
139
+ const positionals = rest.filter((a) => !a.startsWith('-'));
140
+ if (positionals.length > 1) {
141
+ throw new Error(`unexpected argument: ${positionals[1]} — give the project directory once`);
142
+ }
143
+ const positional = positionals[0];
144
+ const explicitDir = explicit === null ? null : (explicit === '' ? process.cwd() : resolve(explicit));
145
+ if (explicitDir !== null && positional !== undefined && explicitDir !== resolve(positional)) {
146
+ throw new Error('give the project directory once: either positionally or with --project');
147
+ }
148
+ return explicitDir ?? (positional !== undefined ? resolve(positional) : process.cwd());
149
+ }
150
+
151
+ /**
152
+ * Refuse an option we do not understand.
153
+ *
154
+ * An unknown flag is a TYPO. It is never a directory name — creating a
155
+ * directory called "--no-mcp" because the flag was misspelled is exactly the
156
+ * kind of silent mess this check exists to prevent.
157
+ */
158
+ function assertNoUnknownFlags() {
159
+ const unknown = rest.find((a) => a.startsWith('-'));
160
+ if (unknown) throw new Error(`unknown option: ${unknown}`);
94
161
  }
95
162
 
96
163
  try {
@@ -99,7 +166,9 @@ try {
99
166
  flag('--force');
100
167
  const noCodegraph = flag('--no-codegraph');
101
168
  const noMcp = flag('--no-mcp');
102
- const r = initProject(positionalDir(), PACKAGE_ROOT, { codegraph: !noCodegraph, mcp: !noMcp });
169
+ const dir = targetDir();
170
+ assertNoUnknownFlags();
171
+ const r = initProject(dir, PACKAGE_ROOT, { codegraph: !noCodegraph, mcp: !noMcp });
103
172
  console.log('archgen: project ready (single-store layout)');
104
173
  console.log(' + store ' + r.storePath);
105
174
  if (r.claudeLink === 'created') console.log(' + link .claude/skills/archgen -> ../../.agents/skills/archgen');
@@ -117,7 +186,9 @@ try {
117
186
  }
118
187
  case 'doctor': {
119
188
  const check = flag('--check');
120
- const r = doctorProject(positionalDir(), PACKAGE_ROOT, { check });
189
+ const dir = targetDir();
190
+ assertNoUnknownFlags();
191
+ const r = doctorProject(dir, PACKAGE_ROOT, { check });
121
192
  console.log('archgen doctor — ' + r.root + (check ? ' (check mode: no changes made)' : ''));
122
193
  for (const c of r.checks) console.log(` [${c.status.toLowerCase().padEnd(9)}] ${c.msg}`);
123
194
  const t = r.tally;
@@ -129,7 +200,11 @@ try {
129
200
  const copy = flag('--copy');
130
201
  const noCodegraph = flag('--no-codegraph');
131
202
  const noMcp = flag('--no-mcp');
132
- if (flag('--uninstall')) { uninstall(); break; }
203
+ const uninstallMode = flag('--uninstall');
204
+ assertNoUnknownFlags();
205
+ const stray = rest.find((a) => !a.startsWith('-'));
206
+ if (stray) throw new Error(`unexpected argument: ${stray} — install takes no directory`);
207
+ if (uninstallMode) { uninstall(); break; }
133
208
  const r = installGlobal({ copy, codegraph: !noCodegraph, mcp: !noMcp });
134
209
  console.log('archgen: global harness install (mode: ' + (copy ? 'copy' : 'symlink') + ')');
135
210
  for (const [status, mode, path] of r.rows) console.log(` ${status.padEnd(7)} ${mode.padEnd(5)} ${path}`);
@@ -138,8 +213,25 @@ try {
138
213
  break;
139
214
  }
140
215
  case 'uninstall': {
141
- if (flag('--project')) {
142
- const r = uninstallProject(positionalDir(), PACKAGE_ROOT);
216
+ // `--project` may carry a directory (`uninstall --project myproj`) or
217
+ // stand alone (`uninstall --project` → this directory). A POSITIONAL
218
+ // directory (`uninstall myproj`) is a project uninstall as well: the
219
+ // GLOBAL uninstall happens only when no project was named at all.
220
+ const explicit = takeValue('--project');
221
+ const positionals = rest.filter((a) => !a.startsWith('-'));
222
+ assertNoUnknownFlags();
223
+ if (positionals.length > 1) {
224
+ throw new Error(`unexpected argument: ${positionals[1]} — give the project directory once`);
225
+ }
226
+ const positional = positionals[0];
227
+ const projectTarget = explicit !== null
228
+ ? (explicit === '' ? process.cwd() : resolve(explicit))
229
+ : (positional !== undefined ? resolve(positional) : null);
230
+ if (explicit !== null && positional !== undefined && projectTarget !== resolve(positional)) {
231
+ throw new Error('give the project directory once: either positionally or with --project');
232
+ }
233
+ if (projectTarget !== null) {
234
+ const r = uninstallProject(projectTarget, PACKAGE_ROOT);
143
235
  console.log('archgen: project uninstall — ' + r.root);
144
236
  if (r.linkRemoved) console.log(' - .claude/skills/archgen (link)');
145
237
  if (r.strippedBlocks.length) console.log(' - managed blocks stripped from ' + r.strippedBlocks.join(', '));
@@ -166,7 +258,8 @@ try {
166
258
  break;
167
259
  }
168
260
  case 'update': {
169
- const dir = positionalDir();
261
+ const dir = targetDir();
262
+ assertNoUnknownFlags();
170
263
  const current = cliVersion();
171
264
  console.log('archgen update — running v' + current + ', checking npm registry…');
172
265
  const latest = fetchLatestVersion('archgen-skill');
@@ -175,6 +268,14 @@ try {
175
268
  break;
176
269
  }
177
270
  const cmp = compareSemver(latest, current);
271
+ if (cmp === null) {
272
+ // The registry answered with something that is not a semver (e.g. a
273
+ // "beta" dist-tag). It is NOT "already the latest version" — refuse
274
+ // instead, and never interpolate it into a command line.
275
+ console.error(' ! registry returned a non-semver version string; refusing: ' + JSON.stringify(latest));
276
+ process.exitCode = 1;
277
+ break;
278
+ }
178
279
  if (cmp === 0) {
179
280
  console.log(' = already on the latest published version (' + latest + ').');
180
281
  console.log(' Refreshing this project against it anyway…');
@@ -204,7 +305,9 @@ try {
204
305
  break;
205
306
  }
206
307
  // The running process is the OLD code; re-exec the freshly installed binary.
207
- const freshBin = whichOnPath('archgen-skill');
308
+ // The PATH search would find the FIRST match — often the OLD global
309
+ // install — so the binary npm actually wrote is preferred.
310
+ const freshBin = globalBinPath('archgen-skill') ?? whichOnPath('archgen-skill');
208
311
  if (!freshBin) {
209
312
  console.log('\nUpgraded to v' + latest + '. Now finish per project with:\n npx archgen-skill@latest init');
210
313
  break;
@@ -218,7 +321,13 @@ try {
218
321
  process.exitCode = upInit.status ?? 1;
219
322
  break;
220
323
  }
221
- spawnSync(freshBin, ['doctor', dir], { stdio: 'inherit' });
324
+ const upDoctor = spawnSync(freshBin, ['doctor', dir], { stdio: 'inherit' });
325
+ if (upDoctor.status !== 0) {
326
+ // A failed doctor must fail the update too — the project was NOT
327
+ // brought to the new version.
328
+ console.error(' ! doctor failed during update (see message above)');
329
+ process.exitCode = upDoctor.status ?? 1;
330
+ }
222
331
  break;
223
332
  }
224
333
  case '--version': case '-v': {
@@ -243,5 +352,6 @@ function uninstall() {
243
352
  const r = uninstallGlobal();
244
353
  if (r.noop) { console.log('archgen: nothing to uninstall (no manifest found).'); return; }
245
354
  console.log(`archgen: removed ${r.removed} entr${r.removed === 1 ? 'y' : 'ies'}` + (r.failed ? `, ${r.failed} FAILED` : ''));
355
+ for (const b of r.backups ?? []) console.log(' ~ backup ' + b + ' (edited copy saved before removal)');
246
356
  if (r.failed > 0) process.exitCode = 1;
247
357
  }
package/lib/block.js CHANGED
@@ -16,7 +16,7 @@
16
16
  // place on the next init/doctor pass and detectBlockVersion() lets doctor
17
17
  // report stale formats precisely.
18
18
 
19
- import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
19
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
20
20
  import { loadConfig } from './config.js';
21
21
  import { assertNotSymlink } from './store.js';
22
22
 
@@ -171,11 +171,14 @@ export function upsertBlock(existing, block, eol = '\n') {
171
171
  if (s === -1 || e === -1) {
172
172
  throw new Error('found only one archgen marker - fix or remove it manually');
173
173
  }
174
+ const prefix = trimmed.slice(0, s);
174
175
  const tail = stripCompleteBlocks(trimmed.slice(e + END.length));
175
- if (tail.includes(START) || tail.includes(END)) {
176
+ // An orphan marker ANYWHERE (before the first block as well as after it)
177
+ // would survive the rewrite and leave the file broken — refuse instead.
178
+ if (prefix.includes(START) || prefix.includes(END) || tail.includes(START) || tail.includes(END)) {
176
179
  throw new Error('found only one archgen marker - fix or remove it manually');
177
180
  }
178
- return trimmed.slice(0, s) + fresh + tail;
181
+ return prefix + fresh + tail;
179
182
  }
180
183
 
181
184
  const startIdx = trimmed.indexOf(START);
@@ -209,13 +212,37 @@ function splitBom(raw) {
209
212
  return { bom, body: bom ? raw.slice(1) : raw };
210
213
  }
211
214
 
212
- /** Write via sibling temp file + rename so readers never observe partial content. */
213
- function atomicWrite(absPath, data) {
214
- const tmp = absPath + '.tmp-' + process.pid + '-' + Date.now();
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' });
218
- renameSync(tmp, absPath);
215
+ /**
216
+ * Write via sibling temp file + rename so readers never observe partial content.
217
+ * @param {string} absPath target file
218
+ * @param {string} data contents
219
+ */
220
+ export function atomicWrite(absPath, data) {
221
+ let tmp = absPath + '.tmp-' + process.pid + '-' + Date.now();
222
+ for (let attempt = 0; ; attempt++) {
223
+ try {
224
+ // 'wx' (O_CREAT|O_EXCL): if the tmp name already exists — e.g. an attacker
225
+ // planted a symlink there to redirect the write — fail instead of following.
226
+ writeFileSync(tmp, data, { flag: 'wx' });
227
+ break;
228
+ } catch (e) {
229
+ // A collision (two writes inside the same millisecond) is not a failure:
230
+ // retry with a fresh name instead of failing the whole write.
231
+ if (e && e.code === 'EEXIST' && attempt < 10) {
232
+ tmp = absPath + '.tmp-' + process.pid + '-' + Date.now() + '-' + (attempt + 1);
233
+ continue;
234
+ }
235
+ throw e;
236
+ }
237
+ }
238
+ try {
239
+ renameSync(tmp, absPath);
240
+ } catch (e) {
241
+ // Never leave the temp file behind: a failed rename must not litter the
242
+ // project with half-written siblings.
243
+ try { unlinkSync(tmp); } catch { /* original intact; leftover tmp is harmless */ }
244
+ throw e;
245
+ }
219
246
  }
220
247
 
221
248
  /**
@@ -250,6 +277,20 @@ export function importsAgents(absPath) {
250
277
  return body.split(/\r?\n/).some((l) => l.trim() === '@AGENTS.md');
251
278
  }
252
279
 
280
+ /**
281
+ * True when the file carries a COMPLETE managed block (both markers).
282
+ *
283
+ * A file with a hand-written `@AGENTS.md` import has none — that import is
284
+ * the user's, not ours, and must survive untouched. A file WITH a managed
285
+ * block is ours: its provenance stamp is refreshed on every init, exactly
286
+ * like the AGENTS.md block.
287
+ */
288
+ export function hasManagedBlock(absPath) {
289
+ let raw = '';
290
+ try { raw = readFileSync(absPath, 'utf8'); } catch { return false; }
291
+ return raw.includes(START) && raw.includes(END);
292
+ }
293
+
253
294
  /** Strip the managed block from a file on disk, preserving BOM. */
254
295
  export function stripManagedFile(absPath) {
255
296
  assertNotSymlink(absPath);
package/lib/bootstrap.js CHANGED
@@ -11,8 +11,9 @@
11
11
  // 2. ensures CodeGraph: its CLI, its MCP entry, and the project index
12
12
  //
13
13
  // NEVER FATAL: no npm, no network, a read-only config, or a missing MCP
14
- // server must still leave a working skill install. Failures are REPORTED,
15
- // never thrown.
14
+ // server must still leave a working skill install. Failures are REPORTED to
15
+ // the caller (ok:false + detail) and rendered as a warning by it — never
16
+ // thrown, and never swallowed.
16
17
  import { bootstrapMcp, resolveMcpBinary } from './mcp-server.js';
17
18
 
18
19
  /**
@@ -63,8 +64,11 @@ export function bootstrapServers(opts) {
63
64
  });
64
65
 
65
66
  if (!r.report) {
67
+ // The bootstrap did NOT run: report it as a failure so the caller tells
68
+ // the user. The skill install itself still succeeded — this is a warning,
69
+ // not a fatal error — but it must never be swallowed silently.
66
70
  return {
67
- ok: true, // never fatal — the skill install itself succeeded
71
+ ok: false,
68
72
  detail: `server bootstrap incomplete: ${r.detail ?? 'unknown error'}`,
69
73
  };
70
74
  }
@@ -96,7 +100,7 @@ export function bootstrapServers(opts) {
96
100
  return {
97
101
  ok: false,
98
102
  report,
99
- detail: 'one or more server bootstrap steps failed',
103
+ detail: r.detail ? `server bootstrap incomplete: ${r.detail}` : 'one or more server bootstrap steps failed',
100
104
  };
101
105
  }
102
106
  return { ok: true, report };
package/lib/config.js CHANGED
@@ -2,8 +2,9 @@
2
2
  //
3
3
  // WHY: the version, managed-block marker strings, and well-known filenames
4
4
  // were hardcoded independently in block.js, store.js, install.js, install.sh,
5
- // and SKILL.md frontmatter — and drifted (SKILL.md advertised 1.0.0 while the
6
- // CLI shipped 0.0.x). The canonical file lives at <repo-root>/archgen.config.json;
5
+ // and SKILL.md frontmatter — and drifted (SKILL.md advertised a version the
6
+ // CLI had never shipped). The canonical file lives at
7
+ // <repo-root>/archgen.config.json;
7
8
  // a byte-identical derived copy ships inside the published npm package at
8
9
  // vendor/skills/archgen/archgen.config.json (kept in sync by
9
10
  // scripts/sync-config.mjs + scripts/sync-vendor.mjs).