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