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.
- package/README.md +29 -3
- package/bin/archgen.mjs +146 -12
- package/lib/block.js +197 -8
- package/lib/config.js +96 -0
- package/lib/doctor.js +283 -0
- package/lib/init.js +130 -27
- package/lib/install.js +105 -14
- 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 +161 -0
- package/lib/uninstall-project.js +95 -0
- package/lib/version-stamp.js +47 -0
- package/lib/version.js +23 -0
- package/package.json +37 -9
- package/vendor/skills/archgen/SKILL.md +252 -73
- 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 +251 -7
- package/vendor/skills/archgen/references/interview.md +180 -16
- package/vendor/skills/archgen/references/mcp-and-skills.md +88 -17
- package/vendor/skills/archgen/references/orchestration.md +102 -39
- package/vendor/skills/archgen/references/platforms.md +18 -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 +82 -3
- 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 +26 -6
- package/vendor/skills/archgen/scripts/update-agents.mjs +204 -0
- package/vendor/skills/archgen/scripts/validate.mjs +18 -1
- package/vendor/skills/archgen/scripts/verify-plan.mjs +8 -3
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// doctor.js — `archgen doctor [dir] [--check]` health-check + safe auto-repair.
|
|
2
|
+
//
|
|
3
|
+
// Verifies a project install: canonical store integrity, version stamp,
|
|
4
|
+
// claude link resolution, managed blocks (exactly once each), and manifest
|
|
5
|
+
// entry resolution. Safe issues are repaired in place unless --check is given
|
|
6
|
+
// (report-only). Advisory FAIL rows (broken states only a human can fix) are
|
|
7
|
+
// reported and counted in the summary but do NOT fail the run: the process
|
|
8
|
+
// exits non-zero only when a repair operation itself throws.
|
|
9
|
+
|
|
10
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { dirname, join, resolve } from 'node:path';
|
|
13
|
+
import { globalTargets } from './install.js';
|
|
14
|
+
import {
|
|
15
|
+
END,
|
|
16
|
+
FEATURES_END,
|
|
17
|
+
FEATURES_START,
|
|
18
|
+
START,
|
|
19
|
+
detectBlockVersion,
|
|
20
|
+
renderBlock,
|
|
21
|
+
upsertFeaturesRegistry,
|
|
22
|
+
upsertManagedFile,
|
|
23
|
+
} from './block.js';
|
|
24
|
+
import { resolveSkillSource } from './init.js';
|
|
25
|
+
import { CLAUDE_LINK_REL, MANIFEST_REL, STORE_REL, VERSION_FILE, cliVersion, hashDir, loadManifest, saveManifest } from './store.js';
|
|
26
|
+
import { compareSemver, parseSemver } from './version.js';
|
|
27
|
+
import { readStamp, writeStamp } from './version-stamp.js';
|
|
28
|
+
|
|
29
|
+
function lstatSafe(p) {
|
|
30
|
+
try { return lstatSync(p); } catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function countOccurrences(haystack, needle) {
|
|
34
|
+
let n = 0;
|
|
35
|
+
for (let i = haystack.indexOf(needle); i !== -1; i = haystack.indexOf(needle, i + needle.length)) n++;
|
|
36
|
+
return n;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function relJoin(rel) {
|
|
40
|
+
return join(...rel.split('/'));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} projectDir project root to inspect
|
|
45
|
+
* @param {string} packageRoot dir containing this CLI package
|
|
46
|
+
* @param {{check?: boolean, home?: string}} opts check=true → report-only, no
|
|
47
|
+
* mutations; home overrides the homedir scanned for global installs (tests)
|
|
48
|
+
* @returns {{root: string, checks: {status: 'OK'|'FIXED'|'WOULD-FIX'|'WARN'|'FAIL', msg: string}[],
|
|
49
|
+
* tally: Record<string, number>, failures: number}} `failures` counts
|
|
50
|
+
* repair operations that THEMSELVES threw — advisory FAIL rows are reported
|
|
51
|
+
* but never fail the run.
|
|
52
|
+
*/
|
|
53
|
+
export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
54
|
+
const root = resolve(projectDir);
|
|
55
|
+
const checkOnly = opts.check === true;
|
|
56
|
+
const homeDir = opts.home ?? homedir();
|
|
57
|
+
const version = cliVersion(packageRoot);
|
|
58
|
+
const checks = [];
|
|
59
|
+
const add = (status, msg) => checks.push({ status, msg });
|
|
60
|
+
let opFailures = 0;
|
|
61
|
+
// Run one repair step; a throw becomes a FAIL row instead of aborting the
|
|
62
|
+
// whole doctor pass (and is the only thing that flips the exit code).
|
|
63
|
+
const attempt = (fn, okStatus, okMsg, failLabel) => {
|
|
64
|
+
try {
|
|
65
|
+
fn();
|
|
66
|
+
add(okStatus, okMsg);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
opFailures++;
|
|
69
|
+
add('FAIL', `${failLabel}: ${e.message} - fix manually`);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const storeAbs = join(root, relJoin(STORE_REL));
|
|
74
|
+
const claudeAbs = join(root, relJoin(CLAUDE_LINK_REL));
|
|
75
|
+
const agentsAbs = join(root, 'AGENTS.md');
|
|
76
|
+
const claudeMdAbs = join(root, 'CLAUDE.md');
|
|
77
|
+
|
|
78
|
+
// 1. Canonical store validity — SKILL.md alone is not enough: a store
|
|
79
|
+
// missing whole subtrees (scripts/, references/) green-lights installs
|
|
80
|
+
// whose entry points are gone. Divergence from the packaged skill is a
|
|
81
|
+
// WARN, not a failure: user customization is legitimate.
|
|
82
|
+
const deferredStoreFindings = [];
|
|
83
|
+
if (!existsSync(join(storeAbs, 'SKILL.md'))) {
|
|
84
|
+
add('FAIL', 'store .agents/skills/archgen missing or incomplete - run `archgen init`');
|
|
85
|
+
} else {
|
|
86
|
+
const missingCore = ['scripts', 'references'].filter((d) => !existsSync(join(storeAbs, d)));
|
|
87
|
+
if (missingCore.length > 0) {
|
|
88
|
+
// Deferred so the managed-block FAIL rows below stay FIRST: existing
|
|
89
|
+
// consumers read checks.find(FAIL) expecting the marker verdict.
|
|
90
|
+
deferredStoreFindings.push(['FAIL', `store .agents/skills/archgen is missing core ${missingCore.map((d) => d + '/').join(', ')} - run \`archgen init\``]);
|
|
91
|
+
} else {
|
|
92
|
+
let diverged = false;
|
|
93
|
+
try {
|
|
94
|
+
diverged = hashDir(storeAbs) !== hashDir(resolveSkillSource(packageRoot));
|
|
95
|
+
} catch { /* packaged skill unavailable — skip the divergence probe */ }
|
|
96
|
+
if (diverged) add('WARN', 'store .agents/skills/archgen diverges from packaged skill (customized?)');
|
|
97
|
+
else add('OK', 'store .agents/skills/archgen (SKILL.md present)');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 2. Version stamp currency.
|
|
102
|
+
const stampAbs = join(storeAbs, VERSION_FILE);
|
|
103
|
+
let stamp = null;
|
|
104
|
+
try { stamp = readFileSync(stampAbs, 'utf8').trim(); } catch { /* absent */ }
|
|
105
|
+
if (!existsSync(storeAbs)) {
|
|
106
|
+
// already reported above; skip stamp handling without touching anything
|
|
107
|
+
} else if (stamp === version) {
|
|
108
|
+
add('OK', `version stamp ${version} (current)`);
|
|
109
|
+
} else if (checkOnly) {
|
|
110
|
+
add('WOULD-FIX', `version stamp ${stamp || 'missing'} is stale -> ${version}`);
|
|
111
|
+
} else {
|
|
112
|
+
attempt(() => writeStamp(storeAbs, version), 'FIXED',
|
|
113
|
+
`version stamp updated ${stamp || 'missing'} -> ${version}`, 'cannot write version stamp');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 3. Claude link resolution.
|
|
117
|
+
const repairLink = () => {
|
|
118
|
+
if (checkOnly) {
|
|
119
|
+
add('WOULD-FIX', 'claude link missing/dangling -> ../../.agents/skills/archgen');
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
try { unlinkSync(claudeAbs); } catch { /* absent */ }
|
|
124
|
+
mkdirSync(dirname(claudeAbs), { recursive: true });
|
|
125
|
+
symlinkSync('../../.agents/skills/archgen', claudeAbs);
|
|
126
|
+
add('FIXED', 'claude link recreated -> ../../.agents/skills/archgen');
|
|
127
|
+
} catch (e) {
|
|
128
|
+
add('FAIL', 'cannot recreate claude link: ' + e.message);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
const st = lstatSafe(claudeAbs);
|
|
132
|
+
if (st && st.isSymbolicLink()) {
|
|
133
|
+
const target = resolve(dirname(claudeAbs), readlinkSync(claudeAbs));
|
|
134
|
+
if (target === storeAbs && existsSync(claudeAbs)) add('OK', 'claude link resolves to the canonical store');
|
|
135
|
+
else if (existsSync(claudeAbs)) add('WARN', 'claude link is foreign (points elsewhere) - left untouched');
|
|
136
|
+
else repairLink();
|
|
137
|
+
} else if (st && st.isDirectory()) {
|
|
138
|
+
add('WARN', '.claude/skills/archgen is a real directory (not managed) - inspect manually');
|
|
139
|
+
} else if (st) {
|
|
140
|
+
add('WARN', '.claude/skills/archgen is a regular file - left untouched');
|
|
141
|
+
} else {
|
|
142
|
+
repairLink();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 4. AGENTS.md — managed block exactly once, features registry inside.
|
|
146
|
+
if (!existsSync(agentsAbs)) {
|
|
147
|
+
if (checkOnly) add('WOULD-FIX', 'AGENTS.md missing -> created with managed block');
|
|
148
|
+
else {
|
|
149
|
+
writeFileSync(agentsAbs, renderBlock(STORE_REL) + '\n');
|
|
150
|
+
add('FIXED', 'AGENTS.md created with managed block');
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
let raw = readFileSync(agentsAbs, 'utf8');
|
|
154
|
+
const starts = countOccurrences(raw, START);
|
|
155
|
+
const ends = countOccurrences(raw, END);
|
|
156
|
+
if (starts === 0 && ends === 0) {
|
|
157
|
+
if (checkOnly) add('WOULD-FIX', 'AGENTS.md has no archgen block -> added');
|
|
158
|
+
else {
|
|
159
|
+
attempt(() => upsertManagedFile(agentsAbs, renderBlock(STORE_REL).split('\n')), 'FIXED',
|
|
160
|
+
'AGENTS.md managed block added', 'cannot add AGENTS.md managed block');
|
|
161
|
+
}
|
|
162
|
+
} else if (starts > 1 || ends > 1) {
|
|
163
|
+
add('FAIL', `AGENTS.md has ${starts} start / ${ends} end markers - fix manually`);
|
|
164
|
+
} else if (starts === 0 || ends === 0 || raw.indexOf(START) > raw.indexOf(END)) {
|
|
165
|
+
// Orphan/inverted markers: repairing through upsertBlock would throw, so
|
|
166
|
+
// classify as FAIL instead of attempting the write.
|
|
167
|
+
add('FAIL', 'AGENTS.md has broken archgen markers - fix manually');
|
|
168
|
+
} else {
|
|
169
|
+
// Block-format currency: an unversioned or older-stamped managed block is
|
|
170
|
+
// upgraded in place through the normal upsert path (everything outside the
|
|
171
|
+
// markers survives byte-for-byte by design). Newer stamps are left alone.
|
|
172
|
+
const det = detectBlockVersion(raw);
|
|
173
|
+
const parsed = det.version ? parseSemver(det.version) : null;
|
|
174
|
+
const stale = !det.present || !parsed || compareSemver(det.version, version) < 0;
|
|
175
|
+
if (stale) {
|
|
176
|
+
const from = det.version ? 'v' + det.version : 'unversioned';
|
|
177
|
+
if (checkOnly) {
|
|
178
|
+
add('WOULD-FIX', `WOULD-UPGRADE: AGENTS.md block ${from} -> v${version}`);
|
|
179
|
+
} else {
|
|
180
|
+
let upgraded = false;
|
|
181
|
+
attempt(() => { upsertManagedFile(agentsAbs, renderBlock(STORE_REL).split('\n')); upgraded = true; },
|
|
182
|
+
'FIXED', `UPGRADED: AGENTS.md block ${from} -> v${version}`, 'cannot upgrade AGENTS.md block');
|
|
183
|
+
if (upgraded) raw = readFileSync(agentsAbs, 'utf8');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const fsCount = countOccurrences(raw, FEATURES_START);
|
|
187
|
+
const feCount = countOccurrences(raw, FEATURES_END);
|
|
188
|
+
const fsIdx = raw.indexOf(FEATURES_START);
|
|
189
|
+
const feIdx = raw.indexOf(FEATURES_END);
|
|
190
|
+
if (fsCount === 1 && feCount === 1 && fsIdx < feIdx) {
|
|
191
|
+
add('OK', 'AGENTS.md block + features registry present');
|
|
192
|
+
} else if (fsCount === 1 && feCount === 1) {
|
|
193
|
+
// Counts are right but ORDER is inverted (end before start): any
|
|
194
|
+
// registry write would throw (upsertFeaturesRegistry requires
|
|
195
|
+
// end > start), so flag the state instead of blessing it as OK.
|
|
196
|
+
if (checkOnly) add('WOULD-FIX', 'AGENTS.md archgen:features markers are reversed -> swap them');
|
|
197
|
+
else add('FAIL', 'AGENTS.md archgen:features markers are reversed - fix manually');
|
|
198
|
+
} else if (fsCount === 0 && feCount === 0) {
|
|
199
|
+
if (checkOnly) add('WOULD-FIX', 'AGENTS.md missing features registry -> inserted');
|
|
200
|
+
else {
|
|
201
|
+
attempt(() => upsertFeaturesRegistry(agentsAbs, []), 'FIXED',
|
|
202
|
+
'AGENTS.md features registry inserted', 'cannot insert AGENTS.md features registry');
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
add('FAIL', 'AGENTS.md has broken archgen:features markers - fix manually');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 5. CLAUDE.md — @AGENTS.md bridge present exactly once.
|
|
211
|
+
if (!existsSync(claudeMdAbs)) {
|
|
212
|
+
if (checkOnly) add('WOULD-FIX', 'CLAUDE.md missing -> created with @AGENTS.md bridge');
|
|
213
|
+
else {
|
|
214
|
+
attempt(() => upsertManagedFile(claudeMdAbs, [START, '@AGENTS.md', END]), 'FIXED',
|
|
215
|
+
'CLAUDE.md created with @AGENTS.md bridge', 'cannot create CLAUDE.md bridge');
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
const raw = readFileSync(claudeMdAbs, 'utf8');
|
|
219
|
+
const body = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
|
|
220
|
+
const imports = body.split(/\r?\n/).some((l) => l.trim() === '@AGENTS.md');
|
|
221
|
+
const starts = countOccurrences(raw, START);
|
|
222
|
+
const ends = countOccurrences(raw, END);
|
|
223
|
+
if (imports && starts <= 1 && ends <= 1) add('OK', 'CLAUDE.md bridges to AGENTS.md');
|
|
224
|
+
else if (starts > 1 || ends > 1) add('FAIL', 'CLAUDE.md has multiple archgen blocks - fix manually');
|
|
225
|
+
else if (starts !== ends) add('FAIL', 'CLAUDE.md has broken archgen markers - fix manually');
|
|
226
|
+
else if (checkOnly) add('WOULD-FIX', 'CLAUDE.md missing @AGENTS.md bridge -> written');
|
|
227
|
+
else {
|
|
228
|
+
attempt(() => upsertManagedFile(claudeMdAbs, [START, '@AGENTS.md', END]), 'FIXED',
|
|
229
|
+
'CLAUDE.md @AGENTS.md bridge written', 'cannot write CLAUDE.md bridge');
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
for (const [status, msg] of deferredStoreFindings) add(status, msg);
|
|
234
|
+
|
|
235
|
+
// 6. Manifest entries resolve.
|
|
236
|
+
const manifest = loadManifest(root);
|
|
237
|
+
if (!manifest) {
|
|
238
|
+
if (existsSync(storeAbs)) add('WARN', `no install manifest at ${MANIFEST_REL} (re-run init to regenerate)`);
|
|
239
|
+
else add('OK', 'no manifest (nothing installed)');
|
|
240
|
+
} else {
|
|
241
|
+
const resolves = (e) => e && typeof e.path === 'string' && existsSync(join(root, ...e.path.split('/')));
|
|
242
|
+
const stale = manifest.entries.filter((e) => !resolves(e));
|
|
243
|
+
if (stale.length === 0) {
|
|
244
|
+
add('OK', `manifest: all ${manifest.entries.length} entr${manifest.entries.length === 1 ? 'y' : 'ies'} resolve`);
|
|
245
|
+
} else if (checkOnly) {
|
|
246
|
+
add('WOULD-FIX', `manifest: ${stale.length} stale entr${stale.length === 1 ? 'y' : 'ies'} -> pruned`);
|
|
247
|
+
} else {
|
|
248
|
+
saveManifest(root, { ...manifest, entries: manifest.entries.filter(resolves) });
|
|
249
|
+
add('FIXED', `manifest: pruned ${stale.length} stale entr${stale.length === 1 ? 'y' : 'ies'}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 7. Version-stamp inventory — every archgen skill tree visible from this
|
|
254
|
+
// project/machine, one line each, for tools that probe
|
|
255
|
+
// <skillRoot>/.archgen-version (the VS Code extension reads it to warn
|
|
256
|
+
// "skill outdated"). Report-only: doctor repairs the project stamp in
|
|
257
|
+
// section 2, never global installs.
|
|
258
|
+
const seen = new Set();
|
|
259
|
+
const installs = [];
|
|
260
|
+
const noteInstall = (p) => {
|
|
261
|
+
// Dedupe by realpath (two harness dirs may link one tree) but DISPLAY the
|
|
262
|
+
// discovered path — that is the path tools probe <skillRoot>/.archgen-version through.
|
|
263
|
+
let real;
|
|
264
|
+
try { real = realpathSync(p); } catch { return; } // vanished mid-scan
|
|
265
|
+
if (!seen.has(real)) { seen.add(real); installs.push(p); }
|
|
266
|
+
};
|
|
267
|
+
if (existsSync(storeAbs)) noteInstall(storeAbs);
|
|
268
|
+
for (const t of globalTargets(homeDir)) {
|
|
269
|
+
const p = join(t, 'archgen');
|
|
270
|
+
if (existsSync(p)) noteInstall(p);
|
|
271
|
+
}
|
|
272
|
+
const ghSkills = join(root, '.github', 'skills', 'archgen');
|
|
273
|
+
if (existsSync(ghSkills)) noteInstall(ghSkills);
|
|
274
|
+
for (const p of installs) {
|
|
275
|
+
const v = readStamp(p);
|
|
276
|
+
const slot = v ?? (existsSync(join(p, VERSION_FILE)) ? 'unknown (pre-0.1 stamp)' : 'missing');
|
|
277
|
+
add(v ? 'OK' : 'WARN', `skill ${p}: ${slot}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const tally = { OK: 0, FIXED: 0, 'WOULD-FIX': 0, WARN: 0, FAIL: 0 };
|
|
281
|
+
for (const c of checks) tally[c.status]++;
|
|
282
|
+
return { root, checks, tally, failures: opFailures };
|
|
283
|
+
}
|
package/lib/init.js
CHANGED
|
@@ -1,13 +1,28 @@
|
|
|
1
|
-
// init.js —
|
|
1
|
+
// init.js — single-store project installer + AGENTS.md/CLAUDE.md context files.
|
|
2
2
|
//
|
|
3
|
-
// `archgen init` makes a repository SELF-CONTAINED
|
|
4
|
-
// .agents/skills/archgen (committed to git,
|
|
5
|
-
// .claude/skills/archgen
|
|
6
|
-
//
|
|
3
|
+
// `archgen init` makes a repository SELF-CONTAINED with ONE real copy of the
|
|
4
|
+
// skill: the canonical store at .agents/skills/archgen (committed to git,
|
|
5
|
+
// team-shared). .claude/skills/archgen becomes a RELATIVE symlink to the
|
|
6
|
+
// store — skipped gracefully where symlinks are unavailable, never a second
|
|
7
|
+
// copy. AGENTS.md carries a managed pointer block with an embedded features
|
|
8
|
+
// registry; CLAUDE.md carries a one-line `@AGENTS.md` bridge. Every created
|
|
9
|
+
// artifact is recorded in .archgen/.install-manifest.json.
|
|
10
|
+
//
|
|
11
|
+
// Verified-surgical safety semantics:
|
|
12
|
+
// - existing paths are fingerprinted (stable-order sha256 over relpaths +
|
|
13
|
+
// contents) BEFORE any mutation;
|
|
14
|
+
// - an identical store is refreshed in place; a divergent one is moved to
|
|
15
|
+
// .archgen/.backup/<timestamp>/ FIRST, then reinstalled fresh;
|
|
16
|
+
// - a real-directory claude copy identical to the vendor skill is migrated
|
|
17
|
+
// to a symlink; a customized one is left untouched with a warning;
|
|
18
|
+
// - installs are atomic: built in a temp sibling dir, then renamed.
|
|
7
19
|
|
|
8
|
-
import { cpSync, existsSync, mkdirSync, readFileSync,
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
20
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, renameSync, rmSync, symlinkSync, unlinkSync } from 'node:fs';
|
|
21
|
+
import { randomBytes } from 'node:crypto';
|
|
22
|
+
import { dirname, join, resolve } from 'node:path';
|
|
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';
|
|
25
|
+
import { writeStamp } from './version-stamp.js';
|
|
11
26
|
|
|
12
27
|
const CONTEXT_FILES = ['AGENTS.md', 'CLAUDE.md'];
|
|
13
28
|
|
|
@@ -21,36 +36,124 @@ export function resolveSkillSource(packageRoot) {
|
|
|
21
36
|
throw new Error('bundled archgen skill not found (expected vendor/skills/archgen)');
|
|
22
37
|
}
|
|
23
38
|
|
|
39
|
+
function lstatSafe(p) {
|
|
40
|
+
try { return lstatSync(p); } catch { return null; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function relJoin(rel) {
|
|
44
|
+
return join(...rel.split('/'));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Create the claude adapter: `<project>/.claude/skills/archgen` as a RELATIVE
|
|
49
|
+
* symlink into the canonical store. Never copies. Migration/keep semantics:
|
|
50
|
+
* - ours already -> 'existing'
|
|
51
|
+
* - real dir identical to vendor -> replaced with symlink ('migrated')
|
|
52
|
+
* - real dir modified / foreign live symlink / stray file -> hands off + warn
|
|
53
|
+
* - dangling symlink -> recreated
|
|
54
|
+
* - symlink creation fails (privileges/FS) -> skipped + warning
|
|
55
|
+
*/
|
|
56
|
+
function installClaudeAdapter(root, source, warnings) {
|
|
57
|
+
const claudeAbs = join(root, relJoin(CLAUDE_LINK_REL));
|
|
58
|
+
const storeAbs = join(root, relJoin(STORE_REL));
|
|
59
|
+
const st = lstatSafe(claudeAbs);
|
|
60
|
+
let migrating = false;
|
|
61
|
+
|
|
62
|
+
if (st && st.isSymbolicLink()) {
|
|
63
|
+
const resolvedTarget = resolve(dirname(claudeAbs), readlinkSync(claudeAbs));
|
|
64
|
+
if (resolvedTarget === storeAbs && existsSync(claudeAbs)) return 'existing';
|
|
65
|
+
if (existsSync(claudeAbs)) {
|
|
66
|
+
warnings.push('.claude/skills/archgen is a foreign symlink - left untouched');
|
|
67
|
+
return 'kept-divergent';
|
|
68
|
+
}
|
|
69
|
+
unlinkSync(claudeAbs); // dangling: recreate ours below
|
|
70
|
+
} else if (st && st.isDirectory()) {
|
|
71
|
+
if (hashDir(claudeAbs) === hashDir(source)) {
|
|
72
|
+
rmSync(claudeAbs, { recursive: true }); // legacy dual copy -> migrate
|
|
73
|
+
migrating = true;
|
|
74
|
+
} else {
|
|
75
|
+
warnings.push('user-customized claude copy kept at .claude/skills/archgen (not managed)');
|
|
76
|
+
return 'kept-divergent';
|
|
77
|
+
}
|
|
78
|
+
} else if (st) {
|
|
79
|
+
warnings.push('.claude/skills/archgen exists and is not a directory - left untouched');
|
|
80
|
+
return 'kept-divergent';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
mkdirSync(dirname(claudeAbs), { recursive: true });
|
|
85
|
+
symlinkSync('../../.agents/skills/archgen', claudeAbs);
|
|
86
|
+
appendEntry(root, 'link', CLAUDE_LINK_REL);
|
|
87
|
+
return migrating ? 'migrated' : 'created';
|
|
88
|
+
} catch (e) {
|
|
89
|
+
warnings.push('claude adapter skipped: symlink unavailable (' + e.message + ')');
|
|
90
|
+
return 'skipped';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
24
94
|
/**
|
|
25
|
-
* Initialize a project:
|
|
95
|
+
* Initialize a project: install the single canonical skill store, wire the
|
|
96
|
+
* claude adapter, and write context pointers.
|
|
26
97
|
* @param {string} projectDir target repo root
|
|
27
98
|
* @param {string} packageRoot dir containing this CLI package
|
|
28
|
-
* @
|
|
99
|
+
* @param {{force?: boolean}} opts --force proceeds automatically; divergent
|
|
100
|
+
* copies are always auto-backed-up (never refused, never destroyed)
|
|
101
|
+
* @returns {{storePath: string, claudeLink: 'created'|'existing'|'migrated'|'skipped'|'kept-divergent',
|
|
102
|
+
* backups: string[], contextFiles: string[], createdContextFiles: string[], warnings: string[]}}
|
|
29
103
|
*/
|
|
30
|
-
export function initProject(projectDir, packageRoot) {
|
|
104
|
+
export function initProject(projectDir, packageRoot, opts = {}) {
|
|
105
|
+
// --force is accepted for explicitness: this installer never refuses and
|
|
106
|
+
// never destroys data — divergent copies are backed up automatically, so
|
|
107
|
+
// both plain and --force runs take the same safe path.
|
|
108
|
+
const force = opts.force === true;
|
|
109
|
+
void force;
|
|
110
|
+
|
|
31
111
|
const root = resolve(projectDir);
|
|
32
112
|
const source = resolveSkillSource(packageRoot);
|
|
113
|
+
const version = cliVersion(packageRoot);
|
|
114
|
+
const warnings = [];
|
|
115
|
+
const backups = [];
|
|
116
|
+
|
|
117
|
+
const storeAbs = join(root, relJoin(STORE_REL));
|
|
118
|
+
|
|
119
|
+
// 1. Canonical store — fingerprint before mutating (ownership check).
|
|
120
|
+
const prev = lstatSafe(storeAbs);
|
|
121
|
+
if (prev) {
|
|
122
|
+
const identical = prev.isDirectory() && !prev.isSymbolicLink()
|
|
123
|
+
&& hashDir(storeAbs, { ignore: [VERSION_FILE] }) === hashDir(source);
|
|
124
|
+
if (!identical) backups.push(moveToBackup(root, STORE_REL));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Atomic install: build in a temp sibling dir, then rename into place.
|
|
128
|
+
const skillsParent = dirname(storeAbs);
|
|
129
|
+
mkdirSync(skillsParent, { recursive: true });
|
|
130
|
+
const tmp = join(skillsParent, '.archgen-tmp-' + randomBytes(6).toString('hex'));
|
|
131
|
+
cpSync(source, tmp, { recursive: true });
|
|
132
|
+
writeStamp(tmp, version);
|
|
133
|
+
rmSync(storeAbs, { recursive: true, force: true });
|
|
134
|
+
renameSync(tmp, storeAbs);
|
|
135
|
+
appendEntry(root, 'dir', STORE_REL);
|
|
33
136
|
|
|
34
|
-
//
|
|
35
|
-
const
|
|
36
|
-
const claudeSkills = join(root, '.claude', 'skills', 'archgen');
|
|
37
|
-
mkdirSync(agentsSkills, { recursive: true });
|
|
38
|
-
cpSync(source, agentsSkills, { recursive: true });
|
|
39
|
-
mkdirSync(join(root, '.claude', 'skills'), { recursive: true });
|
|
40
|
-
cpSync(agentsSkills, claudeSkills, { recursive: true });
|
|
137
|
+
// 2. Claude adapter — relative symlink, never a second copy.
|
|
138
|
+
const claudeLink = installClaudeAdapter(root, source, warnings);
|
|
41
139
|
|
|
42
|
-
//
|
|
43
|
-
const block = renderBlock('.agents/skills/archgen');
|
|
140
|
+
// 3. Context files — managed blocks, user content preserved.
|
|
44
141
|
const contextFiles = [];
|
|
45
|
-
const
|
|
142
|
+
const createdContextFiles = [];
|
|
46
143
|
for (const name of CONTEXT_FILES) {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
144
|
+
const abs = join(root, name);
|
|
145
|
+
if (name === 'CLAUDE.md' && importsAgents(abs)) {
|
|
146
|
+
warnings.push('CLAUDE.md already imports @AGENTS.md - skipped (already-imports)');
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const existed = upsertManagedFile(
|
|
150
|
+
abs,
|
|
151
|
+
name === 'AGENTS.md' ? renderBlock(STORE_REL).split('\n') : [START, '@AGENTS.md', END],
|
|
152
|
+
);
|
|
51
153
|
contextFiles.push(name);
|
|
52
|
-
if (!existed)
|
|
154
|
+
if (!existed) createdContextFiles.push(name);
|
|
155
|
+
appendEntry(root, 'block', name);
|
|
53
156
|
}
|
|
54
157
|
|
|
55
|
-
return {
|
|
158
|
+
return { storePath: storeAbs, claudeLink, backups, contextFiles, createdContextFiles, warnings };
|
|
56
159
|
}
|
package/lib/install.js
CHANGED
|
@@ -2,19 +2,31 @@
|
|
|
2
2
|
// Installs the archgen skill into every EXISTING harness skills dir; manifest
|
|
3
3
|
// records entries so --uninstall removes exactly what we put there.
|
|
4
4
|
|
|
5
|
-
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync, lstatSync } from 'node:fs';
|
|
5
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync, lstatSync, unlinkSync } from 'node:fs';
|
|
6
6
|
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';
|
|
11
|
+
import { writeStamp } from './version-stamp.js';
|
|
12
|
+
import { loadConfig } from './config.js';
|
|
10
13
|
|
|
11
14
|
// Module-anchored default: works from the npm package (cli/lib -> cli/vendor)
|
|
12
15
|
// AND from a monorepo checkout (packages/cli/lib -> ../../skill) without depending on cwd.
|
|
13
16
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
14
17
|
|
|
15
|
-
const MANIFEST_NAME =
|
|
18
|
+
export const MANIFEST_NAME = loadConfig().files.globalManifest;
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
// Global backup vault: global installs live OUTSIDE any project, so there is
|
|
21
|
+
// no project root to host `.archgen/.backup/`. Convention chosen instead: each
|
|
22
|
+
// harness skills dir keeps its own sibling vault at
|
|
23
|
+
// <skills-dir>/.archgen-backups/<ISO-timestamp>/archgen/
|
|
24
|
+
// — a dot-prefixed sibling (never scanned as a skill by harnesses), colocated
|
|
25
|
+
// with what it protects, and discovered by lib/restore.js alongside the
|
|
26
|
+
// project vault.
|
|
27
|
+
export const GLOBAL_BACKUP_REL = '.archgen-backups';
|
|
28
|
+
|
|
29
|
+
export function globalTargets(home = homedir()) {
|
|
18
30
|
return [
|
|
19
31
|
join(home, '.claude', 'skills'),
|
|
20
32
|
join(home, '.agents', 'skills'),
|
|
@@ -32,15 +44,33 @@ function record(manifest, kind, path) {
|
|
|
32
44
|
if (!dup) writeFileSync(manifest, (existsSync(manifest) ? readFileSync(manifest, 'utf8').replace(/\n*$/, '\n') : '') + line + '\n');
|
|
33
45
|
}
|
|
34
46
|
|
|
47
|
+
function lstatSafe(p) {
|
|
48
|
+
try { return lstatSync(p); } catch { return null; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Collision-free timestamp for `<tdir>/.archgen-backups/<ts>/` (ms clock can tick twice). */
|
|
52
|
+
function nextBackupTs(tdir) {
|
|
53
|
+
for (;;) {
|
|
54
|
+
const ts = new Date().toISOString().replace(/:/g, '-');
|
|
55
|
+
if (!existsSync(join(tdir, GLOBAL_BACKUP_REL, ts))) return ts;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
35
59
|
/**
|
|
36
|
-
* @param {{copy?: boolean, home?: string}} opts
|
|
60
|
+
* @param {{copy?: boolean, home?: string, packageRoot?: string}} opts
|
|
61
|
+
* @returns {{rows: Array<[string, string, string]>, failures: number, manifest: string,
|
|
62
|
+
* backups: string[]}} rows are [STATUS, MODE, PATH]; `backups` lists
|
|
63
|
+
* absolute vault locations written by this run.
|
|
37
64
|
*/
|
|
38
65
|
export function installGlobal(opts = {}) {
|
|
39
66
|
const home = opts.home ?? homedir();
|
|
40
67
|
if (!home) throw new Error('$HOME is not set; cannot resolve target directories.');
|
|
41
|
-
const
|
|
68
|
+
const packageRoot = opts.packageRoot ?? PACKAGE_ROOT;
|
|
69
|
+
const source = resolve(resolveSkillSource(packageRoot));
|
|
70
|
+
const version = cliVersion(packageRoot);
|
|
42
71
|
const manifest = join(home, MANIFEST_NAME);
|
|
43
72
|
const rows = [];
|
|
73
|
+
const backups = [];
|
|
44
74
|
let failures = 0;
|
|
45
75
|
|
|
46
76
|
const targets = [...globalTargets(home), join(process.cwd(), '.github', 'skills')];
|
|
@@ -50,23 +80,84 @@ export function installGlobal(opts = {}) {
|
|
|
50
80
|
try {
|
|
51
81
|
mkdirSync(tdir, { recursive: true }); // no-op when present
|
|
52
82
|
if (opts.copy) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
83
|
+
// Safety invariant: fingerprint dest BEFORE mutating; identical
|
|
84
|
+
// (stamp-ignoring) skips like link mode, divergent moves into the
|
|
85
|
+
// vault first — nothing is rmSync'd without a prior backup of it.
|
|
86
|
+
const prev = lstatSafe(dest);
|
|
87
|
+
let identical = false;
|
|
88
|
+
if (prev && prev.isDirectory() && !prev.isSymbolicLink()) {
|
|
89
|
+
try { identical = hashDir(dest) === hashDir(source); } catch { identical = false; }
|
|
90
|
+
}
|
|
91
|
+
if (identical) {
|
|
92
|
+
stampNote(rows, dest, () => writeStamp(dest, version));
|
|
93
|
+
record(manifest, 'copy', dest);
|
|
94
|
+
rows.push(['SAME', 'copy', dest]);
|
|
95
|
+
} else {
|
|
96
|
+
if (prev) {
|
|
97
|
+
const loc = moveToBackupInto(tdir, 'archgen', GLOBAL_BACKUP_REL, nextBackupTs(tdir));
|
|
98
|
+
const abs = join(tdir, ...loc.split('/'));
|
|
99
|
+
backups.push(abs);
|
|
100
|
+
rows.push(['BACKED-UP', '-', abs]);
|
|
101
|
+
}
|
|
102
|
+
rmSync(dest, { force: true, recursive: true }); // no-op after a move; guards exotic-FS fallbacks
|
|
103
|
+
cpSync(source, dest, { recursive: true });
|
|
104
|
+
stampNote(rows, dest, () => writeStamp(dest, version));
|
|
105
|
+
record(manifest, 'copy', dest);
|
|
106
|
+
rows.push(['OK', 'copy', dest]);
|
|
107
|
+
}
|
|
57
108
|
} else {
|
|
58
|
-
let
|
|
59
|
-
|
|
60
|
-
if (
|
|
109
|
+
let status = 'OK';
|
|
110
|
+
const prev = lstatSafe(dest);
|
|
111
|
+
if (prev && prev.isSymbolicLink()) {
|
|
112
|
+
if (existsSync(dest)) {
|
|
113
|
+
// A live link resolving anywhere but our source belongs to the
|
|
114
|
+
// user: leave it untouched and OUT of the uninstall manifest, or
|
|
115
|
+
// `uninstall` would later delete their link. (install.sh replaces
|
|
116
|
+
// foreign links via ln -sfn — that destructive semantic is
|
|
117
|
+
// deliberately NOT copied here.)
|
|
118
|
+
const linkTarget = resolve(dirname(dest), readlinkSync(dest));
|
|
119
|
+
if (linkTarget !== source) {
|
|
120
|
+
rows.push(['KEPT', '-', dest + ' (foreign symlink, not recorded)']);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
status = 'SAME';
|
|
124
|
+
} else {
|
|
125
|
+
// Dangling (npx cache eviction): recreate against the current
|
|
126
|
+
// source instead of leaving users broken while reporting SAME.
|
|
127
|
+
unlinkSync(dest); // removes the link itself, never any target
|
|
128
|
+
symlinkSync(source, dest, 'dir');
|
|
129
|
+
status = 'REPAIRED';
|
|
130
|
+
}
|
|
131
|
+
} else if (!prev) {
|
|
132
|
+
symlinkSync(source, dest, 'dir');
|
|
133
|
+
} else {
|
|
134
|
+
// Real directory/file at dest is NEVER clobbered: symlinkSync fails
|
|
135
|
+
// with EEXIST and surfaces as FAILED below (historical behavior).
|
|
136
|
+
symlinkSync(source, dest, 'dir');
|
|
137
|
+
}
|
|
138
|
+
// The link itself holds no bytes: the stamp must live INSIDE the real
|
|
139
|
+
// directory the link resolves to (the shared copy) so a read of
|
|
140
|
+
// <link>/.archgen-version sees it — true for symlinks and Windows
|
|
141
|
+
// junctions alike.
|
|
142
|
+
stampNote(rows, dest, () => writeStamp(source, version));
|
|
61
143
|
record(manifest, 'link', dest);
|
|
62
|
-
rows.push([
|
|
144
|
+
rows.push([status, 'link', dest]);
|
|
63
145
|
}
|
|
64
146
|
} catch (e) {
|
|
65
147
|
failures++;
|
|
66
148
|
rows.push(['FAILED', '-', dest + ' (' + e.message + ')']);
|
|
67
149
|
}
|
|
68
150
|
}
|
|
69
|
-
return { rows, failures, manifest };
|
|
151
|
+
return { rows, failures, manifest, backups };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Install succeeded even when the stamp could not be written (e.g. read-only vendor); surface it without failing. */
|
|
155
|
+
function stampNote(rows, dest, write) {
|
|
156
|
+
try {
|
|
157
|
+
write();
|
|
158
|
+
} catch (e) {
|
|
159
|
+
rows.push(['WARN', '-', dest + ' (installed; version stamp not written: ' + e.message + ')']);
|
|
160
|
+
}
|
|
70
161
|
}
|
|
71
162
|
|
|
72
163
|
export function uninstallGlobal(home = homedir()) {
|