@hanv89/azure-arch-skill 0.6.0 → 0.8.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,137 @@
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 (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.silenceStderr = exports.rmTmpdir = exports.mkTmpdir = exports.failOnNthHeadFetchMock = exports.installFetchMock = exports.SYNTHETIC_MANIFEST = exports.SYNTHETIC_EXAMPLE = exports.SYNTHETIC_SKILL_MD = exports.SYNTHETIC_ICONS_VERSION = exports.SYNTHETIC_REQUIRES_ICONS = exports.SYNTHETIC_VERSION = void 0;
27
+ const fs = __importStar(require("node:fs"));
28
+ const path = __importStar(require("node:path"));
29
+ const os = __importStar(require("node:os"));
30
+ // Synthetic skill bundle used by every test suite that mocks the network.
31
+ // Keeping all three suites (adapters-roundtrip, all, version) on the same
32
+ // fixture eliminates per-file drift (e.g. SKILL.md version: 0.5.0 vs 0.6.0
33
+ // landed in earlier test files because the fixture was copy-pasted).
34
+ exports.SYNTHETIC_VERSION = "0.5.0";
35
+ exports.SYNTHETIC_REQUIRES_ICONS = ">=0.2.2";
36
+ exports.SYNTHETIC_ICONS_VERSION = "0.2.2";
37
+ exports.SYNTHETIC_SKILL_MD = [
38
+ "---",
39
+ "name: azure-architecture-diagram",
40
+ "description: test fixture",
41
+ `version: ${exports.SYNTHETIC_VERSION}`,
42
+ `requires_icons: "${exports.SYNTHETIC_REQUIRES_ICONS}"`,
43
+ "---",
44
+ "# Test skill body",
45
+ "",
46
+ "This is a synthetic SKILL.md used only by the test fixtures.",
47
+ ].join("\n");
48
+ exports.SYNTHETIC_EXAMPLE = "@startuml\ntitle Test\n@enduml\n";
49
+ exports.SYNTHETIC_MANIFEST = {
50
+ $schema: "./manifest.schema.json",
51
+ name: "azure-architecture-diagram",
52
+ version: exports.SYNTHETIC_VERSION,
53
+ requires_icons: exports.SYNTHETIC_REQUIRES_ICONS,
54
+ icons_version: exports.SYNTHETIC_ICONS_VERSION,
55
+ files: [
56
+ { src: "dist/skill/SKILL.md", dest: "SKILL.md", role: "skill" },
57
+ { src: "dist/skill/examples/01-context.puml", dest: "examples/01-context.puml", role: "example" },
58
+ ],
59
+ };
60
+ const realFetch = globalThis.fetch;
61
+ /**
62
+ * Returns 200 for the manifest, SKILL.md, and the one bundled example;
63
+ * 200 for any HEAD; 404 otherwise.
64
+ */
65
+ function installFetchMock() {
66
+ globalThis.fetch = (async (url, init) => {
67
+ const u = url.toString();
68
+ const method = (init?.method ?? "GET").toUpperCase();
69
+ if (method === "HEAD") {
70
+ return new Response(null, { status: 200 });
71
+ }
72
+ if (u.endsWith("/dist/skill/manifest.json")) {
73
+ return new Response(JSON.stringify(exports.SYNTHETIC_MANIFEST), { status: 200, headers: { "Content-Type": "application/json" } });
74
+ }
75
+ if (u.endsWith("/dist/skill/SKILL.md")) {
76
+ return new Response(exports.SYNTHETIC_SKILL_MD, { status: 200 });
77
+ }
78
+ if (u.endsWith("/dist/skill/examples/01-context.puml")) {
79
+ return new Response(exports.SYNTHETIC_EXAMPLE, { status: 200 });
80
+ }
81
+ return new Response("not found", { status: 404 });
82
+ });
83
+ return { restore: () => { globalThis.fetch = realFetch; } };
84
+ }
85
+ exports.installFetchMock = installFetchMock;
86
+ /**
87
+ * Like `installFetchMock` but the Nth HEAD request returns 404. Used by
88
+ * the rollback test to fail the third adapter's canary while the first
89
+ * two have already installed.
90
+ */
91
+ function failOnNthHeadFetchMock(failOnHeadIndex) {
92
+ let headCount = 0;
93
+ globalThis.fetch = (async (url, init) => {
94
+ const u = url.toString();
95
+ const method = (init?.method ?? "GET").toUpperCase();
96
+ if (method === "HEAD") {
97
+ headCount++;
98
+ if (headCount === failOnHeadIndex) {
99
+ return new Response(null, { status: 404 });
100
+ }
101
+ return new Response(null, { status: 200 });
102
+ }
103
+ if (u.endsWith("/dist/skill/manifest.json")) {
104
+ return new Response(JSON.stringify(exports.SYNTHETIC_MANIFEST), { status: 200, headers: { "Content-Type": "application/json" } });
105
+ }
106
+ if (u.endsWith("/dist/skill/SKILL.md")) {
107
+ return new Response(exports.SYNTHETIC_SKILL_MD, { status: 200 });
108
+ }
109
+ if (u.endsWith("/dist/skill/examples/01-context.puml")) {
110
+ return new Response(exports.SYNTHETIC_EXAMPLE, { status: 200 });
111
+ }
112
+ return new Response("not found", { status: 404 });
113
+ });
114
+ return {
115
+ restore: () => { globalThis.fetch = realFetch; },
116
+ headCount: () => headCount,
117
+ };
118
+ }
119
+ exports.failOnNthHeadFetchMock = failOnNthHeadFetchMock;
120
+ function mkTmpdir() {
121
+ return fs.mkdtempSync(path.join(os.tmpdir(), "azure-arch-skill-test-"));
122
+ }
123
+ exports.mkTmpdir = mkTmpdir;
124
+ function rmTmpdir(dir) {
125
+ fs.rmSync(dir, { recursive: true, force: true });
126
+ }
127
+ exports.rmTmpdir = rmTmpdir;
128
+ // Silence stderr summary lines so the test reporter's output stays readable.
129
+ // IMPORTANT: do NOT silence stdout. Hijacking process.stdout.write inside a
130
+ // node:test test confuses the runner's buffered reporter — other tests' ✔
131
+ // lines get eaten by the capture buffer and silently drop from the count.
132
+ function silenceStderr() {
133
+ const orig = process.stderr.write.bind(process.stderr);
134
+ process.stderr.write = (_chunk) => true;
135
+ return { restore: () => { process.stderr.write = orig; } };
136
+ }
137
+ exports.silenceStderr = silenceStderr;
@@ -26,7 +26,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
26
26
  return (mod && mod.__esModule) ? mod : { "default": mod };
27
27
  };
28
28
  Object.defineProperty(exports, "__esModule", { value: true });
29
- exports.withFatalReturn = exports.stripFrontmatter = exports.parseFrontmatter = exports.fetchManifest = exports.headOk = exports.fetchText = exports.fetchWithTimeout = exports.safeResolveTarget = exports.baseUrl = exports.USER_AGENT = exports.FETCH_TIMEOUT_MS = exports.CANARY_ICON_PATH = exports.MANIFEST_PATH = exports.SKILL_NAME = exports.DEFAULT_BASE_RAW_URL = void 0;
29
+ exports.makeFolderInstallAdapter = exports.withFatalReturn = exports.verifyIconsAvailability = exports.satisfiesRequiresIcons = exports.stripFrontmatter = exports.parseFrontmatter = exports.fetchManifest = exports.headOk = exports.fetchText = exports.fetchWithTimeout = exports.safeResolveTarget = exports.baseUrl = exports.USER_AGENT = exports.FETCH_TIMEOUT_MS = exports.CANARY_ICON_PATH = exports.MANIFEST_PATH = exports.SKILL_NAME = exports.DEFAULT_BASE_RAW_URL = void 0;
30
30
  const fs = __importStar(require("node:fs/promises"));
31
31
  const path = __importStar(require("node:path"));
32
32
  const package_json_1 = __importDefault(require("../../package.json"));
@@ -35,6 +35,10 @@ const package_json_1 = __importDefault(require("../../package.json"));
35
35
  // adapter file that imports from here. Codex / Cursor / future adapters
36
36
  // re-use these helpers via the same import path.
37
37
  exports.DEFAULT_BASE_RAW_URL = "https://raw.githubusercontent.com/hanv89/azure-icons-for-architecture-diagrams/main";
38
+ // Same repo root without the ref segment; baseUrl() appends `main` (default)
39
+ // or `skill-vX.Y.Z` when --version is supplied.
40
+ const RAW_BASE_NO_REF = "https://raw.githubusercontent.com/hanv89/azure-icons-for-architecture-diagrams";
41
+ const VERSION_RE = /^\d+\.\d+\.\d+$/;
38
42
  // SKILL_NAME must stay in lockstep with dist/skill/SKILL.md frontmatter `name`.
39
43
  // Renaming the skill is a breaking change requiring a coordinated CLI release;
40
44
  // existing installs become un-uninstallable until users upgrade the CLI
@@ -48,28 +52,46 @@ exports.FETCH_TIMEOUT_MS = 30_000;
48
52
  exports.USER_AGENT = `azure-arch-skill/${package_json_1.default.version}`;
49
53
  const ALLOWED_BASE_URL_HOSTS = new Set(["raw.githubusercontent.com"]);
50
54
  const ALLOWED_BASE_URL_PATH_PREFIX = "/hanv89/azure-icons-for-architecture-diagrams/";
51
- function baseUrl() {
55
+ /**
56
+ * Resolve the base URL for fetching the skill bundle.
57
+ *
58
+ * - `version` undefined → `<RAW_BASE>/main` (default, tracks the upstream main branch).
59
+ * - `version="X.Y.Z"` → `<RAW_BASE>/skill-vX.Y.Z` (tag-pinned fetch).
60
+ * - `AZURE_ARCH_SKILL_BASE_URL` env set → env override wins; `version` is ignored
61
+ * (the env exists only for validation harnesses).
62
+ *
63
+ * `version` is validated against the strict X.Y.Z regex here as defense-in-depth;
64
+ * `src/index.ts` also rejects malformed values pre-dispatch.
65
+ */
66
+ function baseUrl(version) {
52
67
  const override = process.env.AZURE_ARCH_SKILL_BASE_URL;
53
- if (!override)
54
- return exports.DEFAULT_BASE_RAW_URL;
55
- let u;
56
- try {
57
- u = new URL(override);
58
- }
59
- catch {
60
- throw new Error(`AZURE_ARCH_SKILL_BASE_URL is not a valid URL: ${override}`);
61
- }
62
- if (u.protocol !== "https:") {
63
- throw new Error(`AZURE_ARCH_SKILL_BASE_URL must use https; got ${u.protocol}`);
64
- }
65
- if (!ALLOWED_BASE_URL_HOSTS.has(u.hostname)) {
66
- throw new Error(`AZURE_ARCH_SKILL_BASE_URL host '${u.hostname}' not in allow-list (${[...ALLOWED_BASE_URL_HOSTS].join(", ")})`);
68
+ if (override) {
69
+ let u;
70
+ try {
71
+ u = new URL(override);
72
+ }
73
+ catch {
74
+ throw new Error(`AZURE_ARCH_SKILL_BASE_URL is not a valid URL: ${override}`);
75
+ }
76
+ if (u.protocol !== "https:") {
77
+ throw new Error(`AZURE_ARCH_SKILL_BASE_URL must use https; got ${u.protocol}`);
78
+ }
79
+ if (!ALLOWED_BASE_URL_HOSTS.has(u.hostname)) {
80
+ throw new Error(`AZURE_ARCH_SKILL_BASE_URL host '${u.hostname}' not in allow-list (${[...ALLOWED_BASE_URL_HOSTS].join(", ")})`);
81
+ }
82
+ if (!u.pathname.startsWith(ALLOWED_BASE_URL_PATH_PREFIX)) {
83
+ throw new Error(`AZURE_ARCH_SKILL_BASE_URL path must start with ${ALLOWED_BASE_URL_PATH_PREFIX}`);
84
+ }
85
+ process.stderr.write(`warn: AZURE_ARCH_SKILL_BASE_URL override active: ${override}\n`);
86
+ return override.replace(/\/$/, "");
67
87
  }
68
- if (!u.pathname.startsWith(ALLOWED_BASE_URL_PATH_PREFIX)) {
69
- throw new Error(`AZURE_ARCH_SKILL_BASE_URL path must start with ${ALLOWED_BASE_URL_PATH_PREFIX}`);
88
+ if (version !== undefined) {
89
+ if (!VERSION_RE.test(version)) {
90
+ throw new Error(`--version must match X.Y.Z (got: ${version})`);
91
+ }
92
+ return `${RAW_BASE_NO_REF}/skill-v${version}`;
70
93
  }
71
- process.stderr.write(`warn: AZURE_ARCH_SKILL_BASE_URL override active: ${override}\n`);
72
- return override.replace(/\/$/, "");
94
+ return exports.DEFAULT_BASE_RAW_URL;
73
95
  }
74
96
  exports.baseUrl = baseUrl;
75
97
  let envTargetRootWarned = false;
@@ -206,6 +228,11 @@ async function fetchManifest(base) {
206
228
  throw new Error(`manifest ${url} missing required field: ${key}`);
207
229
  }
208
230
  }
231
+ if (m.icons_version !== undefined) {
232
+ if (typeof m.icons_version !== "string" || !/^\d+\.\d+\.\d+$/.test(m.icons_version)) {
233
+ throw new Error(`manifest ${url} icons_version malformed (must match X.Y.Z): ${String(m.icons_version)}`);
234
+ }
235
+ }
209
236
  if (!Array.isArray(m.files) || m.files.length === 0) {
210
237
  throw new Error(`manifest ${url} files[] missing or empty`);
211
238
  }
@@ -272,6 +299,105 @@ function stripFrontmatter(md) {
272
299
  return match ? text.slice(match[0].length) : text;
273
300
  }
274
301
  exports.stripFrontmatter = stripFrontmatter;
302
+ /**
303
+ * Test whether an icons-tag semver satisfies the SKILL.md's `requires_icons`
304
+ * constraint. Hand-rolled to keep the runtime dep tree minimal (commander +
305
+ * nothing else; pulling in `semver` would add transitive deps for a feature
306
+ * that today only needs `>=X.Y.Z` matching).
307
+ *
308
+ * Supported constraint forms:
309
+ * - "X.Y.Z" (exact match)
310
+ * - ">=X.Y.Z" (tag >= constraint)
311
+ * - "^X.Y.Z" (same major, tag >= constraint — npm caret semantics)
312
+ * - "~X.Y.Z" (same major.minor, tag.patch >= constraint.patch)
313
+ *
314
+ * Throws on any other input. The project's SKILL.md frontmatter only ships
315
+ * `>=X.Y.Z` today; the other 3 forms exist for future-proofing.
316
+ *
317
+ * Numeric encoding `maj * 1e6 + min * 1e3 + pat` rules out individual segments
318
+ * >= 1000 — if a future icons release ever bumps any segment to 4 digits the
319
+ * encoding silently collides (e.g. 1.0.1000 vs 1.1.0). We hard-fail on that
320
+ * input rather than mis-compare.
321
+ */
322
+ const SEMVER_SEGMENT_MAX = 999;
323
+ function satisfiesRequiresIcons(constraint, iconsSemver) {
324
+ const tagParts = iconsSemver.split(".").map(Number);
325
+ if (tagParts.length !== 3 || tagParts.some(n => isNaN(n))) {
326
+ throw new Error(`icons semver malformed: ${iconsSemver}`);
327
+ }
328
+ const [tagMaj, tagMin, tagPat] = tagParts;
329
+ if (tagMaj > SEMVER_SEGMENT_MAX || tagMin > SEMVER_SEGMENT_MAX || tagPat > SEMVER_SEGMENT_MAX) {
330
+ throw new Error(`icons semver segment exceeds matcher capacity (${SEMVER_SEGMENT_MAX}): ${iconsSemver}`);
331
+ }
332
+ const trimmed = constraint.trim().replace(/^["']|["']$/g, "");
333
+ const m = trimmed.match(/^(>=|\^|~|)(\d+)\.(\d+)\.(\d+)$/);
334
+ if (!m) {
335
+ throw new Error(`requires_icons constraint form not supported: ${constraint}`);
336
+ }
337
+ const [, op, majS, minS, patS] = m;
338
+ const maj = Number(majS);
339
+ const min = Number(minS);
340
+ const pat = Number(patS);
341
+ if (maj > SEMVER_SEGMENT_MAX || min > SEMVER_SEGMENT_MAX || pat > SEMVER_SEGMENT_MAX) {
342
+ throw new Error(`requires_icons segment exceeds matcher capacity (${SEMVER_SEGMENT_MAX}): ${constraint}`);
343
+ }
344
+ const tag = tagMaj * 1e6 + tagMin * 1e3 + tagPat;
345
+ const ref = maj * 1e6 + min * 1e3 + pat;
346
+ if (op === "")
347
+ return tag === ref;
348
+ if (op === ">=")
349
+ return tag >= ref;
350
+ if (op === "^")
351
+ return tagMaj === maj && tag >= ref;
352
+ if (op === "~")
353
+ return tagMaj === maj && tagMin === min && tagPat >= pat;
354
+ throw new Error(`unreachable constraint op: ${op}`);
355
+ }
356
+ exports.satisfiesRequiresIcons = satisfiesRequiresIcons;
357
+ /**
358
+ * Verify the icon set the skill bundle references is reachable AND its
359
+ * semver satisfies SKILL.md's requires_icons.
360
+ *
361
+ * Source of the icons-tag, in priority order:
362
+ * 1. `manifest.icons_version` — exact tag (preferred).
363
+ * 2. Lower-bound parse of `manifest.requires_icons` — fallback for
364
+ * bundles published before the field landed (cannot be edited
365
+ * retroactively on a tag).
366
+ *
367
+ * The fallback path makes the gate trivially pass by construction (a
368
+ * lower bound always satisfies its own constraint), preserving install
369
+ * behaviour for older tags. New bundles ship the field, so the gate
370
+ * becomes a real cross-track compatibility check going forward.
371
+ */
372
+ async function verifyIconsAvailability(base, manifest, requestedVersion) {
373
+ const requires = manifest.requires_icons;
374
+ const canaryUrl = `${base}/${exports.CANARY_ICON_PATH}`;
375
+ const reachable = await headOk(canaryUrl);
376
+ if (!reachable) {
377
+ throw new Error(`icon-set unreachable - HEAD ${canaryUrl} failed (skill declares requires_icons=${requires})`);
378
+ }
379
+ if (!requestedVersion) {
380
+ return;
381
+ }
382
+ let iconsTagSemver;
383
+ let source;
384
+ if (manifest.icons_version) {
385
+ iconsTagSemver = manifest.icons_version;
386
+ source = `manifest icons_version`;
387
+ }
388
+ else {
389
+ const lowerMatch = requires.match(/(\d+\.\d+\.\d+)/);
390
+ if (!lowerMatch) {
391
+ throw new Error(`SKILL.md requires_icons has no parseable lower bound: ${requires}`);
392
+ }
393
+ iconsTagSemver = lowerMatch[1];
394
+ source = `requires_icons lower-bound (bundle has no icons_version field)`;
395
+ }
396
+ if (!satisfiesRequiresIcons(requires, iconsTagSemver)) {
397
+ throw new Error(`requires_icons constraint ${requires} not satisfied by ${source} ${iconsTagSemver}`);
398
+ }
399
+ }
400
+ exports.verifyIconsAvailability = verifyIconsAvailability;
275
401
  async function withFatalReturn(fn) {
276
402
  try {
277
403
  return await fn();
@@ -282,3 +408,117 @@ async function withFatalReturn(fn) {
282
408
  }
283
409
  }
284
410
  exports.withFatalReturn = withFatalReturn;
411
+ function makeFolderInstallAdapter(cfg) {
412
+ const defaultTarget = () => path.join(cfg.rootDir(), "skills", exports.SKILL_NAME);
413
+ const defaultSkillsRoot = () => path.join(cfg.rootDir(), "skills");
414
+ const resolve = (target) => safeResolveTarget(target, cfg.rootDir(), cfg.rootDisplay());
415
+ const isOurSkillDir = async (dir) => {
416
+ try {
417
+ const skillMd = await fs.readFile(path.join(dir, "SKILL.md"), "utf8");
418
+ return parseFrontmatter(skillMd).name === exports.SKILL_NAME;
419
+ }
420
+ catch {
421
+ return false;
422
+ }
423
+ };
424
+ async function install(opts) {
425
+ return withFatalReturn(async () => {
426
+ const target = await resolve(opts.target ?? defaultTarget());
427
+ const base = baseUrl(opts.version);
428
+ if (!process.env.AZURE_ARCH_SKILL_TARGET_ROOT && path.basename(target) !== exports.SKILL_NAME) {
429
+ throw new Error(`refusing to install at ${target} - target basename must be '${exports.SKILL_NAME}' (default ${cfg.rootDisplay()}/skills/${exports.SKILL_NAME}/). Set AZURE_ARCH_SKILL_TARGET_ROOT to install into a custom test root.`);
430
+ }
431
+ const manifest = await fetchManifest(base);
432
+ if (manifest.name !== exports.SKILL_NAME) {
433
+ throw new Error(`manifest name mismatch: expected '${exports.SKILL_NAME}', got '${manifest.name}'. CLI and bundle are out of sync.`);
434
+ }
435
+ const presence = await Promise.all(manifest.files.map(async ({ dest }) => ({
436
+ dest,
437
+ exists: await fs.stat(path.join(target, dest)).then(() => true).catch(() => false),
438
+ })));
439
+ const someExist = presence.some(p => p.exists);
440
+ const allExist = presence.every(p => p.exists);
441
+ if (someExist && !opts.overwrite) {
442
+ throw new Error(allExist
443
+ ? `${target} already contains an install. Run 'azure-arch-skill update --agent=${cfg.agentFlag}' to refresh.`
444
+ : `${target} contains a partial install (${presence.filter(p => !p.exists).map(p => p.dest).join(", ")} missing). Run 'azure-arch-skill update --agent=${cfg.agentFlag}' to repair.`);
445
+ }
446
+ const skillUrl = `${base}/${manifest.files[0].src}`;
447
+ const skillMd = await fetchText(skillUrl);
448
+ const fm = parseFrontmatter(skillMd);
449
+ if (!fm.requires_icons) {
450
+ throw new Error("SKILL.md missing requires_icons frontmatter");
451
+ }
452
+ await verifyIconsAvailability(base, manifest, opts.version);
453
+ for (const { dest } of manifest.files) {
454
+ await fs.mkdir(path.dirname(path.join(target, dest)), { recursive: true });
455
+ }
456
+ await fs.writeFile(path.join(target, manifest.files[0].dest), skillMd, "utf8");
457
+ for (const { src, dest } of manifest.files.slice(1)) {
458
+ const body = await fetchText(`${base}/${src}`);
459
+ await fs.writeFile(path.join(target, dest), body, "utf8");
460
+ }
461
+ process.stdout.write(`installed ${exports.SKILL_NAME} to ${target}\n`);
462
+ return 0;
463
+ });
464
+ }
465
+ async function uninstall(opts) {
466
+ return withFatalReturn(async () => {
467
+ const target = await resolve(opts.target ?? defaultTarget());
468
+ const exists = await fs.stat(target).then(() => true).catch(() => false);
469
+ if (!exists) {
470
+ process.stdout.write(`(nothing to uninstall at ${target})\n`);
471
+ return 0;
472
+ }
473
+ const ours = await isOurSkillDir(target);
474
+ if (!ours) {
475
+ throw new Error(`refusing to remove ${target} - not an azure-architecture-diagram skill folder (no matching SKILL.md). Move/rename the directory or remove it manually if intentional.`);
476
+ }
477
+ try {
478
+ await fs.rm(target, { recursive: true, force: false });
479
+ }
480
+ catch (err) {
481
+ const stillExists = await fs.stat(target).then(() => true).catch(() => false);
482
+ if (stillExists) {
483
+ const msg = err instanceof Error ? err.message : String(err);
484
+ throw new Error(`uninstall partially failed at ${target}: ${msg}; manual cleanup may be required`);
485
+ }
486
+ throw err;
487
+ }
488
+ process.stdout.write(`uninstalled ${exports.SKILL_NAME} from ${target}\n`);
489
+ return 0;
490
+ });
491
+ }
492
+ async function update(opts) {
493
+ return install({ ...opts, overwrite: true });
494
+ }
495
+ async function list(opts) {
496
+ return withFatalReturn(async () => {
497
+ const root = await resolve(opts.target ?? defaultSkillsRoot());
498
+ const exists = await fs.stat(root).then(() => true).catch(() => false);
499
+ if (!exists) {
500
+ process.stdout.write("(no skills installed)\n");
501
+ return 0;
502
+ }
503
+ const entries = await fs.readdir(root, { withFileTypes: true });
504
+ const rows = [];
505
+ for (const e of entries) {
506
+ if (!e.isDirectory())
507
+ continue;
508
+ const skillMdPath = path.join(root, e.name, "SKILL.md");
509
+ try {
510
+ const md = await fs.readFile(skillMdPath, "utf8");
511
+ const fm = parseFrontmatter(md);
512
+ rows.push(`${fm.name ?? e.name}\t${fm.version ?? "?"}`);
513
+ }
514
+ catch {
515
+ // not a skill folder; skip silently
516
+ }
517
+ }
518
+ process.stdout.write(rows.length ? rows.join("\n") + "\n" : "(no skills installed)\n");
519
+ return 0;
520
+ });
521
+ }
522
+ return { install, uninstall, update, list };
523
+ }
524
+ exports.makeFolderInstallAdapter = makeFolderInstallAdapter;
@@ -24,7 +24,6 @@ var __importStar = (this && this.__importStar) || function (mod) {
24
24
  };
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
26
  exports.claudeCodeAdapter = exports.parseFrontmatter = exports.fetchWithTimeout = void 0;
27
- const fs = __importStar(require("node:fs/promises"));
28
27
  const path = __importStar(require("node:path"));
29
28
  const os = __importStar(require("node:os"));
30
29
  const _shared_1 = require("./_shared");
@@ -33,131 +32,8 @@ Object.defineProperty(exports, "parseFrontmatter", { enumerable: true, get: func
33
32
  function claudeRootDir() {
34
33
  return path.join(os.homedir(), ".claude");
35
34
  }
36
- function defaultTarget() {
37
- return path.join(claudeRootDir(), "skills", _shared_1.SKILL_NAME);
38
- }
39
- function defaultSkillsRoot() {
40
- return path.join(claudeRootDir(), "skills");
41
- }
42
- const CLAUDE_ROOT_DISPLAY = "~/.claude";
43
- async function resolveTarget(target) {
44
- return (0, _shared_1.safeResolveTarget)(target, claudeRootDir(), CLAUDE_ROOT_DISPLAY);
45
- }
46
- /**
47
- * Returns true iff `dir` contains a SKILL.md whose frontmatter `name` field
48
- * matches our skill. Used by uninstall to refuse deleting paths that aren't
49
- * our skill folder.
50
- */
51
- async function isOurSkillDir(dir) {
52
- try {
53
- const skillMd = await fs.readFile(path.join(dir, "SKILL.md"), "utf8");
54
- const fm = (0, _shared_1.parseFrontmatter)(skillMd);
55
- return fm.name === _shared_1.SKILL_NAME;
56
- }
57
- catch {
58
- return false;
59
- }
60
- }
61
- async function install(opts) {
62
- return (0, _shared_1.withFatalReturn)(async () => {
63
- const target = await resolveTarget(opts.target ?? defaultTarget());
64
- const base = (0, _shared_1.baseUrl)();
65
- if (!process.env.AZURE_ARCH_SKILL_TARGET_ROOT && path.basename(target) !== _shared_1.SKILL_NAME) {
66
- throw new Error(`refusing to install at ${target} - target basename must be '${_shared_1.SKILL_NAME}' (default ~/.claude/skills/${_shared_1.SKILL_NAME}/). Set AZURE_ARCH_SKILL_TARGET_ROOT to install into a custom test root.`);
67
- }
68
- const manifest = await (0, _shared_1.fetchManifest)(base);
69
- if (manifest.name !== _shared_1.SKILL_NAME) {
70
- throw new Error(`manifest name mismatch: expected '${_shared_1.SKILL_NAME}', got '${manifest.name}'. CLI and bundle are out of sync.`);
71
- }
72
- const presence = await Promise.all(manifest.files.map(async ({ dest }) => ({
73
- dest,
74
- exists: await fs.stat(path.join(target, dest)).then(() => true).catch(() => false),
75
- })));
76
- const someExist = presence.some(p => p.exists);
77
- const allExist = presence.every(p => p.exists);
78
- if (someExist && !opts.overwrite) {
79
- throw new Error(allExist
80
- ? `${target} already contains an install. Run 'azure-arch-skill update --agent=claude-code' to refresh.`
81
- : `${target} contains a partial install (${presence.filter(p => !p.exists).map(p => p.dest).join(", ")} missing). Run 'azure-arch-skill update --agent=claude-code' to repair.`);
82
- }
83
- const skillUrl = `${base}/${manifest.files[0].src}`;
84
- const skillMd = await (0, _shared_1.fetchText)(skillUrl);
85
- const fm = (0, _shared_1.parseFrontmatter)(skillMd);
86
- if (!fm.requires_icons) {
87
- throw new Error("SKILL.md missing requires_icons frontmatter");
88
- }
89
- const canaryUrl = `${base}/${_shared_1.CANARY_ICON_PATH}`;
90
- const reachable = await (0, _shared_1.headOk)(canaryUrl);
91
- if (!reachable) {
92
- throw new Error(`icon-set unreachable - HEAD ${canaryUrl} failed (skill declares requires_icons=${fm.requires_icons}; this release verifies reachability only, strict semver match planned)`);
93
- }
94
- for (const { dest } of manifest.files) {
95
- await fs.mkdir(path.dirname(path.join(target, dest)), { recursive: true });
96
- }
97
- await fs.writeFile(path.join(target, manifest.files[0].dest), skillMd, "utf8");
98
- for (const { src, dest } of manifest.files.slice(1)) {
99
- const body = await (0, _shared_1.fetchText)(`${base}/${src}`);
100
- await fs.writeFile(path.join(target, dest), body, "utf8");
101
- }
102
- process.stdout.write(`installed ${_shared_1.SKILL_NAME} to ${target}\n`);
103
- return 0;
104
- });
105
- }
106
- async function uninstall(opts) {
107
- return (0, _shared_1.withFatalReturn)(async () => {
108
- const target = await resolveTarget(opts.target ?? defaultTarget());
109
- const exists = await fs.stat(target).then(() => true).catch(() => false);
110
- if (!exists) {
111
- process.stdout.write(`(nothing to uninstall at ${target})\n`);
112
- return 0;
113
- }
114
- const ours = await isOurSkillDir(target);
115
- if (!ours) {
116
- throw new Error(`refusing to remove ${target} - not an azure-architecture-diagram skill folder (no matching SKILL.md). Move/rename the directory or remove it manually if intentional.`);
117
- }
118
- try {
119
- await fs.rm(target, { recursive: true, force: false });
120
- }
121
- catch (err) {
122
- const stillExists = await fs.stat(target).then(() => true).catch(() => false);
123
- if (stillExists) {
124
- const msg = err instanceof Error ? err.message : String(err);
125
- throw new Error(`uninstall partially failed at ${target}: ${msg}; manual cleanup may be required`);
126
- }
127
- throw err;
128
- }
129
- process.stdout.write(`uninstalled ${_shared_1.SKILL_NAME} from ${target}\n`);
130
- return 0;
131
- });
132
- }
133
- async function update(opts) {
134
- return install({ ...opts, overwrite: true });
135
- }
136
- async function list(opts) {
137
- return (0, _shared_1.withFatalReturn)(async () => {
138
- const root = await resolveTarget(opts.target ?? defaultSkillsRoot());
139
- const exists = await fs.stat(root).then(() => true).catch(() => false);
140
- if (!exists) {
141
- process.stdout.write("(no skills installed)\n");
142
- return 0;
143
- }
144
- const entries = await fs.readdir(root, { withFileTypes: true });
145
- const rows = [];
146
- for (const e of entries) {
147
- if (!e.isDirectory())
148
- continue;
149
- const skillMdPath = path.join(root, e.name, "SKILL.md");
150
- try {
151
- const md = await fs.readFile(skillMdPath, "utf8");
152
- const fm = (0, _shared_1.parseFrontmatter)(md);
153
- rows.push(`${fm.name ?? e.name}\t${fm.version ?? "?"}`);
154
- }
155
- catch {
156
- // not a skill folder; skip silently
157
- }
158
- }
159
- process.stdout.write(rows.length ? rows.join("\n") + "\n" : "(no skills installed)\n");
160
- return 0;
161
- });
162
- }
163
- exports.claudeCodeAdapter = { install, uninstall, update, list };
35
+ exports.claudeCodeAdapter = (0, _shared_1.makeFolderInstallAdapter)({
36
+ rootDir: claudeRootDir,
37
+ rootDisplay: () => "~/.claude",
38
+ agentFlag: "claude-code",
39
+ });
@@ -24,7 +24,6 @@ var __importStar = (this && this.__importStar) || function (mod) {
24
24
  };
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
26
  exports.codexAdapter = exports.parseFrontmatter = exports.fetchWithTimeout = void 0;
27
- const fs = __importStar(require("node:fs/promises"));
28
27
  const path = __importStar(require("node:path"));
29
28
  const os = __importStar(require("node:os"));
30
29
  const _shared_1 = require("./_shared");
@@ -36,128 +35,11 @@ function codexRootDir() {
36
35
  return path.resolve(explicit);
37
36
  return path.join(os.homedir(), ".codex");
38
37
  }
39
- function defaultTarget() {
40
- return path.join(codexRootDir(), "skills", _shared_1.SKILL_NAME);
41
- }
42
- function defaultSkillsRoot() {
43
- return path.join(codexRootDir(), "skills");
44
- }
45
38
  function codexRootDisplay() {
46
39
  return process.env.CODEX_HOME ? `$CODEX_HOME=${process.env.CODEX_HOME}` : "~/.codex";
47
40
  }
48
- async function resolveTarget(target) {
49
- return (0, _shared_1.safeResolveTarget)(target, codexRootDir(), codexRootDisplay());
50
- }
51
- async function isOurSkillDir(dir) {
52
- try {
53
- const skillMd = await fs.readFile(path.join(dir, "SKILL.md"), "utf8");
54
- const fm = (0, _shared_1.parseFrontmatter)(skillMd);
55
- return fm.name === _shared_1.SKILL_NAME;
56
- }
57
- catch {
58
- return false;
59
- }
60
- }
61
- async function install(opts) {
62
- return (0, _shared_1.withFatalReturn)(async () => {
63
- const target = await resolveTarget(opts.target ?? defaultTarget());
64
- const base = (0, _shared_1.baseUrl)();
65
- if (!process.env.AZURE_ARCH_SKILL_TARGET_ROOT && path.basename(target) !== _shared_1.SKILL_NAME) {
66
- throw new Error(`refusing to install at ${target} - target basename must be '${_shared_1.SKILL_NAME}' (default ${codexRootDisplay()}/skills/${_shared_1.SKILL_NAME}/). Set AZURE_ARCH_SKILL_TARGET_ROOT to install into a custom test root.`);
67
- }
68
- const manifest = await (0, _shared_1.fetchManifest)(base);
69
- if (manifest.name !== _shared_1.SKILL_NAME) {
70
- throw new Error(`manifest name mismatch: expected '${_shared_1.SKILL_NAME}', got '${manifest.name}'. CLI and bundle are out of sync.`);
71
- }
72
- const presence = await Promise.all(manifest.files.map(async ({ dest }) => ({
73
- dest,
74
- exists: await fs.stat(path.join(target, dest)).then(() => true).catch(() => false),
75
- })));
76
- const someExist = presence.some(p => p.exists);
77
- const allExist = presence.every(p => p.exists);
78
- if (someExist && !opts.overwrite) {
79
- throw new Error(allExist
80
- ? `${target} already contains an install. Run 'azure-arch-skill update --agent=codex' to refresh.`
81
- : `${target} contains a partial install (${presence.filter(p => !p.exists).map(p => p.dest).join(", ")} missing). Run 'azure-arch-skill update --agent=codex' to repair.`);
82
- }
83
- const skillUrl = `${base}/${manifest.files[0].src}`;
84
- const skillMd = await (0, _shared_1.fetchText)(skillUrl);
85
- const fm = (0, _shared_1.parseFrontmatter)(skillMd);
86
- if (!fm.requires_icons) {
87
- throw new Error("SKILL.md missing requires_icons frontmatter");
88
- }
89
- const canaryUrl = `${base}/${_shared_1.CANARY_ICON_PATH}`;
90
- const reachable = await (0, _shared_1.headOk)(canaryUrl);
91
- if (!reachable) {
92
- throw new Error(`icon-set unreachable - HEAD ${canaryUrl} failed (skill declares requires_icons=${fm.requires_icons}; this release verifies reachability only, strict semver match planned)`);
93
- }
94
- for (const { dest } of manifest.files) {
95
- await fs.mkdir(path.dirname(path.join(target, dest)), { recursive: true });
96
- }
97
- await fs.writeFile(path.join(target, manifest.files[0].dest), skillMd, "utf8");
98
- for (const { src, dest } of manifest.files.slice(1)) {
99
- const body = await (0, _shared_1.fetchText)(`${base}/${src}`);
100
- await fs.writeFile(path.join(target, dest), body, "utf8");
101
- }
102
- process.stdout.write(`installed ${_shared_1.SKILL_NAME} to ${target}\n`);
103
- return 0;
104
- });
105
- }
106
- async function uninstall(opts) {
107
- return (0, _shared_1.withFatalReturn)(async () => {
108
- const target = await resolveTarget(opts.target ?? defaultTarget());
109
- const exists = await fs.stat(target).then(() => true).catch(() => false);
110
- if (!exists) {
111
- process.stdout.write(`(nothing to uninstall at ${target})\n`);
112
- return 0;
113
- }
114
- const ours = await isOurSkillDir(target);
115
- if (!ours) {
116
- throw new Error(`refusing to remove ${target} - not an azure-architecture-diagram skill folder (no matching SKILL.md). Move/rename the directory or remove it manually if intentional.`);
117
- }
118
- try {
119
- await fs.rm(target, { recursive: true, force: false });
120
- }
121
- catch (err) {
122
- const stillExists = await fs.stat(target).then(() => true).catch(() => false);
123
- if (stillExists) {
124
- const msg = err instanceof Error ? err.message : String(err);
125
- throw new Error(`uninstall partially failed at ${target}: ${msg}; manual cleanup may be required`);
126
- }
127
- throw err;
128
- }
129
- process.stdout.write(`uninstalled ${_shared_1.SKILL_NAME} from ${target}\n`);
130
- return 0;
131
- });
132
- }
133
- async function update(opts) {
134
- return install({ ...opts, overwrite: true });
135
- }
136
- async function list(opts) {
137
- return (0, _shared_1.withFatalReturn)(async () => {
138
- const root = await resolveTarget(opts.target ?? defaultSkillsRoot());
139
- const exists = await fs.stat(root).then(() => true).catch(() => false);
140
- if (!exists) {
141
- process.stdout.write("(no skills installed)\n");
142
- return 0;
143
- }
144
- const entries = await fs.readdir(root, { withFileTypes: true });
145
- const rows = [];
146
- for (const e of entries) {
147
- if (!e.isDirectory())
148
- continue;
149
- const skillMdPath = path.join(root, e.name, "SKILL.md");
150
- try {
151
- const md = await fs.readFile(skillMdPath, "utf8");
152
- const fm = (0, _shared_1.parseFrontmatter)(md);
153
- rows.push(`${fm.name ?? e.name}\t${fm.version ?? "?"}`);
154
- }
155
- catch {
156
- // not a skill folder; skip silently
157
- }
158
- }
159
- process.stdout.write(rows.length ? rows.join("\n") + "\n" : "(no skills installed)\n");
160
- return 0;
161
- });
162
- }
163
- exports.codexAdapter = { install, uninstall, update, list };
41
+ exports.codexAdapter = (0, _shared_1.makeFolderInstallAdapter)({
42
+ rootDir: codexRootDir,
43
+ rootDisplay: codexRootDisplay,
44
+ agentFlag: "codex",
45
+ });
@@ -23,7 +23,7 @@ var __importStar = (this && this.__importStar) || function (mod) {
23
23
  return result;
24
24
  };
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
- exports.cursorAdapter = exports.parseFrontmatter = exports.fetchWithTimeout = void 0;
26
+ exports.cursorAdapter = exports.PROVENANCE_RE = exports.provenanceMarker = exports.parseFrontmatter = exports.fetchWithTimeout = void 0;
27
27
  const fs = __importStar(require("node:fs/promises"));
28
28
  const path = __importStar(require("node:path"));
29
29
  const _shared_1 = require("./_shared");
@@ -33,6 +33,11 @@ const RULE_BASENAME = "azure-arch-skill.mdc";
33
33
  const RULE_DESCRIPTION = "Use this rule when drawing Microsoft Azure or Microsoft Fabric architecture diagrams using PlantUML. " +
34
34
  "Triggers on \"draw Azure architecture\", \"create deployment diagram\", \"Lakehouse + Notebook + Warehouse diagram\", " +
35
35
  "\"vẽ Azure\", \"PlantUML diagram for [project]\".";
36
+ // Cursor's rule discovery is per-project: <cwd>/.cursor/rules/*.mdc is the
37
+ // canonical install location, so the team picks up the rule via the project's
38
+ // git repo. A HOME-relative install would only help the local developer. The
39
+ // trade-off — running install from an unintended cwd — is mitigated by a
40
+ // runtime warn-line (see `install` below) and the `--target=<path>` override.
36
41
  function defaultTarget() {
37
42
  return path.join(process.cwd(), ".cursor", "rules");
38
43
  }
@@ -40,15 +45,15 @@ const CWD_DISPLAY = "<cwd>/.cursor/rules";
40
45
  async function resolveTarget(target) {
41
46
  return (0, _shared_1.safeResolveTarget)(target, process.cwd(), CWD_DISPLAY);
42
47
  }
43
- /**
44
- * Provenance marker emitted into the rendered .mdc body so `list` / `uninstall`
45
- * can recognise our rule without re-fetching upstream. The marker is the first
46
- * non-frontmatter line after the closing `---`.
47
- */
48
+ // `provenanceMarker` and `PROVENANCE_RE` are paired: the regex MUST parse what
49
+ // the marker writes. Keep them in lockstep a unit test in version.test.ts
50
+ // asserts the round-trip.
48
51
  function provenanceMarker(version, requiresIcons) {
49
52
  return `<!-- ${_shared_1.SKILL_NAME} v${version} (requires_icons: ${requiresIcons}) -->`;
50
53
  }
54
+ exports.provenanceMarker = provenanceMarker;
51
55
  const PROVENANCE_RE = new RegExp(`<!--\\s*${_shared_1.SKILL_NAME}\\s+v([^\\s]+)\\s+\\(requires_icons:\\s*([^)]+)\\)\\s*-->`);
56
+ exports.PROVENANCE_RE = PROVENANCE_RE;
52
57
  function renderRule(skillBody, version, requiresIcons) {
53
58
  return [
54
59
  "---",
@@ -75,8 +80,11 @@ async function isOurRuleFile(file) {
75
80
  }
76
81
  async function install(opts) {
77
82
  return (0, _shared_1.withFatalReturn)(async () => {
83
+ if (opts.target === undefined) {
84
+ process.stderr.write(`note: Cursor target resolved to ${defaultTarget()} (per-project install). Pass --target=<path> to override.\n`);
85
+ }
78
86
  const targetDir = await resolveTarget(opts.target ?? defaultTarget());
79
- const base = (0, _shared_1.baseUrl)();
87
+ const base = (0, _shared_1.baseUrl)(opts.version);
80
88
  const ruleFile = path.join(targetDir, RULE_BASENAME);
81
89
  // Fetch manifest first so we know which file is the canonical SKILL.md
82
90
  // and what version / requires_icons to embed in the provenance marker.
@@ -90,11 +98,7 @@ async function install(opts) {
90
98
  if (!fm.requires_icons) {
91
99
  throw new Error("SKILL.md missing requires_icons frontmatter");
92
100
  }
93
- const canaryUrl = `${base}/${_shared_1.CANARY_ICON_PATH}`;
94
- const reachable = await (0, _shared_1.headOk)(canaryUrl);
95
- if (!reachable) {
96
- throw new Error(`icon-set unreachable - HEAD ${canaryUrl} failed (skill declares requires_icons=${fm.requires_icons}; this release verifies reachability only, strict semver match planned)`);
97
- }
101
+ await (0, _shared_1.verifyIconsAvailability)(base, manifest, opts.version);
98
102
  const exists = await fs.stat(ruleFile).then(() => true).catch(() => false);
99
103
  if (exists && !opts.overwrite) {
100
104
  const probe = await isOurRuleFile(ruleFile);
package/dist/all.js CHANGED
@@ -50,6 +50,7 @@ async function runOverAll(sub, opts) {
50
50
  exports.runOverAll = runOverAll;
51
51
  async function runOne(adapter, agent, sub, opts) {
52
52
  try {
53
+ // All four Adapter methods now accept the same AdapterOpts shape (see types.ts).
53
54
  const exit = await adapter[sub](opts);
54
55
  return { agent, exit };
55
56
  }
package/dist/index.js CHANGED
@@ -8,42 +8,44 @@ const commander_1 = require("commander");
8
8
  const package_json_1 = __importDefault(require("../package.json"));
9
9
  const registry_1 = require("./adapters/registry");
10
10
  const all_1 = require("./all");
11
- function pickAdapter(agent) {
12
- if (!(agent in registry_1.ADAPTERS)) {
13
- throw new Error(`unknown agent: ${agent} (supported: ${registry_1.SUPPORTED_TARGETS.join(", ")})`);
14
- }
15
- return registry_1.ADAPTERS[agent];
16
- }
17
11
  const program = new commander_1.Command()
18
12
  .name("azure-arch-skill")
19
13
  .description("Install the Azure architecture diagram skill into your AI coding agent.")
20
14
  .version(package_json_1.default.version, "-V, --version");
15
+ const VERSION_RE = /^\d+\.\d+\.\d+$/;
21
16
  function defineSubcommand(name, description) {
22
17
  program
23
18
  .command(name)
24
19
  .description(description)
25
20
  .requiredOption("--agent <name>", `target AI agent (${registry_1.SUPPORTED_TARGETS.join("|")})`)
26
21
  .option("--target <dir>", "override target directory (validation use)")
22
+ .option("--version <semver>", "pin to a specific skill version (X.Y.Z); default = latest from main")
27
23
  .action(async (opts) => {
28
- // Set process.exitCode so any pending async cleanup (file handles, the
29
- // override-warning stderr write) drains before the event loop empties.
30
- // Both this top-level path and adapter-internal failures emit a single
31
- // '^fatal: ' prefix line on stderr — log-parsers can rely on the prefix.
32
- const optsForAdapter = { target: opts.target };
24
+ // Validate --version pre-dispatch as defense-in-depth; baseUrl() in
25
+ // _shared.ts also rejects malformed values when it builds the URL.
26
+ if (opts.version !== undefined && !VERSION_RE.test(opts.version)) {
27
+ throw new Error(`--version must match X.Y.Z (got: ${opts.version})`);
28
+ }
29
+ const optsForAdapter = { target: opts.target, version: opts.version };
33
30
  if (opts.agent === registry_1.ALL_TARGET) {
34
31
  process.exitCode = await (0, all_1.runOverAll)(name, optsForAdapter);
35
32
  return;
36
33
  }
37
34
  if (!registry_1.SUPPORTED_AGENTS.includes(opts.agent)) {
38
- throw new Error(`unknown agent: ${opts.agent} (supported: ${registry_1.SUPPORTED_TARGETS.join(", ")})`);
35
+ throw new Error(`unknown agent: ${opts.agent} (supported: ${registry_1.SUPPORTED_AGENTS.join(", ")})`);
39
36
  }
40
- process.exitCode = await pickAdapter(opts.agent)[name](optsForAdapter);
37
+ process.exitCode = await registry_1.ADAPTERS[opts.agent][name](optsForAdapter);
41
38
  });
42
39
  }
43
40
  defineSubcommand("install", "Install the skill into an AI agent's skill folder.");
44
41
  defineSubcommand("uninstall", "Remove a previously installed skill.");
45
42
  defineSubcommand("update", "Update an installed skill to the latest version.");
46
43
  defineSubcommand("list", "List installed skills and their versions.");
44
+ // Top-level catch: setting process.exitCode (instead of process.exit(1)) lets
45
+ // any pending async cleanup (file handles, the override-warning stderr write)
46
+ // drain before the event loop empties. Both this path and adapter-internal
47
+ // failures emit a single '^fatal: ' prefix line on stderr — log-parsers can
48
+ // rely on the prefix.
47
49
  program.parseAsync(process.argv).catch(err => {
48
50
  process.stderr.write(`fatal: ${err instanceof Error ? err.message : String(err)}\n`);
49
51
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanv89/azure-arch-skill",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Install the Azure architecture diagram skill into your AI coding agent (Claude Code, Codex CLI, Cursor).",
5
5
  "bin": {
6
6
  "azure-arch-skill": "dist/index.js"