archgen-skill 0.0.3 → 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 +31 -33
- package/bin/archgen.mjs +31 -1
- package/lib/block.js +147 -16
- package/lib/config.js +96 -0
- package/lib/doctor.js +144 -28
- package/lib/init.js +14 -3
- package/lib/install.js +124 -16
- package/lib/migrate.js +88 -0
- package/lib/migrations/001-stamp-provenance.mjs +77 -0
- package/lib/migrations/index.mjs +170 -0
- package/lib/restore.js +239 -0
- package/lib/store.js +125 -15
- package/lib/uninstall-project.js +6 -3
- package/lib/version-stamp.js +49 -0
- package/package.json +39 -10
- package/vendor/skills/archgen/SKILL.md +218 -145
- package/vendor/skills/archgen/archgen.config.json +16 -0
- package/vendor/skills/archgen/assets/skill-registry.json +4 -3
- package/vendor/skills/archgen/references/artifact-templates.md +308 -7
- package/vendor/skills/archgen/references/interview.md +226 -17
- package/vendor/skills/archgen/references/mcp-and-skills.md +88 -17
- package/vendor/skills/archgen/references/orchestration.md +230 -44
- package/vendor/skills/archgen/references/platforms.md +56 -14
- package/vendor/skills/archgen/scripts/doc-index.mjs +457 -0
- package/vendor/skills/archgen/scripts/impact.mjs +14 -1
- package/vendor/skills/archgen/scripts/lib/graph.mjs +99 -5
- package/vendor/skills/archgen/scripts/lib/scope.mjs +65 -0
- package/vendor/skills/archgen/scripts/lib/yaml.mjs +19 -3
- package/vendor/skills/archgen/scripts/plan-graph.mjs +329 -0
- package/vendor/skills/archgen/scripts/set-status.mjs +51 -9
- package/vendor/skills/archgen/scripts/update-agents.mjs +16 -5
- package/vendor/skills/archgen/scripts/validate.mjs +18 -1
- package/vendor/skills/archgen/scripts/verify-plan.mjs +8 -3
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
|
|
37
|
-
| `AGENTS.md` | Managed hub block: skill pointer
|
|
38
|
-
| `CLAUDE.md` | One-line bridge: `@AGENTS.md` (existing
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
|
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,29 +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
|
|
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]` |
|
|
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,
|
|
77
|
-
| `npx archgen-skill doctor [dir] [--check]` | Verify
|
|
78
|
-
| `npx archgen-skill
|
|
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 |
|
|
79
84
|
|
|
80
85
|
## Why archgen
|
|
81
86
|
|
|
82
87
|
AI coding agents fail at scale for predictable reasons: plans with hidden cycles,
|
|
83
88
|
workers editing the same files, tasks without objective acceptance criteria, and no
|
|
84
89
|
human checkpoint before execution. archgen makes each of these a *structural*
|
|
85
|
-
guarantee
|
|
90
|
+
guarantee:
|
|
86
91
|
|
|
87
92
|
| Guarantee | Mechanism |
|
|
88
93
|
| --- | --- |
|
|
89
|
-
| Plans are valid before work starts | Verifier gate —
|
|
94
|
+
| Plans are valid before work starts | Verifier gate — cycles, dangling refs, ownership overlaps, plan↔task coverage |
|
|
90
95
|
| You approve before code is written | Explicit user gate after verifier approval |
|
|
91
96
|
| Parallel workers never collide | Disjoint `file_ownership` globs enforced per wave |
|
|
92
97
|
| Task order is always correct | Topological waves from `depends_on`; chains stay sequential |
|
|
@@ -114,23 +119,17 @@ guarantee rather than a hope:
|
|
|
114
119
|
|
|
115
120
|
## Supported harnesses
|
|
116
121
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
| OpenCode | ✅ | ✅ |
|
|
121
|
-
| Cursor | ✅ | ✅ |
|
|
122
|
-
| Codex | ✅ | ✅ |
|
|
123
|
-
| Gemini CLI | ✅ | ✅ |
|
|
124
|
-
| Antigravity | ✅ | ✅ |
|
|
125
|
-
| 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.
|
|
126
125
|
|
|
127
126
|
## VS Code extension (optional)
|
|
128
127
|
|
|
129
|
-
A read-only visual layer that renders `.archgen/` as a live task DAG
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
[GitHub Releases](https://github.com/akashmaurya99/archgen/releases). It
|
|
133
|
-
|
|
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.
|
|
134
133
|
|
|
135
134
|
## Documentation
|
|
136
135
|
|
|
@@ -139,7 +138,6 @@ a viewer: uninstalling it loses nothing.
|
|
|
139
138
|
| [Architecture walkthrough](https://github.com/akashmaurya99/archgen/blob/main/docs/architecture.md) | Dual-mode design, gates, wave mechanics, platform detection |
|
|
140
139
|
| [The skill contract](https://github.com/akashmaurya99/archgen/blob/main/skill/SKILL.md) | The full skill specification agents execute |
|
|
141
140
|
| [Changelog](https://github.com/akashmaurya99/archgen/blob/main/CHANGELOG.md) | Release history |
|
|
142
|
-
| [Contributing](https://github.com/akashmaurya99/archgen/blob/main/CONTRIBUTING.md) | Development setup and PR guide |
|
|
143
141
|
|
|
144
142
|
## Contributing
|
|
145
143
|
|
package/bin/archgen.mjs
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
// (Claude Code, OpenCode, Cursor, agentskills)
|
|
9
9
|
// npx archgen-skill uninstall Remove globally-installed copies (manifest-based)
|
|
10
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)
|
|
11
13
|
//
|
|
12
14
|
// Zero dependencies. Cross-platform (Windows included — no bash required).
|
|
13
15
|
|
|
@@ -17,6 +19,8 @@ import { installGlobal, uninstallGlobal } from '../lib/install.js';
|
|
|
17
19
|
import { initProject } from '../lib/init.js';
|
|
18
20
|
import { doctorProject } from '../lib/doctor.js';
|
|
19
21
|
import { uninstallProject } from '../lib/uninstall-project.js';
|
|
22
|
+
import { restoreMain } from '../lib/restore.js';
|
|
23
|
+
import { migrateMain } from '../lib/migrate.js';
|
|
20
24
|
import { compareSemver, fetchLatestVersion } from '../lib/version.js';
|
|
21
25
|
import { spawnSync } from 'node:child_process';
|
|
22
26
|
import { readFileSync } from 'node:fs';
|
|
@@ -54,6 +58,14 @@ Usage:
|
|
|
54
58
|
managed blocks, canonical store (kept when
|
|
55
59
|
customized) and manifest; .archgen feature
|
|
56
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
|
|
57
69
|
npx archgen-skill update [dir] Check npm for a newer archgen-skill, upgrade
|
|
58
70
|
the global install when outdated, then
|
|
59
71
|
re-init this project + doctor against the
|
|
@@ -132,6 +144,17 @@ try {
|
|
|
132
144
|
}
|
|
133
145
|
break;
|
|
134
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
|
+
}
|
|
135
158
|
case 'update': {
|
|
136
159
|
const dir = positionalDir();
|
|
137
160
|
const current = cliVersion();
|
|
@@ -170,7 +193,14 @@ try {
|
|
|
170
193
|
break;
|
|
171
194
|
}
|
|
172
195
|
console.log('\nre-running init + doctor with the new binary (' + latest + ')…');
|
|
173
|
-
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
|
+
}
|
|
174
204
|
spawnSync(freshBin, ['doctor', dir], { stdio: 'inherit' });
|
|
175
205
|
break;
|
|
176
206
|
}
|
package/lib/block.js
CHANGED
|
@@ -8,13 +8,71 @@
|
|
|
8
8
|
// AGENTS.md carries the full pointer block with an embedded features registry
|
|
9
9
|
// (between FEATURES_START/FEATURES_END). CLAUDE.md carries a one-line
|
|
10
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
|
+
import { assertNotSymlink } from './store.js';
|
|
22
|
+
|
|
23
|
+
// Markers + block-format version come from the single source of truth.
|
|
24
|
+
// Module-level load is safe: config resolution is module-relative and
|
|
25
|
+
// deterministic, and a missing/corrupt config fails loudly at import by design.
|
|
26
|
+
const CONFIG = loadConfig();
|
|
11
27
|
|
|
12
|
-
|
|
28
|
+
export const START = CONFIG.markers.start;
|
|
29
|
+
export const END = CONFIG.markers.end;
|
|
30
|
+
export const FEATURES_START = CONFIG.markers.featuresStart;
|
|
31
|
+
export const FEATURES_END = CONFIG.markers.featuresEnd;
|
|
32
|
+
|
|
33
|
+
/** The exact provenance line rendered as the first line of a managed block. */
|
|
34
|
+
export function provenanceLine() {
|
|
35
|
+
return '<!-- archgen:block v' + CONFIG.version + ' -->';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const PROVENANCE_LINE_RE = /^<!--\s*archgen:block\s+v[^\s>]*\s*-->$/;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Normalize an archgen-rendered block so the current provenance line sits
|
|
42
|
+
* immediately after START: stale/differently-versioned stamps are dropped and
|
|
43
|
+
* the canonical one inserted. Blocks not beginning with START pass through
|
|
44
|
+
* untouched. Preserves the block's own CRLF/LF convention.
|
|
45
|
+
*/
|
|
46
|
+
function injectProvenance(block) {
|
|
47
|
+
if (typeof block !== 'string' || !block.startsWith(START)) return block;
|
|
48
|
+
const eol = block.includes('\r\n') ? '\r\n' : '\n';
|
|
49
|
+
const lines = block.split(/\r?\n/).filter((l, i) => i === 0 || !PROVENANCE_LINE_RE.test(l.trim()));
|
|
50
|
+
lines.splice(1, 0, provenanceLine());
|
|
51
|
+
return lines.join(eol);
|
|
52
|
+
}
|
|
13
53
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Detect the provenance stamp of a managed block inside file content.
|
|
56
|
+
* @param {string} content file contents (BOM/CRLF tolerated)
|
|
57
|
+
* @returns {{present: boolean, version: string|null}} present=true when a
|
|
58
|
+
* provenance line is found as the first non-empty line after START; version
|
|
59
|
+
* is the stamped semver, or null when the line is malformed.
|
|
60
|
+
*/
|
|
61
|
+
export function detectBlockVersion(content) {
|
|
62
|
+
const src = String(content ?? '');
|
|
63
|
+
const s = src.indexOf(START);
|
|
64
|
+
if (s === -1) return { present: false, version: null };
|
|
65
|
+
const e = src.indexOf(END, s + START.length);
|
|
66
|
+
const head = src.slice(s + START.length, e === -1 ? undefined : e);
|
|
67
|
+
for (const line of head.split(/\r?\n/)) {
|
|
68
|
+
const t = line.trim();
|
|
69
|
+
if (t === '') continue;
|
|
70
|
+
if (!t.includes('archgen:block')) return { present: false, version: null };
|
|
71
|
+
const m = /^<!--\s*archgen:block\s+v([^\s>]+)\s*-->$/.exec(t);
|
|
72
|
+
return { present: true, version: m ? m[1] : null };
|
|
73
|
+
}
|
|
74
|
+
return { present: false, version: null };
|
|
75
|
+
}
|
|
18
76
|
|
|
19
77
|
/**
|
|
20
78
|
* Render the archgen pointer block for a project.
|
|
@@ -24,6 +82,7 @@ export const FEATURES_END = '<!-- archgen:features:end -->';
|
|
|
24
82
|
export function renderBlock(skillRelPath = '.agents/skills/archgen', eol = '\n') {
|
|
25
83
|
return [
|
|
26
84
|
START,
|
|
85
|
+
provenanceLine(),
|
|
27
86
|
'# ArchGen - Architecture Generation & Autonomous Task Execution',
|
|
28
87
|
'',
|
|
29
88
|
'This project uses the **archgen** skill, installed at `' + skillRelPath + '/`.',
|
|
@@ -52,40 +111,97 @@ export function renderBlock(skillRelPath = '.agents/skills/archgen', eol = '\n')
|
|
|
52
111
|
].join(eol);
|
|
53
112
|
}
|
|
54
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Canonical AGENTS.md managed-block text (LF-joined, provenance included).
|
|
116
|
+
* Integration contract: install.sh's heredoc is regenerated byte-identically
|
|
117
|
+
* from this function's output — do not let the two drift.
|
|
118
|
+
*/
|
|
119
|
+
export function renderManagedBlockText() {
|
|
120
|
+
return renderBlock();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Canonical CLAUDE.md bridge text (LF-joined, provenance included).
|
|
125
|
+
* Same integration contract as renderManagedBlockText().
|
|
126
|
+
*/
|
|
127
|
+
export function renderClaudeBridgeText() {
|
|
128
|
+
return [START, provenanceLine(), '@AGENTS.md', END].join('\n');
|
|
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
|
+
|
|
55
149
|
/**
|
|
56
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.
|
|
57
156
|
* @param {string} existing file contents ('' when creating new)
|
|
58
157
|
* @param {string} block rendered block
|
|
59
158
|
* @param {string} eol line ending used when appending to empty/missing files
|
|
60
159
|
*/
|
|
61
160
|
export function upsertBlock(existing, block, eol = '\n') {
|
|
161
|
+
// Every write carries the CURRENT provenance stamp regardless of what the
|
|
162
|
+
// caller rendered — this is what upgrades legacy unversioned blocks in place.
|
|
163
|
+
const fresh = injectProvenance(block);
|
|
62
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
|
+
|
|
63
181
|
const startIdx = trimmed.indexOf(START);
|
|
64
182
|
const endIdx = trimmed.indexOf(END);
|
|
65
183
|
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
|
66
|
-
return trimmed.slice(0, startIdx) +
|
|
184
|
+
return trimmed.slice(0, startIdx) + fresh + trimmed.slice(endIdx + END.length);
|
|
67
185
|
}
|
|
68
186
|
if (startIdx !== -1 || endIdx !== -1) {
|
|
69
187
|
throw new Error('found only one archgen marker - fix or remove it manually');
|
|
70
188
|
}
|
|
71
|
-
if (trimmed.trim() === '') return
|
|
189
|
+
if (trimmed.trim() === '') return fresh + eol;
|
|
72
190
|
const core = trimmed.replace(/(?:\r?\n)+$/, '');
|
|
73
|
-
return core + eol + eol +
|
|
191
|
+
return core + eol + eol + fresh + eol;
|
|
74
192
|
}
|
|
75
193
|
|
|
76
194
|
/**
|
|
77
|
-
* Strip
|
|
195
|
+
* Strip EVERY managed block from file contents (duplicated markers normalize
|
|
196
|
+
* to zero blocks, matching upsertBlock's one-block guarantee).
|
|
78
197
|
* @returns {{content: string, hadBlock: boolean}}
|
|
79
198
|
*/
|
|
80
199
|
export function stripBlock(existing) {
|
|
81
200
|
const trimmed = existing ?? '';
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
if (startIdx === -1 || endIdx === -1) return { content: trimmed, hadBlock: false };
|
|
85
|
-
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 };
|
|
86
203
|
// Collapse whitespace left behind; drop file if nothing else remains.
|
|
87
|
-
|
|
88
|
-
return { content: out, hadBlock: true };
|
|
204
|
+
return { content: out.replace(/^\n+/, '').replace(/\n{3,}/g, '\n\n'), hadBlock: true };
|
|
89
205
|
}
|
|
90
206
|
|
|
91
207
|
function splitBom(raw) {
|
|
@@ -93,20 +209,33 @@ function splitBom(raw) {
|
|
|
93
209
|
return { bom, body: bom ? raw.slice(1) : raw };
|
|
94
210
|
}
|
|
95
211
|
|
|
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);
|
|
219
|
+
}
|
|
220
|
+
|
|
96
221
|
/**
|
|
97
222
|
* BOM/EOL-aware upsert of a managed block directly into a file.
|
|
98
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).
|
|
99
227
|
* @param {string} absPath target file (created when missing)
|
|
100
228
|
* @param {string[]} blockLines lines between the managed markers
|
|
101
229
|
* @returns {boolean} whether the file existed before the call
|
|
102
230
|
*/
|
|
103
231
|
export function upsertManagedFile(absPath, blockLines) {
|
|
232
|
+
assertNotSymlink(absPath);
|
|
104
233
|
const existed = existsSync(absPath);
|
|
105
234
|
const raw = existed ? readFileSync(absPath, 'utf8') : '';
|
|
106
235
|
const { bom, body } = splitBom(raw);
|
|
107
236
|
const eol = body.includes('\r\n') ? '\r\n' : '\n';
|
|
108
237
|
const next = upsertBlock(body, blockLines.join(eol), eol);
|
|
109
|
-
|
|
238
|
+
atomicWrite(absPath, (bom ? '\uFEFF' : '') + next);
|
|
110
239
|
return existed;
|
|
111
240
|
}
|
|
112
241
|
|
|
@@ -123,6 +252,7 @@ export function importsAgents(absPath) {
|
|
|
123
252
|
|
|
124
253
|
/** Strip the managed block from a file on disk, preserving BOM. */
|
|
125
254
|
export function stripManagedFile(absPath) {
|
|
255
|
+
assertNotSymlink(absPath);
|
|
126
256
|
let raw = '';
|
|
127
257
|
try { raw = readFileSync(absPath, 'utf8'); } catch { return { hadBlock: false }; }
|
|
128
258
|
const { bom, body } = splitBom(raw);
|
|
@@ -148,6 +278,7 @@ function renderRow(row) {
|
|
|
148
278
|
* @returns {string} the new file content
|
|
149
279
|
*/
|
|
150
280
|
export function upsertFeaturesRegistry(fileAbsPath, rows = []) {
|
|
281
|
+
assertNotSymlink(fileAbsPath);
|
|
151
282
|
const raw = readFileSync(fileAbsPath, 'utf8');
|
|
152
283
|
const table = [
|
|
153
284
|
'| Feature | Status | Updated |',
|
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
|
+
}
|