archgen-skill 0.0.2 → 0.0.4

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 (34) hide show
  1. package/README.md +29 -3
  2. package/bin/archgen.mjs +146 -12
  3. package/lib/block.js +197 -8
  4. package/lib/config.js +96 -0
  5. package/lib/doctor.js +283 -0
  6. package/lib/init.js +130 -27
  7. package/lib/install.js +105 -14
  8. package/lib/migrate.js +88 -0
  9. package/lib/migrations/001-stamp-provenance.mjs +77 -0
  10. package/lib/migrations/index.mjs +170 -0
  11. package/lib/restore.js +239 -0
  12. package/lib/store.js +161 -0
  13. package/lib/uninstall-project.js +95 -0
  14. package/lib/version-stamp.js +47 -0
  15. package/lib/version.js +23 -0
  16. package/package.json +37 -9
  17. package/vendor/skills/archgen/SKILL.md +252 -73
  18. package/vendor/skills/archgen/archgen.config.json +16 -0
  19. package/vendor/skills/archgen/assets/skill-registry.json +4 -3
  20. package/vendor/skills/archgen/references/artifact-templates.md +251 -7
  21. package/vendor/skills/archgen/references/interview.md +180 -16
  22. package/vendor/skills/archgen/references/mcp-and-skills.md +88 -17
  23. package/vendor/skills/archgen/references/orchestration.md +102 -39
  24. package/vendor/skills/archgen/references/platforms.md +18 -14
  25. package/vendor/skills/archgen/scripts/doc-index.mjs +457 -0
  26. package/vendor/skills/archgen/scripts/impact.mjs +14 -1
  27. package/vendor/skills/archgen/scripts/lib/graph.mjs +82 -3
  28. package/vendor/skills/archgen/scripts/lib/scope.mjs +65 -0
  29. package/vendor/skills/archgen/scripts/lib/yaml.mjs +19 -3
  30. package/vendor/skills/archgen/scripts/plan-graph.mjs +329 -0
  31. package/vendor/skills/archgen/scripts/set-status.mjs +26 -6
  32. package/vendor/skills/archgen/scripts/update-agents.mjs +204 -0
  33. package/vendor/skills/archgen/scripts/validate.mjs +18 -1
  34. package/vendor/skills/archgen/scripts/verify-plan.mjs +8 -3
package/README.md CHANGED
@@ -21,13 +21,27 @@ Antigravity** — and any [agentskills.io](https://agentskills.io)-compatible ha
21
21
 
22
22
  ## Install
23
23
 
24
- **Prepare a project** (recommended) — copies the skill locally + writes
25
- `AGENTS.md` / `CLAUDE.md` pointer blocks so every harness auto-discovers it:
24
+ **Prepare a project** (recommended) — installs ONE canonical skill copy plus
25
+ harness bridges so every agent auto-discovers it:
26
26
 
27
27
  ```bash
28
28
  npx archgen-skill init
29
29
  ```
30
30
 
31
+ What `init` writes (nothing is ever duplicated):
32
+
33
+ | Path | What |
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) |
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`.
44
+
31
45
  **Or install globally** into every detected harness:
32
46
 
33
47
  ```bash
@@ -56,11 +70,23 @@ architecture contract, docs, ADRs, plans, and a `tasks.yaml` task graph — vers
56
70
 
57
71
  | Command | What it does |
58
72
  | --- | --- |
59
- | `npx archgen-skill init [dir]` | Copy the skill into `.agents/skills/archgen` + `.claude/skills/archgen` and write AGENTS.md / CLAUDE.md pointer blocks |
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 |
60
74
  | `npx archgen-skill install [--copy]` | Install into global harness skill dirs (symlinks by default, real copies with `--copy`) |
61
75
  | `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) |
62
78
  | `npx archgen-skill --help` | Help |
63
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.
89
+
64
90
  ## Why archgen
65
91
 
66
92
  AI coding agents fail at scale for predictable reasons: plans with hidden cycles,
package/bin/archgen.mjs CHANGED
@@ -1,11 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  // archgen — npm CLI for the archgen skill.
3
3
  //
4
- // npx archgen-skill init Install skill into THIS project + write
5
- // AGENTS.md / CLAUDE.md context pointers
4
+ // npx archgen-skill init [dir] ONE canonical skill store + claude symlink
5
+ // adapter + AGENTS.md / CLAUDE.md context pointers
6
+ // npx archgen-skill doctor [dir] Verify/repair a project install (--check = report only)
6
7
  // npx archgen-skill install Install skill into global harness dirs
7
8
  // (Claude Code, OpenCode, Cursor, agentskills)
8
9
  // npx archgen-skill uninstall Remove globally-installed copies (manifest-based)
10
+ // --project [dir] Remove archgen from a specific project instead
11
+ // npx archgen-skill restore List/restore archgen backups (project + global vaults)
12
+ // npx archgen-skill migrate [dir] Evolve generated-artifact formats (--check default)
9
13
  //
10
14
  // Zero dependencies. Cross-platform (Windows included — no bash required).
11
15
 
@@ -13,20 +17,60 @@ import { dirname, resolve } from 'node:path';
13
17
  import { fileURLToPath } from 'node:url';
14
18
  import { installGlobal, uninstallGlobal } from '../lib/install.js';
15
19
  import { initProject } from '../lib/init.js';
20
+ import { doctorProject } from '../lib/doctor.js';
21
+ import { uninstallProject } from '../lib/uninstall-project.js';
22
+ import { restoreMain } from '../lib/restore.js';
23
+ import { migrateMain } from '../lib/migrate.js';
24
+ import { compareSemver, fetchLatestVersion } from '../lib/version.js';
25
+ import { spawnSync } from 'node:child_process';
26
+ import { readFileSync } from 'node:fs';
16
27
 
17
28
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
18
29
 
30
+ function cliVersion() {
31
+ return JSON.parse(readFileSync(resolve(PACKAGE_ROOT, 'package.json'), 'utf8')).version;
32
+ }
33
+
34
+ function runInitAndDoctor(dir) {
35
+ const r = initProject(dir, PACKAGE_ROOT);
36
+ console.log(' = store refreshed at ' + r.storePath);
37
+ doctorProject(dir, PACKAGE_ROOT, {});
38
+ }
39
+
19
40
  const HELP = `archgen — architecture generation & autonomous task execution for coding agents
20
41
 
21
42
  Usage:
22
- npx archgen-skill init [dir] Prepare a project: copy the skill into
23
- .agents/skills/archgen + .claude/skills/archgen
24
- and generate AGENTS.md / CLAUDE.md pointer blocks
25
- so every agent harness auto-discovers it.
43
+ npx archgen-skill init [dir] Prepare a project: create the canonical skill
44
+ store at .agents/skills/archgen, symlink
45
+ .claude/skills/archgen -> ../../.agents/skills/archgen,
46
+ and generate AGENTS.md / CLAUDE.md pointer blocks.
47
+ [--force] proceed automatically; divergent copies are
48
+ backed up to .archgen/.backup/ (never destroyed)
49
+ npx archgen-skill doctor [dir] Health-check a project install: store integrity,
50
+ version stamp, claude link, managed blocks,
51
+ manifest. Repairs safe issues in place.
52
+ [--check] report only - change nothing
26
53
  npx archgen-skill install Install into global harness skill dirs
27
54
  [--copy] real copies instead of symlinks
28
55
  [--uninstall] remove manifest-recorded entries
29
- npx archgen-skill uninstall Alias for install --uninstall
56
+ npx archgen-skill uninstall Remove globally-installed copies (manifest-based)
57
+ --project [dir] remove archgen from a project: claude link,
58
+ managed blocks, canonical store (kept when
59
+ customized) and manifest; .archgen feature
60
+ folders and backups are preserved
61
+ npx archgen-skill restore List backup snapshots across the project and
62
+ global vaults; nothing is restored by default
63
+ [--snapshot <ts>] restore that snapshot (current state is backed
64
+ up first); --project <dir> / --global scope it
65
+ npx archgen-skill migrate [dir] Evolve generated-artifact formats in place;
66
+ --check (default) lists pending migrations
67
+ [--apply] apply them (every touched file is backed up)
68
+ [--migration <id>] target a single migration
69
+ npx archgen-skill update [dir] Check npm for a newer archgen-skill, upgrade
70
+ the global install when outdated, then
71
+ re-init this project + doctor against the
72
+ new version. No-op when already latest.
73
+ npx archgen-skill --version Print the running CLI version
30
74
  npx archgen-skill --help This help
31
75
 
32
76
  Docs: https://github.com/akashmaurya99/archgen`;
@@ -40,19 +84,40 @@ function flag(name) {
40
84
  return true;
41
85
  }
42
86
 
87
+ function positionalDir() {
88
+ return rest[0] ? resolve(rest[0]) : process.cwd();
89
+ }
90
+
43
91
  try {
44
92
  switch (cmd) {
45
93
  case 'init': {
46
- const dir = rest[0] ? resolve(rest[0]) : process.cwd();
47
- const r = initProject(dir, PACKAGE_ROOT);
48
- console.log('archgen: skill installed into project');
49
- for (const p of r.skillCopies) console.log(' + ' + p);
94
+ flag('--force');
95
+ const r = initProject(positionalDir(), PACKAGE_ROOT);
96
+ console.log('archgen: project ready (single-store layout)');
97
+ console.log(' + store ' + r.storePath);
98
+ if (r.claudeLink === 'created') console.log(' + link .claude/skills/archgen -> ../../.agents/skills/archgen');
99
+ else if (r.claudeLink === 'existing') console.log(' = link .claude/skills/archgen (already points at the store)');
100
+ else if (r.claudeLink === 'migrated') console.log(' ~ link .claude/skills/archgen migrated from copy to symlink');
101
+ else if (r.claudeLink === 'kept-divergent') console.log(' ! kept .claude/skills/archgen (divergent - left untouched)');
102
+ else console.log(' ! skip .claude/skills/archgen (symlink unavailable)');
103
+ for (const b of r.backups) console.log(' ~ backup ' + b + ' (previous store moved aside)');
50
104
  for (const f of r.contextFiles) {
51
105
  console.log(` ${r.createdContextFiles.includes(f) ? 'created' : 'updated'} ${f} (archgen pointer block)`);
52
106
  }
107
+ for (const w of r.warnings) console.log(' note: ' + w);
53
108
  console.log('\nNext: open this project in your agent and say "generate architecture" or "add feature X".');
54
109
  break;
55
110
  }
111
+ case 'doctor': {
112
+ const check = flag('--check');
113
+ const r = doctorProject(positionalDir(), PACKAGE_ROOT, { check });
114
+ console.log('archgen doctor — ' + r.root + (check ? ' (check mode: no changes made)' : ''));
115
+ for (const c of r.checks) console.log(` [${c.status.toLowerCase().padEnd(9)}] ${c.msg}`);
116
+ const t = r.tally;
117
+ console.log(`Summary: ${t.OK} ok, ${t.FIXED} fixed, ${t['WOULD-FIX']} would-fix, ${t.WARN} warning(s), ${t.FAIL} failure(s)`);
118
+ if (r.failures > 0) process.exitCode = 1;
119
+ break;
120
+ }
56
121
  case 'install': {
57
122
  const copy = flag('--copy');
58
123
  if (flag('--uninstall')) { uninstall(); break; }
@@ -64,7 +129,76 @@ try {
64
129
  break;
65
130
  }
66
131
  case 'uninstall': {
67
- uninstall();
132
+ if (flag('--project')) {
133
+ const r = uninstallProject(positionalDir(), PACKAGE_ROOT);
134
+ console.log('archgen: project uninstall — ' + r.root);
135
+ if (r.linkRemoved) console.log(' - .claude/skills/archgen (link)');
136
+ if (r.strippedBlocks.length) console.log(' - managed blocks stripped from ' + r.strippedBlocks.join(', '));
137
+ if (r.storeRemoved) console.log(' - .agents/skills/archgen (unmodified store)');
138
+ if (r.storeKept) console.log(' = .agents/skills/archgen KEPT (customized)');
139
+ if (r.manifestRemoved) console.log(' - .archgen/.install-manifest.json');
140
+ for (const w of r.warnings) console.log(' note: ' + w);
141
+ console.log(' .archgen feature folders and backups are preserved.');
142
+ } else {
143
+ uninstall();
144
+ }
145
+ break;
146
+ }
147
+ case 'restore': {
148
+ // restoreMain parses its own flags and returns its own exit code.
149
+ process.exitCode = await restoreMain(rest);
150
+ break;
151
+ }
152
+ case 'migrate': {
153
+ // migrateMain parses its own args and returns its own exit code
154
+ // (0 ok, 1 apply failure, 2 usage error).
155
+ process.exitCode = await migrateMain(rest);
156
+ break;
157
+ }
158
+ case 'update': {
159
+ const dir = positionalDir();
160
+ const current = cliVersion();
161
+ console.log('archgen update — running v' + current + ', checking npm registry…');
162
+ const latest = fetchLatestVersion('archgen-skill');
163
+ if (!latest) {
164
+ console.log(' ! could not reach the npm registry (offline?). Nothing changed.');
165
+ break;
166
+ }
167
+ const cmp = compareSemver(latest, current);
168
+ if (cmp === 0) {
169
+ console.log(' = already on the latest published version (' + latest + ').');
170
+ console.log(' Refreshing this project against it anyway…');
171
+ runInitAndDoctor(dir);
172
+ break;
173
+ }
174
+ if (cmp < 0) {
175
+ console.log(' ↑ running v' + current + ' is newer than npm (' + latest + ') — not published yet.');
176
+ console.log(' Refreshing this project against the running version…');
177
+ runInitAndDoctor(dir);
178
+ break;
179
+ }
180
+ console.log(' ↑ newer version available: ' + latest);
181
+ console.log(' upgrading global install: npm install -g archgen-skill@' + latest);
182
+ const up = spawnSync('npm', ['install', '-g', 'archgen-skill@' + latest], { stdio: 'inherit' });
183
+ if (up.status !== 0) {
184
+ console.error(' ! global upgrade failed — run it manually, then re-run: archgen-skill update');
185
+ process.exitCode = 1;
186
+ break;
187
+ }
188
+ // The running process is the OLD code; re-exec the freshly installed binary.
189
+ const bin = spawnSync('command', ['-v', 'archgen-skill'], { encoding: 'utf8', shell: true });
190
+ const freshBin = (bin.stdout || '').trim();
191
+ if (!freshBin) {
192
+ console.log('\nUpgraded to v' + latest + '. Now finish per project with:\n npx archgen-skill@latest init');
193
+ break;
194
+ }
195
+ console.log('\nre-running init + doctor with the new binary (' + latest + ')…');
196
+ spawnSync(freshBin, ['init', dir], { stdio: 'inherit' });
197
+ spawnSync(freshBin, ['doctor', dir], { stdio: 'inherit' });
198
+ break;
199
+ }
200
+ case '--version': case '-v': {
201
+ console.log(cliVersion());
68
202
  break;
69
203
  }
70
204
  case '--help': case '-h': case undefined: {
package/lib/block.js CHANGED
@@ -4,17 +4,84 @@
4
4
  // archgen writes lives between START/END markers; re-runs replace the block
5
5
  // in place, uninstalls strip it, and anything the user wrote outside the
6
6
  // markers is untouched.
7
+ //
8
+ // AGENTS.md carries the full pointer block with an embedded features registry
9
+ // (between FEATURES_START/FEATURES_END). CLAUDE.md carries a one-line
10
+ // `@AGENTS.md` bridge inside the same managed markers.
11
+ //
12
+ // Block provenance: the FIRST line inside every managed block is a machine-
13
+ // readable `<!-- archgen:block vX.Y.Z -->` stamp sourced from the canonical
14
+ // config (lib/config.js loadConfig()). upsertBlock normalizes every block it
15
+ // writes to carry the CURRENT stamp, so legacy unversioned blocks upgrade in
16
+ // place on the next init/doctor pass and detectBlockVersion() lets doctor
17
+ // report stale formats precisely.
18
+
19
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
20
+ import { loadConfig } from './config.js';
21
+
22
+ // Markers + block-format version come from the single source of truth.
23
+ // Module-level load is safe: config resolution is module-relative and
24
+ // deterministic, and a missing/corrupt config fails loudly at import by design.
25
+ const CONFIG = loadConfig();
26
+
27
+ export const START = CONFIG.markers.start;
28
+ export const END = CONFIG.markers.end;
29
+ export const FEATURES_START = CONFIG.markers.featuresStart;
30
+ export const FEATURES_END = CONFIG.markers.featuresEnd;
31
+
32
+ /** The exact provenance line rendered as the first line of a managed block. */
33
+ export function provenanceLine() {
34
+ return '<!-- archgen:block v' + CONFIG.version + ' -->';
35
+ }
7
36
 
8
- export const START = '<!-- archgen:start (managed block - do not edit between markers) -->';
9
- export const END = '<!-- archgen:end -->';
37
+ const PROVENANCE_LINE_RE = /^<!--\s*archgen:block\s+v[^\s>]*\s*-->$/;
38
+
39
+ /**
40
+ * Normalize an archgen-rendered block so the current provenance line sits
41
+ * immediately after START: stale/differently-versioned stamps are dropped and
42
+ * the canonical one inserted. Blocks not beginning with START pass through
43
+ * untouched. Preserves the block's own CRLF/LF convention.
44
+ */
45
+ function injectProvenance(block) {
46
+ if (typeof block !== 'string' || !block.startsWith(START)) return block;
47
+ const eol = block.includes('\r\n') ? '\r\n' : '\n';
48
+ const lines = block.split(/\r?\n/).filter((l, i) => i === 0 || !PROVENANCE_LINE_RE.test(l.trim()));
49
+ lines.splice(1, 0, provenanceLine());
50
+ return lines.join(eol);
51
+ }
52
+
53
+ /**
54
+ * Detect the provenance stamp of a managed block inside file content.
55
+ * @param {string} content file contents (BOM/CRLF tolerated)
56
+ * @returns {{present: boolean, version: string|null}} present=true when a
57
+ * provenance line is found as the first non-empty line after START; version
58
+ * is the stamped semver, or null when the line is malformed.
59
+ */
60
+ export function detectBlockVersion(content) {
61
+ const src = String(content ?? '');
62
+ const s = src.indexOf(START);
63
+ if (s === -1) return { present: false, version: null };
64
+ const e = src.indexOf(END, s + START.length);
65
+ const head = src.slice(s + START.length, e === -1 ? undefined : e);
66
+ for (const line of head.split(/\r?\n/)) {
67
+ const t = line.trim();
68
+ if (t === '') continue;
69
+ if (!t.includes('archgen:block')) return { present: false, version: null };
70
+ const m = /^<!--\s*archgen:block\s+v([^\s>]+)\s*-->$/.exec(t);
71
+ return { present: true, version: m ? m[1] : null };
72
+ }
73
+ return { present: false, version: null };
74
+ }
10
75
 
11
76
  /**
12
77
  * Render the archgen pointer block for a project.
13
78
  * @param {string} skillRelPath relative path to the skill dir from project root
79
+ * @param {string} eol line ending used to join the block lines
14
80
  */
15
- export function renderBlock(skillRelPath = '.agents/skills/archgen') {
81
+ export function renderBlock(skillRelPath = '.agents/skills/archgen', eol = '\n') {
16
82
  return [
17
83
  START,
84
+ provenanceLine(),
18
85
  '# ArchGen - Architecture Generation & Autonomous Task Execution',
19
86
  '',
20
87
  'This project uses the **archgen** skill, installed at `' + skillRelPath + '/`.',
@@ -32,27 +99,56 @@ export function renderBlock(skillRelPath = '.agents/skills/archgen') {
32
99
  '- Generated artifacts live ONLY under `.archgen/<slug>/`',
33
100
  '- Never hand-edit task statuses - use `scripts/set-status.mjs` (comment-safe)',
34
101
  '- Two gates before any execution: verifier approval, then human approval',
102
+ '',
103
+ '## Features registry',
104
+ '',
105
+ FEATURES_START,
106
+ '| Feature | Status | Updated |',
107
+ '| --- | --- | --- |',
108
+ FEATURES_END,
35
109
  END,
36
- ].join('\n');
110
+ ].join(eol);
111
+ }
112
+
113
+ /**
114
+ * Canonical AGENTS.md managed-block text (LF-joined, provenance included).
115
+ * Integration contract: install.sh's heredoc is regenerated byte-identically
116
+ * from this function's output — do not let the two drift.
117
+ */
118
+ export function renderManagedBlockText() {
119
+ return renderBlock();
120
+ }
121
+
122
+ /**
123
+ * Canonical CLAUDE.md bridge text (LF-joined, provenance included).
124
+ * Same integration contract as renderManagedBlockText().
125
+ */
126
+ export function renderClaudeBridgeText() {
127
+ return [START, provenanceLine(), '@AGENTS.md', END].join('\n');
37
128
  }
38
129
 
39
130
  /**
40
131
  * Insert or replace the managed block inside existing file content.
41
132
  * @param {string} existing file contents ('' when creating new)
42
133
  * @param {string} block rendered block
134
+ * @param {string} eol line ending used when appending to empty/missing files
43
135
  */
44
- export function upsertBlock(existing, block) {
136
+ export function upsertBlock(existing, block, eol = '\n') {
137
+ // Every write carries the CURRENT provenance stamp regardless of what the
138
+ // caller rendered — this is what upgrades legacy unversioned blocks in place.
139
+ const fresh = injectProvenance(block);
45
140
  const trimmed = existing ?? '';
46
141
  const startIdx = trimmed.indexOf(START);
47
142
  const endIdx = trimmed.indexOf(END);
48
143
  if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
49
- return trimmed.slice(0, startIdx) + block + trimmed.slice(endIdx + END.length);
144
+ return trimmed.slice(0, startIdx) + fresh + trimmed.slice(endIdx + END.length);
50
145
  }
51
146
  if (startIdx !== -1 || endIdx !== -1) {
52
147
  throw new Error('found only one archgen marker - fix or remove it manually');
53
148
  }
54
- if (trimmed.trim() === '') return block + '\n';
55
- return trimmed.replace(/\n*$/, '\n\n') + block + '\n';
149
+ if (trimmed.trim() === '') return fresh + eol;
150
+ const core = trimmed.replace(/(?:\r?\n)+$/, '');
151
+ return core + eol + eol + fresh + eol;
56
152
  }
57
153
 
58
154
  /**
@@ -69,3 +165,96 @@ export function stripBlock(existing) {
69
165
  out = out.replace(/^\n+/, '').replace(/\n{3,}/g, '\n\n');
70
166
  return { content: out, hadBlock: true };
71
167
  }
168
+
169
+ function splitBom(raw) {
170
+ const bom = raw.charCodeAt(0) === 0xfeff;
171
+ return { bom, body: bom ? raw.slice(1) : raw };
172
+ }
173
+
174
+ /** Write via sibling temp file + rename so readers never observe partial content. */
175
+ function atomicWrite(absPath, data) {
176
+ const tmp = absPath + '.tmp-' + process.pid + '-' + Date.now();
177
+ writeFileSync(tmp, data);
178
+ renameSync(tmp, absPath);
179
+ }
180
+
181
+ /**
182
+ * BOM/EOL-aware upsert of a managed block directly into a file.
183
+ * Preserves a leading UTF-8 BOM and the file's CRLF/LF convention.
184
+ * @param {string} absPath target file (created when missing)
185
+ * @param {string[]} blockLines lines between the managed markers
186
+ * @returns {boolean} whether the file existed before the call
187
+ */
188
+ export function upsertManagedFile(absPath, blockLines) {
189
+ const existed = existsSync(absPath);
190
+ const raw = existed ? readFileSync(absPath, 'utf8') : '';
191
+ const { bom, body } = splitBom(raw);
192
+ const eol = body.includes('\r\n') ? '\r\n' : '\n';
193
+ const next = upsertBlock(body, blockLines.join(eol), eol);
194
+ atomicWrite(absPath, (bom ? '\uFEFF' : '') + next);
195
+ return existed;
196
+ }
197
+
198
+ /**
199
+ * True when the file already contains a trimmed `@AGENTS.md` line anywhere
200
+ * (BOM- and CRLF-tolerant).
201
+ */
202
+ export function importsAgents(absPath) {
203
+ let raw = '';
204
+ try { raw = readFileSync(absPath, 'utf8'); } catch { return false; }
205
+ const { body } = splitBom(raw);
206
+ return body.split(/\r?\n/).some((l) => l.trim() === '@AGENTS.md');
207
+ }
208
+
209
+ /** Strip the managed block from a file on disk, preserving BOM. */
210
+ export function stripManagedFile(absPath) {
211
+ let raw = '';
212
+ try { raw = readFileSync(absPath, 'utf8'); } catch { return { hadBlock: false }; }
213
+ const { bom, body } = splitBom(raw);
214
+ const { content, hadBlock } = stripBlock(body);
215
+ if (hadBlock) writeFileSync(absPath, (bom ? '\uFEFF' : '') + content);
216
+ return { hadBlock };
217
+ }
218
+
219
+ function renderRow(row) {
220
+ const cells = Array.isArray(row)
221
+ ? row.map(String)
222
+ : [row.feature ?? row.name ?? '', row.status ?? '', row.updated ?? ''].map(String);
223
+ return '| ' + cells.join(' | ') + ' |';
224
+ }
225
+
226
+ /**
227
+ * Replace the content between the archgen:features markers in a file with the
228
+ * registry table (header rows plus `rows`). When the markers are missing but
229
+ * the managed block exists, the registry section is inserted before its END
230
+ * marker. Never touches anything outside the markers.
231
+ * @param {string} fileAbsPath absolute path to AGENTS.md-style file
232
+ * @param {Array<string[]|{feature:string,status:string,updated:string}>} rows
233
+ * @returns {string} the new file content
234
+ */
235
+ export function upsertFeaturesRegistry(fileAbsPath, rows = []) {
236
+ const raw = readFileSync(fileAbsPath, 'utf8');
237
+ const table = [
238
+ '| Feature | Status | Updated |',
239
+ '| --- | --- | --- |',
240
+ ...rows.map(renderRow),
241
+ ].join('\n');
242
+ const s = raw.indexOf(FEATURES_START);
243
+ const e = raw.indexOf(FEATURES_END);
244
+ let next;
245
+ if (s !== -1 && e !== -1 && e > s) {
246
+ next = raw.slice(0, s + FEATURES_START.length) + '\n' + table + '\n' + raw.slice(e);
247
+ } else if (s === -1 && e === -1) {
248
+ const bStart = raw.indexOf(START);
249
+ const bEnd = raw.indexOf(END);
250
+ if (bStart === -1 || bEnd === -1 || bEnd < bStart) {
251
+ throw new Error('no archgen managed block - cannot place features registry');
252
+ }
253
+ const section = '\n## Features registry\n\n' + FEATURES_START + '\n' + table + '\n' + FEATURES_END + '\n';
254
+ next = raw.slice(0, bEnd) + section + raw.slice(bEnd);
255
+ } else {
256
+ throw new Error('found only one archgen:features marker - fix or remove it manually');
257
+ }
258
+ if (next !== raw) writeFileSync(fileAbsPath, next);
259
+ return next;
260
+ }
package/lib/config.js ADDED
@@ -0,0 +1,96 @@
1
+ // config.js — loader for the single-source-of-truth archgen.config.json.
2
+ //
3
+ // WHY: the version, managed-block marker strings, and well-known filenames
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;
7
+ // a byte-identical derived copy ships inside the published npm package at
8
+ // vendor/skills/archgen/archgen.config.json (kept in sync by
9
+ // scripts/sync-config.mjs + scripts/sync-vendor.mjs).
10
+ //
11
+ // Resolution is module-relative (cwd-independent), tried in order:
12
+ // 1. ../../../archgen.config.json — monorepo checkout
13
+ // 2. ../vendor/skills/archgen/archgen.config.json — published package layout
14
+ // The first readable candidate wins; a candidate that exists but cannot be
15
+ // parsed/validated fails LOUDLY rather than silently falling through to a
16
+ // possibly stale copy. Cached per process.
17
+
18
+ import { existsSync, readFileSync } from 'node:fs';
19
+ import { dirname, resolve } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const HERE = dirname(fileURLToPath(import.meta.url)); // packages/cli/lib
23
+
24
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
25
+
26
+ /** Candidate canonical-config paths for a given lib directory. */
27
+ export function configCandidates(baseDir = HERE) {
28
+ return [
29
+ resolve(baseDir, '../../../archgen.config.json'),
30
+ resolve(baseDir, '../vendor/skills/archgen/archgen.config.json'),
31
+ ];
32
+ }
33
+
34
+ /**
35
+ * Parse + validate raw config JSON text.
36
+ * @param {string} text file contents
37
+ * @param {string} sourcePath path used in error messages
38
+ */
39
+ export function parseConfig(text, sourcePath) {
40
+ let cfg;
41
+ try {
42
+ cfg = JSON.parse(text);
43
+ } catch (e) {
44
+ throw new Error(`cannot load ${sourcePath}: invalid JSON (${e.message})`);
45
+ }
46
+ const bad = (msg) => new Error(`cannot load ${sourcePath}: ${msg}`);
47
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) throw bad('expected a JSON object');
48
+ if (typeof cfg.version !== 'string' || !SEMVER_RE.test(cfg.version)) {
49
+ throw bad(`"version" must be MAJOR.MINOR.PATCH, got ${JSON.stringify(cfg.version ?? null)}`);
50
+ }
51
+ if (typeof cfg.skillName !== 'string' || !cfg.skillName) throw bad('"skillName" must be a non-empty string');
52
+ const m = cfg.markers;
53
+ if (!m || typeof m !== 'object') throw bad('"markers" object is required');
54
+ for (const k of ['start', 'end', 'featuresStart', 'featuresEnd']) {
55
+ if (typeof m[k] !== 'string' || !m[k]) throw bad(`"markers.${k}" must be a non-empty string`);
56
+ }
57
+ const f = cfg.files;
58
+ if (!f || typeof f !== 'object') throw bad('"files" object is required');
59
+ for (const k of ['stamp', 'projectManifest', 'globalManifest', 'backupDir']) {
60
+ if (typeof f[k] !== 'string' || !f[k]) throw bad(`"files.${k}" must be a non-empty string`);
61
+ }
62
+ return cfg;
63
+ }
64
+
65
+ /**
66
+ * Resolve + parse the canonical config relative to `baseDir` (a lib dir).
67
+ * @returns {object} the validated config object
68
+ */
69
+ export function loadConfigFrom(baseDir) {
70
+ const candidates = configCandidates(baseDir);
71
+ for (const p of candidates) {
72
+ if (!existsSync(p)) continue;
73
+ let text = '';
74
+ try {
75
+ text = readFileSync(p, 'utf8');
76
+ } catch (e) {
77
+ throw new Error(`cannot load ${p}: ${e.message}`);
78
+ }
79
+ return parseConfig(text, p);
80
+ }
81
+ throw new Error(
82
+ 'archgen config not found. Expected one of:\n' +
83
+ candidates.map((c) => ' - ' + c).join('\n') +
84
+ '\nIn a repository checkout archgen.config.json lives at the repo root; ' +
85
+ 'in the published package it is vendored at vendor/skills/archgen/. ' +
86
+ 'Run `npm run sync:config` inside packages/cli to (re)generate both.',
87
+ );
88
+ }
89
+
90
+ let cached = null;
91
+
92
+ /** Load the canonical config for this process (cached after first call). */
93
+ export function loadConfig() {
94
+ if (!cached) cached = loadConfigFrom(HERE);
95
+ return cached;
96
+ }