@hanv89/azure-arch-skill 0.7.0 → 0.9.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.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;
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"));
@@ -228,6 +228,11 @@ async function fetchManifest(base) {
228
228
  throw new Error(`manifest ${url} missing required field: ${key}`);
229
229
  }
230
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
+ }
231
236
  if (!Array.isArray(m.files) || m.files.length === 0) {
232
237
  throw new Error(`manifest ${url} files[] missing or empty`);
233
238
  }
@@ -310,14 +315,20 @@ exports.stripFrontmatter = stripFrontmatter;
310
315
  * `>=X.Y.Z` today; the other 3 forms exist for future-proofing.
311
316
  *
312
317
  * Numeric encoding `maj * 1e6 + min * 1e3 + pat` rules out individual segments
313
- * >= 1000, which is fine for icons semver in the foreseeable future.
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.
314
321
  */
322
+ const SEMVER_SEGMENT_MAX = 999;
315
323
  function satisfiesRequiresIcons(constraint, iconsSemver) {
316
324
  const tagParts = iconsSemver.split(".").map(Number);
317
325
  if (tagParts.length !== 3 || tagParts.some(n => isNaN(n))) {
318
326
  throw new Error(`icons semver malformed: ${iconsSemver}`);
319
327
  }
320
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
+ }
321
332
  const trimmed = constraint.trim().replace(/^["']|["']$/g, "");
322
333
  const m = trimmed.match(/^(>=|\^|~|)(\d+)\.(\d+)\.(\d+)$/);
323
334
  if (!m) {
@@ -327,6 +338,9 @@ function satisfiesRequiresIcons(constraint, iconsSemver) {
327
338
  const maj = Number(majS);
328
339
  const min = Number(minS);
329
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
+ }
330
344
  const tag = tagMaj * 1e6 + tagMin * 1e3 + tagPat;
331
345
  const ref = maj * 1e6 + min * 1e3 + pat;
332
346
  if (op === "")
@@ -344,32 +358,43 @@ exports.satisfiesRequiresIcons = satisfiesRequiresIcons;
344
358
  * Verify the icon set the skill bundle references is reachable AND its
345
359
  * semver satisfies SKILL.md's requires_icons.
346
360
  *
347
- * - Always: HEAD the canary icon URL (`CANARY_ICON_PATH` at the current base).
348
- * Unreachable throw with the URL in the message.
349
- * - If `requestedVersion` is provided: additionally infer the icons-tag from
350
- * `manifest.requires_icons`'s lower bound and assert it satisfies the
351
- * constraint via `satisfiesRequiresIcons`. The inference is intentionally
352
- * simple: `requires_icons` lower bound IS the icons-tag the skill was
353
- * built against. Future work: record an exact `icons_version` field in
354
- * `manifest.json` and read it here directly.
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.
355
371
  */
356
372
  async function verifyIconsAvailability(base, manifest, requestedVersion) {
357
373
  const requires = manifest.requires_icons;
358
374
  const canaryUrl = `${base}/${exports.CANARY_ICON_PATH}`;
359
375
  const reachable = await headOk(canaryUrl);
360
376
  if (!reachable) {
361
- throw new Error(`icon-set unreachable - HEAD ${canaryUrl} failed (skill declares requires_icons=${requires}; this release verifies reachability only, strict semver match planned)`);
377
+ throw new Error(`icon-set unreachable - HEAD ${canaryUrl} failed (skill declares requires_icons=${requires})`);
362
378
  }
363
379
  if (!requestedVersion) {
364
380
  return;
365
381
  }
366
- const lowerMatch = requires.match(/(\d+\.\d+\.\d+)/);
367
- if (!lowerMatch) {
368
- throw new Error(`SKILL.md requires_icons has no parseable lower bound: ${requires}`);
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)`;
369
395
  }
370
- const iconsTagSemver = lowerMatch[1];
371
396
  if (!satisfiesRequiresIcons(requires, iconsTagSemver)) {
372
- throw new Error(`requires_icons constraint ${requires} not satisfied by inferred icons tag ${iconsTagSemver}`);
397
+ throw new Error(`requires_icons constraint ${requires} not satisfied by ${source} ${iconsTagSemver}`);
373
398
  }
374
399
  }
375
400
  exports.verifyIconsAvailability = verifyIconsAvailability;
@@ -383,3 +408,190 @@ async function withFatalReturn(fn) {
383
408
  }
384
409
  }
385
410
  exports.withFatalReturn = withFatalReturn;
411
+ // Persisted at install time so uninstall can iterate the file list without
412
+ // re-fetching the manifest over the network. Hidden filename so it doesn't
413
+ // clutter the user-visible skill folder.
414
+ const PERSISTED_MANIFEST_BASENAME = ".azure-arch-skill-manifest.json";
415
+ function makeFolderInstallAdapter(cfg) {
416
+ const defaultTarget = () => path.join(cfg.rootDir(), "skills", exports.SKILL_NAME);
417
+ const defaultSkillsRoot = () => path.join(cfg.rootDir(), "skills");
418
+ const resolve = (target) => safeResolveTarget(target, cfg.rootDir(), cfg.rootDisplay());
419
+ const isOurSkillDir = async (dir) => {
420
+ try {
421
+ const skillMd = await fs.readFile(path.join(dir, "SKILL.md"), "utf8");
422
+ return parseFrontmatter(skillMd).name === exports.SKILL_NAME;
423
+ }
424
+ catch {
425
+ return false;
426
+ }
427
+ };
428
+ async function install(opts) {
429
+ return withFatalReturn(async () => {
430
+ const target = await resolve(opts.target ?? defaultTarget());
431
+ const base = baseUrl(opts.version);
432
+ if (!process.env.AZURE_ARCH_SKILL_TARGET_ROOT && path.basename(target) !== exports.SKILL_NAME) {
433
+ 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.`);
434
+ }
435
+ const manifest = await fetchManifest(base);
436
+ if (manifest.name !== exports.SKILL_NAME) {
437
+ throw new Error(`manifest name mismatch: expected '${exports.SKILL_NAME}', got '${manifest.name}'. CLI and bundle are out of sync.`);
438
+ }
439
+ const presence = await Promise.all(manifest.files.map(async ({ dest }) => ({
440
+ dest,
441
+ exists: await fs.stat(path.join(target, dest)).then(() => true).catch(() => false),
442
+ })));
443
+ const someExist = presence.some(p => p.exists);
444
+ const allExist = presence.every(p => p.exists);
445
+ if (someExist && !opts.overwrite) {
446
+ throw new Error(allExist
447
+ ? `${target} already contains an install. Run 'azure-arch-skill update --agent=${cfg.agentFlag}' to refresh.`
448
+ : `${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.`);
449
+ }
450
+ const skillUrl = `${base}/${manifest.files[0].src}`;
451
+ const skillMd = await fetchText(skillUrl);
452
+ const fm = parseFrontmatter(skillMd);
453
+ if (!fm.requires_icons) {
454
+ throw new Error("SKILL.md missing requires_icons frontmatter");
455
+ }
456
+ await verifyIconsAvailability(base, manifest, opts.version);
457
+ for (const { dest } of manifest.files) {
458
+ await fs.mkdir(path.dirname(path.join(target, dest)), { recursive: true });
459
+ }
460
+ await fs.writeFile(path.join(target, manifest.files[0].dest), skillMd, "utf8");
461
+ for (const { src, dest } of manifest.files.slice(1)) {
462
+ const body = await fetchText(`${base}/${src}`);
463
+ await fs.writeFile(path.join(target, dest), body, "utf8");
464
+ }
465
+ // Persist the manifest so uninstall can iterate the file list without
466
+ // re-fetching from the network.
467
+ await fs.writeFile(path.join(target, PERSISTED_MANIFEST_BASENAME), JSON.stringify(manifest, null, 2) + "\n", "utf8");
468
+ process.stdout.write(`installed ${exports.SKILL_NAME} to ${target}\n`);
469
+ return 0;
470
+ });
471
+ }
472
+ async function uninstall(opts) {
473
+ return withFatalReturn(async () => {
474
+ const target = await resolve(opts.target ?? defaultTarget());
475
+ const exists = await fs.stat(target).then(() => true).catch(() => false);
476
+ if (!exists) {
477
+ process.stdout.write(`(nothing to uninstall at ${target})\n`);
478
+ return 0;
479
+ }
480
+ const ours = await isOurSkillDir(target);
481
+ if (!ours) {
482
+ 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.`);
483
+ }
484
+ // Manifest-scoped removal: read the persisted manifest at install time
485
+ // and remove only its files + the manifest itself. Leaves any
486
+ // user-authored content under the same folder in place (with a note).
487
+ // Fallback: bundles installed before 0.9.0 have no persisted manifest;
488
+ // legacy whole-folder rm preserves the pre-0.9.0 behaviour.
489
+ const persistedPath = path.join(target, PERSISTED_MANIFEST_BASENAME);
490
+ const manifestBody = await fs.readFile(persistedPath, "utf8").catch(() => null);
491
+ if (!manifestBody) {
492
+ // Legacy uninstall: rm -rf whole folder.
493
+ try {
494
+ await fs.rm(target, { recursive: true, force: false });
495
+ }
496
+ catch (err) {
497
+ const stillExists = await fs.stat(target).then(() => true).catch(() => false);
498
+ if (stillExists) {
499
+ const msg = err instanceof Error ? err.message : String(err);
500
+ throw new Error(`uninstall partially failed at ${target}: ${msg}; manual cleanup may be required`);
501
+ }
502
+ throw err;
503
+ }
504
+ }
505
+ else {
506
+ let persistedManifest;
507
+ try {
508
+ persistedManifest = JSON.parse(manifestBody);
509
+ }
510
+ catch (err) {
511
+ const msg = err instanceof Error ? err.message : String(err);
512
+ throw new Error(`persisted manifest at ${persistedPath} is not valid JSON: ${msg}. Remove the file manually then retry.`);
513
+ }
514
+ for (const f of persistedManifest.files) {
515
+ await fs.unlink(path.join(target, f.dest)).catch(() => null);
516
+ }
517
+ await fs.unlink(persistedPath).catch(() => null);
518
+ // Recursively prune empty directories under target. Stop at target
519
+ // itself — only rmdir target if no user-authored files remain.
520
+ const pruneEmptyDirs = async (dir) => {
521
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
522
+ for (const e of entries) {
523
+ if (e.isDirectory()) {
524
+ await pruneEmptyDirs(path.join(dir, e.name));
525
+ const subEntries = await fs.readdir(path.join(dir, e.name)).catch(() => []);
526
+ if (subEntries.length === 0) {
527
+ await fs.rmdir(path.join(dir, e.name)).catch(() => null);
528
+ }
529
+ }
530
+ }
531
+ };
532
+ await pruneEmptyDirs(target);
533
+ const remaining = await fs.readdir(target).catch(() => []);
534
+ if (remaining.length === 0) {
535
+ await fs.rmdir(target).catch(() => null);
536
+ }
537
+ else {
538
+ process.stdout.write(`note: ${target} contains files outside the skill manifest; left in place. Remove manually if intentional.\n`);
539
+ }
540
+ }
541
+ process.stdout.write(`uninstalled ${exports.SKILL_NAME} from ${target}\n`);
542
+ return 0;
543
+ });
544
+ }
545
+ async function update(opts) {
546
+ return withFatalReturn(async () => {
547
+ const target = await resolve(opts.target ?? defaultTarget());
548
+ const base = baseUrl(opts.version);
549
+ const manifest = await fetchManifest(base);
550
+ if (manifest.name !== exports.SKILL_NAME) {
551
+ throw new Error(`manifest name mismatch: expected '${exports.SKILL_NAME}', got '${manifest.name}'. CLI and bundle are out of sync.`);
552
+ }
553
+ // Already-at-version short-circuit: read the on-disk SKILL.md
554
+ // frontmatter version and compare to manifest. Equal -> no-op.
555
+ const installedSkillMdPath = path.join(target, "SKILL.md");
556
+ const installedBody = await fs.readFile(installedSkillMdPath, "utf8").catch(() => null);
557
+ if (installedBody) {
558
+ const fmInstalled = parseFrontmatter(installedBody);
559
+ if (fmInstalled.version && fmInstalled.version === manifest.version) {
560
+ process.stdout.write(`${exports.SKILL_NAME} already at version ${manifest.version} (no-op)\n`);
561
+ return 0;
562
+ }
563
+ }
564
+ // Otherwise proceed with overwriting install (the existing path).
565
+ return install({ ...opts, overwrite: true });
566
+ });
567
+ }
568
+ async function list(opts) {
569
+ return withFatalReturn(async () => {
570
+ const root = await resolve(opts.target ?? defaultSkillsRoot());
571
+ const exists = await fs.stat(root).then(() => true).catch(() => false);
572
+ if (!exists) {
573
+ process.stdout.write("(no skills installed)\n");
574
+ return 0;
575
+ }
576
+ const entries = await fs.readdir(root, { withFileTypes: true });
577
+ const rows = [];
578
+ for (const e of entries) {
579
+ if (!e.isDirectory())
580
+ continue;
581
+ const skillMdPath = path.join(root, e.name, "SKILL.md");
582
+ try {
583
+ const md = await fs.readFile(skillMdPath, "utf8");
584
+ const fm = parseFrontmatter(md);
585
+ rows.push(`${fm.name ?? e.name}\t${fm.version ?? "?"}`);
586
+ }
587
+ catch {
588
+ // not a skill folder; skip silently
589
+ }
590
+ }
591
+ process.stdout.write(rows.length ? rows.join("\n") + "\n" : "(no skills installed)\n");
592
+ return 0;
593
+ });
594
+ }
595
+ return { install, uninstall, update, list };
596
+ }
597
+ 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,127 +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)(opts.version);
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
- await (0, _shared_1.verifyIconsAvailability)(base, manifest, opts.version);
90
- for (const { dest } of manifest.files) {
91
- await fs.mkdir(path.dirname(path.join(target, dest)), { recursive: true });
92
- }
93
- await fs.writeFile(path.join(target, manifest.files[0].dest), skillMd, "utf8");
94
- for (const { src, dest } of manifest.files.slice(1)) {
95
- const body = await (0, _shared_1.fetchText)(`${base}/${src}`);
96
- await fs.writeFile(path.join(target, dest), body, "utf8");
97
- }
98
- process.stdout.write(`installed ${_shared_1.SKILL_NAME} to ${target}\n`);
99
- return 0;
100
- });
101
- }
102
- async function uninstall(opts) {
103
- return (0, _shared_1.withFatalReturn)(async () => {
104
- const target = await resolveTarget(opts.target ?? defaultTarget());
105
- const exists = await fs.stat(target).then(() => true).catch(() => false);
106
- if (!exists) {
107
- process.stdout.write(`(nothing to uninstall at ${target})\n`);
108
- return 0;
109
- }
110
- const ours = await isOurSkillDir(target);
111
- if (!ours) {
112
- 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.`);
113
- }
114
- try {
115
- await fs.rm(target, { recursive: true, force: false });
116
- }
117
- catch (err) {
118
- const stillExists = await fs.stat(target).then(() => true).catch(() => false);
119
- if (stillExists) {
120
- const msg = err instanceof Error ? err.message : String(err);
121
- throw new Error(`uninstall partially failed at ${target}: ${msg}; manual cleanup may be required`);
122
- }
123
- throw err;
124
- }
125
- process.stdout.write(`uninstalled ${_shared_1.SKILL_NAME} from ${target}\n`);
126
- return 0;
127
- });
128
- }
129
- async function update(opts) {
130
- return install({ ...opts, overwrite: true });
131
- }
132
- async function list(opts) {
133
- return (0, _shared_1.withFatalReturn)(async () => {
134
- const root = await resolveTarget(opts.target ?? defaultSkillsRoot());
135
- const exists = await fs.stat(root).then(() => true).catch(() => false);
136
- if (!exists) {
137
- process.stdout.write("(no skills installed)\n");
138
- return 0;
139
- }
140
- const entries = await fs.readdir(root, { withFileTypes: true });
141
- const rows = [];
142
- for (const e of entries) {
143
- if (!e.isDirectory())
144
- continue;
145
- const skillMdPath = path.join(root, e.name, "SKILL.md");
146
- try {
147
- const md = await fs.readFile(skillMdPath, "utf8");
148
- const fm = (0, _shared_1.parseFrontmatter)(md);
149
- rows.push(`${fm.name ?? e.name}\t${fm.version ?? "?"}`);
150
- }
151
- catch {
152
- // not a skill folder; skip silently
153
- }
154
- }
155
- process.stdout.write(rows.length ? rows.join("\n") + "\n" : "(no skills installed)\n");
156
- return 0;
157
- });
158
- }
159
- 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,124 +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)(opts.version);
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
- await (0, _shared_1.verifyIconsAvailability)(base, manifest, opts.version);
90
- for (const { dest } of manifest.files) {
91
- await fs.mkdir(path.dirname(path.join(target, dest)), { recursive: true });
92
- }
93
- await fs.writeFile(path.join(target, manifest.files[0].dest), skillMd, "utf8");
94
- for (const { src, dest } of manifest.files.slice(1)) {
95
- const body = await (0, _shared_1.fetchText)(`${base}/${src}`);
96
- await fs.writeFile(path.join(target, dest), body, "utf8");
97
- }
98
- process.stdout.write(`installed ${_shared_1.SKILL_NAME} to ${target}\n`);
99
- return 0;
100
- });
101
- }
102
- async function uninstall(opts) {
103
- return (0, _shared_1.withFatalReturn)(async () => {
104
- const target = await resolveTarget(opts.target ?? defaultTarget());
105
- const exists = await fs.stat(target).then(() => true).catch(() => false);
106
- if (!exists) {
107
- process.stdout.write(`(nothing to uninstall at ${target})\n`);
108
- return 0;
109
- }
110
- const ours = await isOurSkillDir(target);
111
- if (!ours) {
112
- 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.`);
113
- }
114
- try {
115
- await fs.rm(target, { recursive: true, force: false });
116
- }
117
- catch (err) {
118
- const stillExists = await fs.stat(target).then(() => true).catch(() => false);
119
- if (stillExists) {
120
- const msg = err instanceof Error ? err.message : String(err);
121
- throw new Error(`uninstall partially failed at ${target}: ${msg}; manual cleanup may be required`);
122
- }
123
- throw err;
124
- }
125
- process.stdout.write(`uninstalled ${_shared_1.SKILL_NAME} from ${target}\n`);
126
- return 0;
127
- });
128
- }
129
- async function update(opts) {
130
- return install({ ...opts, overwrite: true });
131
- }
132
- async function list(opts) {
133
- return (0, _shared_1.withFatalReturn)(async () => {
134
- const root = await resolveTarget(opts.target ?? defaultSkillsRoot());
135
- const exists = await fs.stat(root).then(() => true).catch(() => false);
136
- if (!exists) {
137
- process.stdout.write("(no skills installed)\n");
138
- return 0;
139
- }
140
- const entries = await fs.readdir(root, { withFileTypes: true });
141
- const rows = [];
142
- for (const e of entries) {
143
- if (!e.isDirectory())
144
- continue;
145
- const skillMdPath = path.join(root, e.name, "SKILL.md");
146
- try {
147
- const md = await fs.readFile(skillMdPath, "utf8");
148
- const fm = (0, _shared_1.parseFrontmatter)(md);
149
- rows.push(`${fm.name ?? e.name}\t${fm.version ?? "?"}`);
150
- }
151
- catch {
152
- // not a skill folder; skip silently
153
- }
154
- }
155
- process.stdout.write(rows.length ? rows.join("\n") + "\n" : "(no skills installed)\n");
156
- return 0;
157
- });
158
- }
159
- 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,6 +80,9 @@ 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
87
  const base = (0, _shared_1.baseUrl)(opts.version);
80
88
  const ruleFile = path.join(targetDir, RULE_BASENAME);
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,12 +8,6 @@ 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.")
@@ -27,10 +21,8 @@ function defineSubcommand(name, description) {
27
21
  .option("--target <dir>", "override target directory (validation use)")
28
22
  .option("--version <semver>", "pin to a specific skill version (X.Y.Z); default = latest from main")
29
23
  .action(async (opts) => {
30
- // Set process.exitCode so any pending async cleanup (file handles, the
31
- // override-warning stderr write) drains before the event loop empties.
32
- // Both this top-level path and adapter-internal failures emit a single
33
- // '^fatal: ' prefix line on stderr — log-parsers can rely on the prefix.
24
+ // Validate --version pre-dispatch as defense-in-depth; baseUrl() in
25
+ // _shared.ts also rejects malformed values when it builds the URL.
34
26
  if (opts.version !== undefined && !VERSION_RE.test(opts.version)) {
35
27
  throw new Error(`--version must match X.Y.Z (got: ${opts.version})`);
36
28
  }
@@ -40,15 +32,20 @@ function defineSubcommand(name, description) {
40
32
  return;
41
33
  }
42
34
  if (!registry_1.SUPPORTED_AGENTS.includes(opts.agent)) {
43
- 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(", ")})`);
44
36
  }
45
- process.exitCode = await pickAdapter(opts.agent)[name](optsForAdapter);
37
+ process.exitCode = await registry_1.ADAPTERS[opts.agent][name](optsForAdapter);
46
38
  });
47
39
  }
48
40
  defineSubcommand("install", "Install the skill into an AI agent's skill folder.");
49
41
  defineSubcommand("uninstall", "Remove a previously installed skill.");
50
42
  defineSubcommand("update", "Update an installed skill to the latest version.");
51
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.
52
49
  program.parseAsync(process.argv).catch(err => {
53
50
  process.stderr.write(`fatal: ${err instanceof Error ? err.message : String(err)}\n`);
54
51
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanv89/azure-arch-skill",
3
- "version": "0.7.0",
3
+ "version": "0.9.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"