@hanv89/arch-skill 0.0.0 → 0.2.0

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.
@@ -0,0 +1,710 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.USER_AGENT = exports.FETCH_TIMEOUT_MS = exports.CANARY_ICON_PATH = exports.MANIFEST_PATH = exports.SKILL_NAME = exports.DEFAULT_BASE_RAW_URL = void 0;
40
+ exports.baseUrl = baseUrl;
41
+ exports.safeResolveTarget = safeResolveTarget;
42
+ exports.joinWithinTarget = joinWithinTarget;
43
+ exports.verifyFileHash = verifyFileHash;
44
+ exports.fetchWithTimeout = fetchWithTimeout;
45
+ exports.fetchText = fetchText;
46
+ exports.headOk = headOk;
47
+ exports.fetchManifest = fetchManifest;
48
+ exports.parseFrontmatter = parseFrontmatter;
49
+ exports.stripFrontmatter = stripFrontmatter;
50
+ exports.satisfiesRequiresIcons = satisfiesRequiresIcons;
51
+ exports.verifyIconsAvailability = verifyIconsAvailability;
52
+ exports.withFatalReturn = withFatalReturn;
53
+ exports.makeFolderInstallAdapter = makeFolderInstallAdapter;
54
+ exports.verifyBundleFile = verifyBundleFile;
55
+ const fs = __importStar(require("node:fs/promises"));
56
+ const path = __importStar(require("node:path"));
57
+ const crypto = __importStar(require("node:crypto"));
58
+ const js_yaml_1 = require("js-yaml");
59
+ const package_json_1 = __importDefault(require("../../package.json"));
60
+ // Shared adapter plumbing. Helpers here must be agent-agnostic — anything
61
+ // Claude-Code-specific (default install path, allowed root) lives in the
62
+ // adapter file that imports from here. Codex / Cursor / future adapters
63
+ // re-use these helpers via the same import path.
64
+ exports.DEFAULT_BASE_RAW_URL = "https://raw.githubusercontent.com/hanv89/archicon/main";
65
+ // Same repo root without the ref segment; baseUrl() appends `main` (default)
66
+ // or `skill-vX.Y.Z` when --version is supplied.
67
+ const RAW_BASE_NO_REF = "https://raw.githubusercontent.com/hanv89/archicon";
68
+ const VERSION_RE = /^\d+\.\d+\.\d+$/;
69
+ // SKILL_NAME must stay in lockstep with dist/skill/SKILL.md frontmatter `name`.
70
+ // Renaming the skill is a breaking change requiring a coordinated CLI release;
71
+ // existing installs become un-uninstallable until users upgrade the CLI
72
+ // (uninstall's allow-list refuses folders whose SKILL.md `name` differs).
73
+ exports.SKILL_NAME = "architecture-diagram";
74
+ // Fetched at install/update time from dist/skill/manifest.json. files[0] MUST
75
+ // be SKILL.md so the frontmatter precheck has a stable target.
76
+ exports.MANIFEST_PATH = "dist/skill/manifest.json";
77
+ exports.CANARY_ICON_PATH = "dist/Azure/Compute/AzureVirtualMachine.png";
78
+ exports.FETCH_TIMEOUT_MS = 30_000;
79
+ exports.USER_AGENT = `arch-skill/${package_json_1.default.version}`;
80
+ const ALLOWED_BASE_URL_HOSTS = new Set(["raw.githubusercontent.com"]);
81
+ const ALLOWED_BASE_URL_PATH_PREFIX = "/hanv89/archicon/";
82
+ /**
83
+ * Resolve the base URL for fetching the skill bundle.
84
+ *
85
+ * - `version` undefined → `<RAW_BASE>/main` (default, tracks the upstream main branch).
86
+ * - `version="X.Y.Z"` → `<RAW_BASE>/skill-vX.Y.Z` (tag-pinned fetch).
87
+ * - `ARCH_SKILL_BASE_URL` env set → env override wins; `version` is ignored
88
+ * (the env exists only for validation harnesses).
89
+ *
90
+ * `version` is validated against the strict X.Y.Z regex here as defense-in-depth;
91
+ * `src/index.ts` also rejects malformed values pre-dispatch.
92
+ */
93
+ function baseUrl(version) {
94
+ const override = process.env.ARCH_SKILL_BASE_URL;
95
+ if (override) {
96
+ let u;
97
+ try {
98
+ u = new URL(override);
99
+ }
100
+ catch {
101
+ throw new Error(`ARCH_SKILL_BASE_URL is not a valid URL: ${override}`);
102
+ }
103
+ if (u.protocol !== "https:") {
104
+ throw new Error(`ARCH_SKILL_BASE_URL must use https; got ${u.protocol}`);
105
+ }
106
+ if (!ALLOWED_BASE_URL_HOSTS.has(u.hostname)) {
107
+ throw new Error(`ARCH_SKILL_BASE_URL host '${u.hostname}' not in allow-list (${[...ALLOWED_BASE_URL_HOSTS].join(", ")})`);
108
+ }
109
+ if (!u.pathname.startsWith(ALLOWED_BASE_URL_PATH_PREFIX)) {
110
+ throw new Error(`ARCH_SKILL_BASE_URL path must start with ${ALLOWED_BASE_URL_PATH_PREFIX}`);
111
+ }
112
+ process.stderr.write(`warn: ARCH_SKILL_BASE_URL override active: ${override}\n`);
113
+ return override.replace(/\/$/, "");
114
+ }
115
+ if (version !== undefined) {
116
+ if (!VERSION_RE.test(version)) {
117
+ throw new Error(`--version must match X.Y.Z (got: ${version})`);
118
+ }
119
+ return `${RAW_BASE_NO_REF}/skill-v${version}`;
120
+ }
121
+ return exports.DEFAULT_BASE_RAW_URL;
122
+ }
123
+ let envTargetRootWarned = false;
124
+ /**
125
+ * Resolve `target` and assert it lives inside an allowed root. Resolution
126
+ * follows symlinks (via fs.realpath on the deepest existing ancestor) so
127
+ * a symlink inside an allowed root that points outside cannot bypass the
128
+ * check.
129
+ *
130
+ * `defaultAllowedRoot` is supplied by the adapter (e.g. `~/.claude` for the
131
+ * Claude Code adapter, `~/.codex` for a future Codex adapter). Setting the
132
+ * `ARCH_SKILL_TARGET_ROOT` env var widens the allow-list to include
133
+ * that root (intended for validation/CI use against a `mktemp -d` directory).
134
+ * Production users should never set the env var.
135
+ *
136
+ * `displayName` controls how the default root appears in error messages
137
+ * when the check fails (e.g. `~/.claude` instead of `/home/user/.claude`).
138
+ * Defaults to the resolved absolute path.
139
+ */
140
+ async function safeResolveTarget(target, defaultAllowedRoot, displayName = defaultAllowedRoot) {
141
+ const lexicallyResolved = path.resolve(target);
142
+ let probe = lexicallyResolved;
143
+ let realProbe = null;
144
+ while (true) {
145
+ try {
146
+ realProbe = await fs.realpath(probe);
147
+ break;
148
+ }
149
+ catch (err) {
150
+ const code = err.code;
151
+ if (code !== "ENOENT" && code !== "ENOTDIR")
152
+ throw err;
153
+ const parent = path.dirname(probe);
154
+ if (parent === probe) {
155
+ throw new Error(`unable to resolve target ${target}`);
156
+ }
157
+ probe = parent;
158
+ }
159
+ }
160
+ const tail = lexicallyResolved.slice(probe.length);
161
+ const realResolved = path.resolve(realProbe + tail);
162
+ const explicit = process.env.ARCH_SKILL_TARGET_ROOT;
163
+ if (explicit && !envTargetRootWarned) {
164
+ process.stderr.write(`warn: ARCH_SKILL_TARGET_ROOT override active: ${explicit}\n`);
165
+ envTargetRootWarned = true;
166
+ }
167
+ const allowedRoots = [
168
+ path.resolve(defaultAllowedRoot),
169
+ explicit ? path.resolve(explicit) : null,
170
+ ].filter((r) => r !== null);
171
+ const inside = allowedRoots.some(root => realResolved === root || realResolved.startsWith(root + path.sep));
172
+ if (!inside) {
173
+ const allowList = `${displayName}${explicit ? `, $ARCH_SKILL_TARGET_ROOT=${explicit}` : ""}`;
174
+ throw new Error(`refusing to operate on ${realResolved} (resolved from ${target}) - outside allowed roots (${allowList})`);
175
+ }
176
+ return realResolved;
177
+ }
178
+ /**
179
+ * Defense-in-depth: assert a manifest `dest` resolves inside `target` before
180
+ * any write/unlink. `fetchManifest` already rejects absolute / `..` paths at
181
+ * the trust boundary; this is the second guard at the filesystem-touch site so
182
+ * a per-file path can never escape the install dir even if the parse-time
183
+ * check is ever bypassed. Returns the safe absolute path.
184
+ */
185
+ function joinWithinTarget(target, dest) {
186
+ const full = path.resolve(target, dest);
187
+ const root = path.resolve(target);
188
+ if (full !== root && !full.startsWith(root + path.sep)) {
189
+ throw new Error(`refusing to operate on ${full} (from dest '${dest}') - outside install target ${root}`);
190
+ }
191
+ return full;
192
+ }
193
+ /**
194
+ * Compute the sha256 of `body` and throw a clear error if it does not match
195
+ * `expectedSha256` (case-insensitive hex compare). Adapter-agnostic so every
196
+ * adapter built on the shared helpers inherits verify-before-write integrity.
197
+ */
198
+ function verifyFileHash(body, expectedSha256) {
199
+ const actual = crypto.createHash("sha256").update(body).digest("hex");
200
+ if (actual.toLowerCase() !== expectedSha256.toLowerCase()) {
201
+ throw new Error(`sha256 mismatch: expected ${expectedSha256}, computed ${actual}`);
202
+ }
203
+ }
204
+ /**
205
+ * Fetch with timeout and 2-retry exponential backoff on transient 5xx
206
+ * responses. Used by `fetchText` and `headOk`; both inherit the retry
207
+ * behavior. The 2-retry default was added to absorb transient 5xx upstream
208
+ * errors — future agent adapters reusing this helper get the retry path
209
+ * for free.
210
+ *
211
+ * Backoff schedule: 500ms after attempt 0, 1s after attempt 1, 2s after
212
+ * attempt 2. Network errors (AbortError, DNS failures) re-throw only
213
+ * after the final attempt.
214
+ *
215
+ * @internal — exported only so unit tests can mock `globalThis.fetch`
216
+ * around it. Not part of the public adapter API.
217
+ */
218
+ async function fetchWithTimeout(url, init = {}, retries = 2) {
219
+ let lastError;
220
+ for (let attempt = 0; attempt <= retries; attempt++) {
221
+ const ctrl = new AbortController();
222
+ const t = setTimeout(() => ctrl.abort(), exports.FETCH_TIMEOUT_MS);
223
+ try {
224
+ const res = await fetch(url, {
225
+ ...init,
226
+ signal: ctrl.signal,
227
+ headers: { ...(init.headers || {}), "User-Agent": exports.USER_AGENT },
228
+ });
229
+ if (res.status < 500 || attempt === retries) {
230
+ return res;
231
+ }
232
+ }
233
+ catch (e) {
234
+ lastError = e;
235
+ if (attempt === retries)
236
+ throw e;
237
+ }
238
+ finally {
239
+ clearTimeout(t);
240
+ }
241
+ await new Promise((r) => setTimeout(r, 2 ** attempt * 500));
242
+ }
243
+ throw lastError ?? new Error("fetchWithTimeout: exhausted retries");
244
+ }
245
+ async function fetchText(url) {
246
+ const res = await fetchWithTimeout(url);
247
+ if (!res.ok)
248
+ throw new Error(`fetch ${url} returned HTTP ${res.status}`);
249
+ return res.text();
250
+ }
251
+ async function headOk(url) {
252
+ const res = await fetchWithTimeout(url, { method: "HEAD" });
253
+ return res.ok;
254
+ }
255
+ /**
256
+ * Fetch + parse the bundle manifest. Validates required fields and the
257
+ * SKILL.md-at-index-0 invariant. Throws with a clear error on any issue —
258
+ * callers should not silently fall back.
259
+ */
260
+ async function fetchManifest(base) {
261
+ const url = `${base}/${exports.MANIFEST_PATH}`;
262
+ const body = await fetchText(url);
263
+ let parsed;
264
+ try {
265
+ parsed = JSON.parse(body);
266
+ }
267
+ catch (err) {
268
+ throw new Error(`manifest ${url} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
269
+ }
270
+ if (!parsed || typeof parsed !== "object") {
271
+ throw new Error(`manifest ${url} did not parse to an object`);
272
+ }
273
+ const m = parsed;
274
+ for (const key of ["name", "version", "requires_icons"]) {
275
+ if (typeof m[key] !== "string" || !m[key]) {
276
+ throw new Error(`manifest ${url} missing required field: ${key}`);
277
+ }
278
+ }
279
+ if (m.icons_version !== undefined) {
280
+ if (typeof m.icons_version !== "string" || !/^\d+\.\d+\.\d+$/.test(m.icons_version)) {
281
+ throw new Error(`manifest ${url} icons_version malformed (must match X.Y.Z): ${String(m.icons_version)}`);
282
+ }
283
+ }
284
+ if (!Array.isArray(m.files) || m.files.length === 0) {
285
+ throw new Error(`manifest ${url} files[] missing or empty`);
286
+ }
287
+ for (const [i, f] of m.files.entries()) {
288
+ if (!f || typeof f !== "object") {
289
+ throw new Error(`manifest ${url} files[${i}] not an object`);
290
+ }
291
+ for (const key of ["src", "dest", "role"]) {
292
+ if (typeof f[key] !== "string") {
293
+ throw new Error(`manifest ${url} files[${i}].${key} missing`);
294
+ }
295
+ }
296
+ // Optional sha256: when present it must be 64 hex chars. A malformed
297
+ // value is rejected here rather than silently skipped, so a typo in a
298
+ // published manifest cannot quietly disable integrity checking.
299
+ const sha = f.sha256;
300
+ if (sha !== undefined && (typeof sha !== "string" || !/^[0-9a-fA-F]{64}$/.test(sha))) {
301
+ throw new Error(`manifest ${url} files[${i}].sha256 must be a 64-char hex string (got: ${String(sha)})`);
302
+ }
303
+ // Path-traversal guard: dest/src are joined onto the install target +
304
+ // the fetch base. A manifest from a compromised repo / malicious tag with
305
+ // an absolute path or a `..` segment could escape the target dir (arbitrary
306
+ // file write) or fetch off-path. Reject both here, at the trust boundary.
307
+ for (const key of ["dest", "src"]) {
308
+ const p = f[key];
309
+ if (path.isAbsolute(p) || p.split(/[\\/]/).includes("..")) {
310
+ throw new Error(`manifest ${url} files[${i}].${key} must be a relative path with no '..' segment (got: ${p})`);
311
+ }
312
+ }
313
+ }
314
+ if (m.files[0].dest !== "SKILL.md" || m.files[0].role !== "skill") {
315
+ throw new Error(`manifest ${url} files[0] must be SKILL.md (role=skill); got dest=${m.files[0].dest} role=${m.files[0].role}`);
316
+ }
317
+ return m;
318
+ }
319
+ /**
320
+ * YAML frontmatter parser. Uses `js-yaml` to handle the full YAML spec
321
+ * (folded scalars `>`, literal block scalars `|`, quoted strings, comments,
322
+ * nested mappings, etc) so SKILL.md frontmatter can grow beyond simple
323
+ * `key: value` pairs without silent mis-parses.
324
+ *
325
+ * Returns only the 3 keys the CLI cares about (`name`, `version`,
326
+ * `requires_icons`); other top-level keys are ignored.
327
+ */
328
+ function parseFrontmatter(md) {
329
+ const text = md.replace(/^/, "");
330
+ const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
331
+ if (!match)
332
+ return {};
333
+ let parsed;
334
+ try {
335
+ parsed = (0, js_yaml_1.load)(match[1]);
336
+ }
337
+ catch (err) {
338
+ return {};
339
+ }
340
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
341
+ return {};
342
+ const obj = parsed;
343
+ const out = {};
344
+ for (const key of ["name", "version", "requires_icons"]) {
345
+ const raw = obj[key];
346
+ if (typeof raw === "string") {
347
+ out[key] = raw;
348
+ }
349
+ else if (typeof raw === "number") {
350
+ out[key] = String(raw);
351
+ }
352
+ }
353
+ return out;
354
+ }
355
+ /**
356
+ * Strip the leading `---\n...\n---\n` YAML frontmatter block from a markdown
357
+ * string. Returns the body unchanged if no frontmatter is detected.
358
+ *
359
+ * Used by adapters that re-render the upstream SKILL.md for a different host
360
+ * (e.g. Cursor's `.mdc` rule files, which carry their own frontmatter shape
361
+ * and embed the SKILL.md body without its original frontmatter).
362
+ */
363
+ function stripFrontmatter(md) {
364
+ const text = md.replace(/^/, "");
365
+ const match = text.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
366
+ return match ? text.slice(match[0].length) : text;
367
+ }
368
+ /**
369
+ * Test whether an icons-tag semver satisfies the SKILL.md's `requires_icons`
370
+ * constraint. Hand-rolled to keep the runtime dep tree minimal (commander +
371
+ * nothing else; pulling in `semver` would add transitive deps for a feature
372
+ * that today only needs `>=X.Y.Z` matching).
373
+ *
374
+ * Supported constraint forms:
375
+ * - "X.Y.Z" (exact match)
376
+ * - ">=X.Y.Z" (tag >= constraint)
377
+ * - "^X.Y.Z" (same major, tag >= constraint — npm caret semantics)
378
+ * - "~X.Y.Z" (same major.minor, tag.patch >= constraint.patch)
379
+ *
380
+ * Throws on any other input. The project's SKILL.md frontmatter only ships
381
+ * `>=X.Y.Z` today; the other 3 forms exist for future-proofing.
382
+ *
383
+ * Numeric encoding `maj * 1e6 + min * 1e3 + pat` rules out individual segments
384
+ * >= 1000 — if a future icons release ever bumps any segment to 4 digits the
385
+ * encoding silently collides (e.g. 1.0.1000 vs 1.1.0). We hard-fail on that
386
+ * input rather than mis-compare.
387
+ */
388
+ // Pre-release suffixes (`1.2.3-rc.1`) are intentionally NOT supported here
389
+ // or in the `VERSION_RE` regex above. The release workflows tag from main
390
+ // only — no rc branches in scope. If pre-release tags ever ship, this
391
+ // matcher needs a re-design (build-metadata + precedence ordering).
392
+ const SEMVER_SEGMENT_MAX = 999;
393
+ function satisfiesRequiresIcons(constraint, iconsSemver) {
394
+ const tagParts = iconsSemver.split(".").map(Number);
395
+ if (tagParts.length !== 3 || tagParts.some(n => isNaN(n))) {
396
+ throw new Error(`icons semver malformed: ${iconsSemver}`);
397
+ }
398
+ const [tagMaj, tagMin, tagPat] = tagParts;
399
+ if (tagMaj > SEMVER_SEGMENT_MAX || tagMin > SEMVER_SEGMENT_MAX || tagPat > SEMVER_SEGMENT_MAX) {
400
+ throw new Error(`icons semver segment exceeds matcher capacity (${SEMVER_SEGMENT_MAX}): ${iconsSemver}`);
401
+ }
402
+ const trimmed = constraint.trim().replace(/^["']|["']$/g, "");
403
+ const m = trimmed.match(/^(>=|\^|~|)(\d+)\.(\d+)\.(\d+)$/);
404
+ if (!m) {
405
+ throw new Error(`requires_icons constraint form not supported: ${constraint}`);
406
+ }
407
+ const [, op, majS, minS, patS] = m;
408
+ const maj = Number(majS);
409
+ const min = Number(minS);
410
+ const pat = Number(patS);
411
+ if (maj > SEMVER_SEGMENT_MAX || min > SEMVER_SEGMENT_MAX || pat > SEMVER_SEGMENT_MAX) {
412
+ throw new Error(`requires_icons segment exceeds matcher capacity (${SEMVER_SEGMENT_MAX}): ${constraint}`);
413
+ }
414
+ const tag = tagMaj * 1e6 + tagMin * 1e3 + tagPat;
415
+ const ref = maj * 1e6 + min * 1e3 + pat;
416
+ if (op === "")
417
+ return tag === ref;
418
+ if (op === ">=")
419
+ return tag >= ref;
420
+ if (op === "^")
421
+ return tagMaj === maj && tag >= ref;
422
+ if (op === "~")
423
+ return tagMaj === maj && tagMin === min && tagPat >= pat;
424
+ throw new Error(`unreachable constraint op: ${op}`);
425
+ }
426
+ /**
427
+ * Verify the icon set the skill bundle references is reachable AND its
428
+ * semver satisfies SKILL.md's requires_icons.
429
+ *
430
+ * Source of the icons-tag, in priority order:
431
+ * 1. `manifest.icons_version` — exact tag (preferred).
432
+ * 2. Lower-bound parse of `manifest.requires_icons` — fallback for
433
+ * bundles published before the field landed (cannot be edited
434
+ * retroactively on a tag).
435
+ *
436
+ * The fallback path makes the gate trivially pass by construction (a
437
+ * lower bound always satisfies its own constraint), preserving install
438
+ * behaviour for older tags. New bundles ship the field, so the gate
439
+ * becomes a real cross-track compatibility check going forward.
440
+ */
441
+ async function verifyIconsAvailability(base, manifest, requestedVersion) {
442
+ const requires = manifest.requires_icons;
443
+ const canaryUrl = `${base}/${exports.CANARY_ICON_PATH}`;
444
+ const reachable = await headOk(canaryUrl);
445
+ if (!reachable) {
446
+ throw new Error(`icon-set unreachable - HEAD ${canaryUrl} failed (skill declares requires_icons=${requires})`);
447
+ }
448
+ if (!requestedVersion) {
449
+ return;
450
+ }
451
+ let iconsTagSemver;
452
+ let source;
453
+ if (manifest.icons_version) {
454
+ iconsTagSemver = manifest.icons_version;
455
+ source = `manifest icons_version`;
456
+ }
457
+ else {
458
+ const lowerMatch = requires.match(/(\d+\.\d+\.\d+)/);
459
+ if (!lowerMatch) {
460
+ throw new Error(`SKILL.md requires_icons has no parseable lower bound: ${requires}`);
461
+ }
462
+ iconsTagSemver = lowerMatch[1];
463
+ source = `requires_icons lower-bound (bundle has no icons_version field)`;
464
+ }
465
+ if (!satisfiesRequiresIcons(requires, iconsTagSemver)) {
466
+ throw new Error(`requires_icons constraint ${requires} not satisfied by ${source} ${iconsTagSemver}`);
467
+ }
468
+ }
469
+ async function withFatalReturn(fn) {
470
+ try {
471
+ return await fn();
472
+ }
473
+ catch (err) {
474
+ process.stderr.write(`fatal: ${err instanceof Error ? err.message : String(err)}\n`);
475
+ return 1;
476
+ }
477
+ }
478
+ // Persisted at install time so uninstall can iterate the file list without
479
+ // re-fetching the manifest over the network. Hidden filename so it doesn't
480
+ // clutter the user-visible skill folder.
481
+ //
482
+ // LOAD-BEARING: this basename is the only signal uninstall has to
483
+ // distinguish a current-era install from a pre-0.9.0 install (when no
484
+ // manifest was persisted). Renaming this constant is a one-way migration:
485
+ // installs done under the old name fall into the legacy whole-folder
486
+ // `rm -rf` path, which still works but loses the manifest-scoped
487
+ // preservation of user-authored content alongside the skill. If renamed,
488
+ // keep at least one release cycle of dual-read support.
489
+ const PERSISTED_MANIFEST_BASENAME = ".arch-skill-manifest.json";
490
+ function makeFolderInstallAdapter(cfg) {
491
+ const defaultTarget = () => path.join(cfg.rootDir(), "skills", exports.SKILL_NAME);
492
+ const defaultSkillsRoot = () => path.join(cfg.rootDir(), "skills");
493
+ const resolve = (target) => safeResolveTarget(target, cfg.rootDir(), cfg.rootDisplay());
494
+ const isOurSkillDir = async (dir) => {
495
+ try {
496
+ const skillMd = await fs.readFile(path.join(dir, "SKILL.md"), "utf8");
497
+ return parseFrontmatter(skillMd).name === exports.SKILL_NAME;
498
+ }
499
+ catch {
500
+ return false;
501
+ }
502
+ };
503
+ async function install(opts) {
504
+ return withFatalReturn(async () => {
505
+ const target = await resolve(opts.target ?? defaultTarget());
506
+ const base = baseUrl(opts.version);
507
+ if (!process.env.ARCH_SKILL_TARGET_ROOT && path.basename(target) !== exports.SKILL_NAME) {
508
+ throw new Error(`refusing to install at ${target} - target basename must be '${exports.SKILL_NAME}' (default ${cfg.rootDisplay()}/skills/${exports.SKILL_NAME}/). Set ARCH_SKILL_TARGET_ROOT to install into a custom test root.`);
509
+ }
510
+ const manifest = await fetchManifest(base);
511
+ if (manifest.name !== exports.SKILL_NAME) {
512
+ throw new Error(`manifest name mismatch: expected '${exports.SKILL_NAME}', got '${manifest.name}'. CLI and bundle are out of sync.`);
513
+ }
514
+ const presence = await Promise.all(manifest.files.map(async ({ dest }) => ({
515
+ dest,
516
+ exists: await fs.stat(path.join(target, dest)).then(() => true).catch(() => false),
517
+ })));
518
+ const someExist = presence.some(p => p.exists);
519
+ const allExist = presence.every(p => p.exists);
520
+ if (someExist && !opts.overwrite) {
521
+ throw new Error(allExist
522
+ ? `${target} already contains an install. Run 'arch-skill update --agent=${cfg.agentFlag}' to refresh.`
523
+ : `${target} contains a partial install (${presence.filter(p => !p.exists).map(p => p.dest).join(", ")} missing). Run 'arch-skill update --agent=${cfg.agentFlag}' to repair.`);
524
+ }
525
+ const skillFile = manifest.files[0];
526
+ const skillUrl = `${base}/${skillFile.src}`;
527
+ const skillMd = await fetchText(skillUrl);
528
+ // Integrity gate (verify-before-write) for SKILL.md. See verifyBundleFile.
529
+ verifyBundleFile(skillFile, skillMd);
530
+ const fm = parseFrontmatter(skillMd);
531
+ if (!fm.requires_icons) {
532
+ throw new Error("SKILL.md missing requires_icons frontmatter");
533
+ }
534
+ await verifyIconsAvailability(base, manifest, opts.version);
535
+ // Fetch + verify every remaining file BEFORE writing any of them, so an
536
+ // integrity failure aborts the whole install with nothing written.
537
+ const remaining = manifest.files.slice(1);
538
+ const remainingBodies = [];
539
+ for (const f of remaining) {
540
+ const body = await fetchText(`${base}/${f.src}`);
541
+ verifyBundleFile(f, body);
542
+ remainingBodies.push({ dest: f.dest, body });
543
+ }
544
+ for (const { dest } of manifest.files) {
545
+ await fs.mkdir(path.dirname(joinWithinTarget(target, dest)), { recursive: true });
546
+ }
547
+ await fs.writeFile(joinWithinTarget(target, skillFile.dest), skillMd, "utf8");
548
+ for (const { dest, body } of remainingBodies) {
549
+ await fs.writeFile(joinWithinTarget(target, dest), body, "utf8");
550
+ }
551
+ // Persist the manifest so uninstall can iterate the file list without
552
+ // re-fetching from the network.
553
+ await fs.writeFile(path.join(target, PERSISTED_MANIFEST_BASENAME), JSON.stringify(manifest, null, 2) + "\n", "utf8");
554
+ process.stdout.write(`installed ${exports.SKILL_NAME} to ${target}\n`);
555
+ return 0;
556
+ });
557
+ }
558
+ async function uninstall(opts) {
559
+ return withFatalReturn(async () => {
560
+ const target = await resolve(opts.target ?? defaultTarget());
561
+ const exists = await fs.stat(target).then(() => true).catch(() => false);
562
+ if (!exists) {
563
+ process.stdout.write(`(nothing to uninstall at ${target})\n`);
564
+ return 0;
565
+ }
566
+ const ours = await isOurSkillDir(target);
567
+ if (!ours) {
568
+ throw new Error(`refusing to remove ${target} - not an architecture-diagram skill folder (no matching SKILL.md). Move/rename the directory or remove it manually if intentional.`);
569
+ }
570
+ // Manifest-scoped removal: read the persisted manifest at install time
571
+ // and remove only its files + the manifest itself. Leaves any
572
+ // user-authored content under the same folder in place (with a note).
573
+ // Fallback: bundles installed before 0.9.0 have no persisted manifest;
574
+ // legacy whole-folder rm preserves the pre-0.9.0 behaviour.
575
+ const persistedPath = path.join(target, PERSISTED_MANIFEST_BASENAME);
576
+ const manifestBody = await fs.readFile(persistedPath, "utf8").catch(() => null);
577
+ if (!manifestBody) {
578
+ // Legacy uninstall: rm -rf whole folder.
579
+ try {
580
+ await fs.rm(target, { recursive: true, force: false });
581
+ }
582
+ catch (err) {
583
+ const stillExists = await fs.stat(target).then(() => true).catch(() => false);
584
+ if (stillExists) {
585
+ const msg = err instanceof Error ? err.message : String(err);
586
+ throw new Error(`uninstall partially failed at ${target}: ${msg}; manual cleanup may be required`);
587
+ }
588
+ throw err;
589
+ }
590
+ }
591
+ else {
592
+ let persistedManifest;
593
+ try {
594
+ persistedManifest = JSON.parse(manifestBody);
595
+ }
596
+ catch (err) {
597
+ const msg = err instanceof Error ? err.message : String(err);
598
+ throw new Error(`persisted manifest at ${persistedPath} is not valid JSON: ${msg}. Remove the file manually then retry.`);
599
+ }
600
+ for (const f of persistedManifest.files) {
601
+ // joinWithinTarget guards against a tampered persisted manifest whose
602
+ // dest escapes target (which would delete files outside the install).
603
+ let victim;
604
+ try {
605
+ victim = joinWithinTarget(target, f.dest);
606
+ }
607
+ catch {
608
+ continue;
609
+ }
610
+ await fs.unlink(victim).catch(() => null);
611
+ }
612
+ await fs.unlink(persistedPath).catch(() => null);
613
+ // Recursively prune empty directories under target. Stop at target
614
+ // itself — only rmdir target if no user-authored files remain.
615
+ const pruneEmptyDirs = async (dir) => {
616
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
617
+ for (const e of entries) {
618
+ if (e.isDirectory()) {
619
+ await pruneEmptyDirs(path.join(dir, e.name));
620
+ const subEntries = await fs.readdir(path.join(dir, e.name)).catch(() => []);
621
+ if (subEntries.length === 0) {
622
+ await fs.rmdir(path.join(dir, e.name)).catch(() => null);
623
+ }
624
+ }
625
+ }
626
+ };
627
+ await pruneEmptyDirs(target);
628
+ const leftover = await fs.readdir(target).catch(() => []);
629
+ if (leftover.length === 0) {
630
+ await fs.rmdir(target).catch(() => null);
631
+ }
632
+ else {
633
+ process.stdout.write(`note: ${target} contains files outside the skill manifest; left in place. Remove manually if intentional.\n`);
634
+ }
635
+ }
636
+ process.stdout.write(`uninstalled ${exports.SKILL_NAME} from ${target}\n`);
637
+ return 0;
638
+ });
639
+ }
640
+ async function update(opts) {
641
+ return withFatalReturn(async () => {
642
+ const target = await resolve(opts.target ?? defaultTarget());
643
+ const base = baseUrl(opts.version);
644
+ const manifest = await fetchManifest(base);
645
+ if (manifest.name !== exports.SKILL_NAME) {
646
+ throw new Error(`manifest name mismatch: expected '${exports.SKILL_NAME}', got '${manifest.name}'. CLI and bundle are out of sync.`);
647
+ }
648
+ // Already-at-version short-circuit: read the on-disk SKILL.md
649
+ // frontmatter version and compare to manifest. Equal -> no-op.
650
+ const installedSkillMdPath = path.join(target, "SKILL.md");
651
+ const installedBody = await fs.readFile(installedSkillMdPath, "utf8").catch(() => null);
652
+ if (installedBody) {
653
+ const fmInstalled = parseFrontmatter(installedBody);
654
+ if (fmInstalled.version && fmInstalled.version === manifest.version) {
655
+ process.stdout.write(`${exports.SKILL_NAME} already at version ${manifest.version} (no-op)\n`);
656
+ return 0;
657
+ }
658
+ }
659
+ // Otherwise proceed with overwriting install (the existing path).
660
+ return install({ ...opts, overwrite: true });
661
+ });
662
+ }
663
+ async function list(opts) {
664
+ return withFatalReturn(async () => {
665
+ const root = await resolve(opts.target ?? defaultSkillsRoot());
666
+ const exists = await fs.stat(root).then(() => true).catch(() => false);
667
+ if (!exists) {
668
+ process.stdout.write("(no skills installed)\n");
669
+ return 0;
670
+ }
671
+ const entries = await fs.readdir(root, { withFileTypes: true });
672
+ const rows = [];
673
+ for (const e of entries) {
674
+ if (!e.isDirectory())
675
+ continue;
676
+ const skillMdPath = path.join(root, e.name, "SKILL.md");
677
+ try {
678
+ const md = await fs.readFile(skillMdPath, "utf8");
679
+ const fm = parseFrontmatter(md);
680
+ rows.push(`${fm.name ?? e.name}\t${fm.version ?? "?"}`);
681
+ }
682
+ catch {
683
+ // not a skill folder; skip silently
684
+ }
685
+ }
686
+ process.stdout.write(rows.length ? rows.join("\n") + "\n" : "(no skills installed)\n");
687
+ return 0;
688
+ });
689
+ }
690
+ return { install, uninstall, update, list };
691
+ }
692
+ /**
693
+ * Supply-chain integrity gate for a single bundle file, applied AFTER the
694
+ * body is fetched and BEFORE it is written to disk.
695
+ *
696
+ * - manifest entry HAS `sha256` → verify; mismatch throws and aborts install.
697
+ * - manifest entry has NO `sha256` (back-compat) → print one-line `warn:` and
698
+ * proceed, so older manifests keep installing.
699
+ *
700
+ * Lives in _shared.ts so every adapter built on makeFolderInstallAdapter (and
701
+ * future adapters reusing these helpers) inherits the check for free.
702
+ */
703
+ function verifyBundleFile(file, body) {
704
+ if (file.sha256) {
705
+ verifyFileHash(body, file.sha256);
706
+ }
707
+ else {
708
+ process.stderr.write(`warn: manifest entry ${file.dest} has no sha256; skipping integrity check\n`);
709
+ }
710
+ }