hngr 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,871 @@
1
+ import { S as HngrError, g as resolveConnection, r as apiRequest, y as writeDirectoryAtomically } from "./http-2HWxLIOq.mjs";
2
+ import { _ as resolveCapabilityVersion, a as failedResult, c as stringValues, d as homeDir, f as downloadContent, g as resolveCapability, h as parseCapabilityPath, i as errorBody, l as AGENTS, m as SKILL_KIND, n as readPublishFlags, o as positionalPaths, r as runPublish, s as runBatch, t as explicitName } from "./publish-D7zaQl9x.mjs";
3
+ import { lstat, mkdir, readFile, readdir, rename, rm, symlink, writeFile } from "node:fs/promises";
4
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { randomUUID } from "node:crypto";
6
+ import { deflateRawSync, inflateRawSync } from "node:zlib";
7
+ // src/http-multipart.ts
8
+ function isFile(part) {
9
+ return "data" in part;
10
+ }
11
+ function escapeQuoted(value) {
12
+ return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
13
+ }
14
+ /**
15
+ * Encodes an RFC 7578 form body in memory. Part order is preserved, which the
16
+ * server relies on: the package (or config) must be the last part so it can be
17
+ * consumed as a stream.
18
+ */
19
+ function encodeMultipart(parts) {
20
+ const boundary = `----hngr-${randomUUID()}`;
21
+ const chunks = [];
22
+ for (const part of parts) {
23
+ chunks.push(Buffer.from(`--${boundary}\r\n`));
24
+ if (isFile(part)) chunks.push(Buffer.from(`Content-Disposition: form-data; name="${escapeQuoted(part.name)}"; filename="${escapeQuoted(part.filename)}"\r\nContent-Type: ${part.contentType}\r\n\r\n`), part.data);
25
+ else chunks.push(Buffer.from(`Content-Disposition: form-data; name="${escapeQuoted(part.name)}"\r\n\r\n${part.value}`, "utf8"));
26
+ chunks.push(Buffer.from("\r\n"));
27
+ }
28
+ chunks.push(Buffer.from(`--${boundary}--\r\n`));
29
+ return {
30
+ body: Buffer.concat(chunks),
31
+ contentType: `multipart/form-data; boundary=${boundary}`
32
+ };
33
+ }
34
+ /**
35
+ * POSTs a multipart body through the shared request path, so Bearer injection,
36
+ * cancellation and the problem+json error mapping are identical to JSON calls.
37
+ */
38
+ async function postMultipart(connection, path, parts, headers = {}, signal) {
39
+ const { body, contentType } = encodeMultipart(parts);
40
+ return apiRequest(connection, {
41
+ method: "POST",
42
+ path,
43
+ rawBody: body,
44
+ headers: {
45
+ ...headers,
46
+ "Content-Type": contentType
47
+ },
48
+ signal
49
+ });
50
+ }
51
+ // src/skills/agents.ts
52
+ const AGENT_TARGETS = AGENTS.map((agent) => ({
53
+ id: agent.id,
54
+ projectSkillsDir: agent.skills.projectDir,
55
+ globalSkillsDir: agent.skills.globalDir,
56
+ link: agent.skills.link
57
+ }));
58
+ function findAgent(id) {
59
+ return AGENT_TARGETS.find((agent) => agent.id === id);
60
+ }
61
+ function agentIds() {
62
+ return AGENT_TARGETS.map((agent) => agent.id);
63
+ }
64
+ /** The canonical store for this scope. */
65
+ function canonicalSkillsDir(cwd, env, global) {
66
+ return global ? join(homeDir(env), ".agents", "skills") : join(cwd, ".agents", "skills");
67
+ }
68
+ /** The directory an agent reads, or null when it shares the canonical store. */
69
+ function agentSkillsDir(agent, cwd, env, global) {
70
+ if (global) return agent.globalSkillsDir?.(env) ?? null;
71
+ return agent.projectSkillsDir ? join(cwd, agent.projectSkillsDir) : null;
72
+ }
73
+ /**
74
+ * The directory an agent actually reads: its own skills directory, or the
75
+ * canonical store for universal agents. (`--copy` writes here; detection
76
+ * checks the parent.)
77
+ */
78
+ function targetSkillsDir(agent, cwd, env, global) {
79
+ return agentSkillsDir(agent, cwd, env, global) ?? canonicalSkillsDir(cwd, env, global);
80
+ }
81
+ /** Directory names that would collide with agent-owned skill stores. */
82
+ const RESERVED_DIR_NAMES = ["synced", "anthropic-skills"];
83
+ function isReservedDirName(name) {
84
+ return RESERVED_DIR_NAMES.includes(name.toLowerCase());
85
+ }
86
+ // src/skills/download.ts
87
+ /** Downloads a Skill Package as `application/zip`. */
88
+ function downloadSkillPackage(connection, capabilityId, version, signal) {
89
+ return downloadContent(connection, capabilityId, version, "application/zip", signal);
90
+ }
91
+ // src/skills/install.ts
92
+ async function pathExists(path) {
93
+ return lstat(path).then(() => true, () => false);
94
+ }
95
+ function targetExistsError(path) {
96
+ return new HngrError({
97
+ type: "io",
98
+ title: `Target already exists: ${path}`,
99
+ detail: "Pass --force to replace it."
100
+ });
101
+ }
102
+ async function writeEntries(dest, entries) {
103
+ await mkdir(dest, { recursive: true });
104
+ for (const entry of entries) {
105
+ const path = resolve(dest, entry.path);
106
+ if (path !== dest && !path.startsWith(`${dest}${sep}`)) throw new HngrError({
107
+ type: "io",
108
+ title: `Refusing to write outside ${dest}`,
109
+ detail: `entry ${JSON.stringify(entry.path)} escapes the target directory`
110
+ });
111
+ await mkdir(dirname(path), { recursive: true });
112
+ await writeFile(path, entry.data, { mode: entry.mode & 73 ? 493 : 420 });
113
+ }
114
+ }
115
+ /**
116
+ * Writes a skill through "temp directory → validate → rename": the target is
117
+ * either absent or complete, never half-written. `--force` moves the old
118
+ * directory aside before the rename so a failed swap puts it back.
119
+ */
120
+ async function installAtomically(dest, entries, force) {
121
+ await writeDirectoryAtomically(dest, (temp) => writeEntries(temp, entries), {
122
+ force,
123
+ existsError: () => targetExistsError(dest)
124
+ });
125
+ }
126
+ /**
127
+ * Creates a relative symlink to the canonical directory, falling back to a
128
+ * copy when the platform refuses the link (Windows without Developer Mode).
129
+ * Windows prefers a junction, which does not need elevated privileges; its
130
+ * target must be absolute. A temp link plus rename replaces an existing link
131
+ * atomically.
132
+ */
133
+ async function linkAtomically(canonical, target, entries, force, options = {}) {
134
+ const exists = await pathExists(target);
135
+ if (exists && !force) throw targetExistsError(target);
136
+ await mkdir(dirname(target), { recursive: true });
137
+ const temp = resolve(dirname(target), `.hngr-${basename(target)}-${randomUUID()}.link`);
138
+ const windows = (options.platform ?? process.platform) === "win32";
139
+ const createLink = options.symlink ?? symlink;
140
+ try {
141
+ await createLink(windows ? canonical : relative(dirname(target), canonical), temp, windows ? "junction" : "dir");
142
+ if (exists) {
143
+ const stat = await lstat(target);
144
+ if (stat.isDirectory() && !stat.isSymbolicLink()) await rm(target, {
145
+ recursive: true,
146
+ force: true
147
+ });
148
+ }
149
+ await rename(temp, target);
150
+ return { mode: "symlink" };
151
+ } catch {
152
+ await rm(temp, { force: true }).catch(() => {});
153
+ await installAtomically(target, entries, false);
154
+ return {
155
+ mode: "copy",
156
+ warning: `warning: could not create a link at ${target}; copied instead`
157
+ };
158
+ }
159
+ }
160
+ /** Copies one target directory atomically, without a canonical store. */
161
+ async function copyAtomically(target, entries, force) {
162
+ await installAtomically(target, entries, force);
163
+ }
164
+ // src/skills/resolve.ts
165
+ /** Parses `<name>` or `<namespace>/<name>`, with an optional `@YYYY.MM.DD.N`. */
166
+ function parseSkillPath(raw) {
167
+ return parseCapabilityPath(raw, SKILL_KIND);
168
+ }
169
+ function resolveSkill(connection, parsed, signal) {
170
+ return resolveCapability(connection, parsed, SKILL_KIND, signal);
171
+ }
172
+ /** The pinned version, or the capability's Current Version. */
173
+ function resolveVersion(capability, parsed) {
174
+ return resolveCapabilityVersion(capability, parsed, SKILL_KIND);
175
+ }
176
+ // src/skills/zip.ts
177
+ /** Limits: at most 25 MiB unpacked and 1000 files. */
178
+ const MAX_UNPACKED_BYTES = 26214400;
179
+ const MAX_FILES = 1e3;
180
+ const EOCD_SIGNATURE = 101010256;
181
+ const CENTRAL_SIGNATURE = 33639248;
182
+ const LOCAL_SIGNATURE = 67324752;
183
+ const ZIP64_MARKER = 4294967295;
184
+ const MAX_EOCD_SEARCH = 65557;
185
+ const FILE_TYPE_MASK$1 = 61440;
186
+ const FILE_TYPE_DIRECTORY = 16384;
187
+ const FILE_TYPE_SYMLINK$1 = 40960;
188
+ const FILE_TYPE_REGULAR$1 = 32768;
189
+ const ZIP_FLAG_ENCRYPTED = 1;
190
+ function invalid$1(detail) {
191
+ return new HngrError({
192
+ type: "api",
193
+ title: "The Capability Package is not a valid Skill package",
194
+ detail
195
+ });
196
+ }
197
+ function hasDriveLetter$1(name) {
198
+ return /^[A-Za-z]:/.test(name);
199
+ }
200
+ /** Rejects names that could escape the target directory or confuse extraction. */
201
+ function cleanEntryName(name) {
202
+ if (name.includes("\\")) throw invalid$1(`entry ${JSON.stringify(name)} uses a backslash; use / as the separator`);
203
+ if (name.startsWith("/") || hasDriveLetter$1(name)) throw invalid$1(`entry ${JSON.stringify(name)} is an absolute path`);
204
+ const withoutTrailingSlash = name.endsWith("/") ? name.slice(0, -1) : name;
205
+ for (const segment of withoutTrailingSlash.split("/")) {
206
+ if (segment === "..") throw invalid$1(`entry ${JSON.stringify(name)} contains a .. segment`);
207
+ if (segment === "" || segment === ".") throw invalid$1(`entry ${JSON.stringify(name)} is not a clean relative path`);
208
+ if (segment.includes("\0")) throw invalid$1(`entry ${JSON.stringify(name)} is not a clean relative path`);
209
+ }
210
+ }
211
+ function findEndOfCentralDirectory(buffer) {
212
+ const start = Math.max(0, buffer.length - MAX_EOCD_SEARCH);
213
+ for (let offset = buffer.length - 22; offset >= start; offset -= 1) if (buffer.readUInt32LE(offset) === EOCD_SIGNATURE) return offset;
214
+ throw invalid$1("the package is not a valid ZIP archive");
215
+ }
216
+ function readCentralDirectory(buffer) {
217
+ const eocd = findEndOfCentralDirectory(buffer);
218
+ const totalEntries = buffer.readUInt16LE(eocd + 10);
219
+ const directorySize = buffer.readUInt32LE(eocd + 12);
220
+ const directoryOffset = buffer.readUInt32LE(eocd + 16);
221
+ if (totalEntries === 65535 || directoryOffset === ZIP64_MARKER) throw invalid$1("ZIP64 packages are not supported");
222
+ if (directoryOffset + directorySize > buffer.length) throw invalid$1("the package is not a valid ZIP archive");
223
+ const entries = [];
224
+ let offset = directoryOffset;
225
+ for (let index = 0; index < totalEntries; index += 1) {
226
+ if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== CENTRAL_SIGNATURE) throw invalid$1("the package is not a valid ZIP archive");
227
+ const flags = buffer.readUInt16LE(offset + 8);
228
+ const method = buffer.readUInt16LE(offset + 10);
229
+ const compressedSize = buffer.readUInt32LE(offset + 20);
230
+ const uncompressedSize = buffer.readUInt32LE(offset + 24);
231
+ const nameLength = buffer.readUInt16LE(offset + 28);
232
+ const extraLength = buffer.readUInt16LE(offset + 30);
233
+ const commentLength = buffer.readUInt16LE(offset + 32);
234
+ const externalAttributes = buffer.readUInt32LE(offset + 38);
235
+ const localHeaderOffset = buffer.readUInt32LE(offset + 42);
236
+ if (compressedSize === ZIP64_MARKER || uncompressedSize === ZIP64_MARKER) throw invalid$1("ZIP64 packages are not supported");
237
+ if (flags & ZIP_FLAG_ENCRYPTED) throw invalid$1("the package contains an encrypted entry");
238
+ const nameStart = offset + 46;
239
+ const nameEnd = nameStart + nameLength;
240
+ if (nameEnd > buffer.length) throw invalid$1("the package is not a valid ZIP archive");
241
+ const name = buffer.toString("utf8", nameStart, nameEnd);
242
+ const mode = externalAttributes >>> 16;
243
+ const type = mode & FILE_TYPE_MASK$1;
244
+ const directory = name.endsWith("/") || type === FILE_TYPE_DIRECTORY;
245
+ cleanEntryName(name);
246
+ if (!directory && type !== 0 && type !== FILE_TYPE_REGULAR$1) throw invalid$1(type === FILE_TYPE_SYMLINK$1 ? `entry ${JSON.stringify(name)} is a symbolic link` : `entry ${JSON.stringify(name)} is not a regular file or directory`);
247
+ if (method !== 0 && method !== 8) throw invalid$1(`entry ${JSON.stringify(name)} uses an unsupported compression method`);
248
+ entries.push({
249
+ name,
250
+ directory,
251
+ localHeaderOffset,
252
+ compressedSize,
253
+ uncompressedSize,
254
+ method,
255
+ mode
256
+ });
257
+ offset = nameEnd + extraLength + commentLength;
258
+ }
259
+ return entries;
260
+ }
261
+ function readEntryData(buffer, entry) {
262
+ const local = entry.localHeaderOffset;
263
+ if (local + 30 > buffer.length || buffer.readUInt32LE(local) !== LOCAL_SIGNATURE) throw invalid$1(`entry ${JSON.stringify(entry.name)} is corrupt`);
264
+ const nameLength = buffer.readUInt16LE(local + 26);
265
+ const extraLength = buffer.readUInt16LE(local + 28);
266
+ const dataStart = local + 30 + nameLength + extraLength;
267
+ const dataEnd = dataStart + entry.compressedSize;
268
+ if (dataEnd > buffer.length) throw invalid$1(`entry ${JSON.stringify(entry.name)} is corrupt`);
269
+ const compressed = buffer.subarray(dataStart, dataEnd);
270
+ let data;
271
+ if (entry.method === 0) data = Buffer.from(compressed);
272
+ else try {
273
+ data = inflateRawSync(compressed, { maxOutputLength: MAX_UNPACKED_BYTES });
274
+ } catch {
275
+ throw invalid$1(`entry ${JSON.stringify(entry.name)} is corrupt`);
276
+ }
277
+ if (data.length !== entry.uncompressedSize) throw invalid$1(`entry ${JSON.stringify(entry.name)} is corrupt`);
278
+ return data;
279
+ }
280
+ function lastSegment$1(name) {
281
+ return name.slice(name.lastIndexOf("/") + 1);
282
+ }
283
+ /** "dir/" when every entry lives under one top-level directory, otherwise "". */
284
+ function packageRoot$1(names) {
285
+ let root = "";
286
+ let nested = false;
287
+ for (const name of names) {
288
+ const slash = name.indexOf("/");
289
+ const top = slash === -1 ? name : name.slice(0, slash);
290
+ if (root !== "" && top !== root) return "";
291
+ root = top;
292
+ nested = nested || slash !== -1;
293
+ }
294
+ return nested ? `${root}/` : "";
295
+ }
296
+ /**
297
+ * Parses and validates a downloaded Capability Package. Nothing is written to
298
+ * disk here; a rejected package fails before the installer touches a target.
299
+ */
300
+ function readSkillPackage(buffer) {
301
+ if (buffer.length < 22) throw invalid$1("the package is not a valid ZIP archive");
302
+ const raw = readCentralDirectory(buffer);
303
+ const files = raw.filter((entry) => !entry.directory);
304
+ if (files.length > 1e3) throw invalid$1(`the package has ${files.length} files; the limit is ${MAX_FILES}`);
305
+ let declaredBytes = 0;
306
+ for (const entry of raw) {
307
+ declaredBytes += entry.uncompressedSize;
308
+ if (declaredBytes > 26214400) throw invalid$1(`the package unpacks to more than ${MAX_UNPACKED_BYTES / 1048576} MiB`);
309
+ }
310
+ const names = raw.map((entry) => entry.directory ? entry.name.slice(0, -1) : entry.name);
311
+ const seen = /* @__PURE__ */ new Set();
312
+ for (const name of names) {
313
+ const folded = name.toLowerCase();
314
+ if (seen.has(folded)) throw invalid$1(`entry ${JSON.stringify(name)} appears more than once`);
315
+ seen.add(folded);
316
+ }
317
+ const manifests = files.filter((entry) => lastSegment$1(entry.name).toLowerCase() === "skill.md");
318
+ if (manifests.length === 0) throw invalid$1("the package has no SKILL.md");
319
+ if (manifests.length > 1) throw invalid$1(`the package has more than one SKILL.md: ${manifests.map((entry) => entry.name).join(", ")}`);
320
+ const root = packageRoot$1(names);
321
+ const expectedManifest = `${root}SKILL.md`;
322
+ if (manifests[0]?.name !== expectedManifest) throw invalid$1(`found ${manifests[0]?.name}, but the package needs SKILL.md at its root or inside its single top-level directory`);
323
+ let actualBytes = 0;
324
+ const entries = [];
325
+ let manifest;
326
+ for (const entry of files) {
327
+ const data = readEntryData(buffer, entry);
328
+ actualBytes += data.length;
329
+ if (actualBytes > 26214400) throw invalid$1(`the package unpacks to more than ${MAX_UNPACKED_BYTES / 1048576} MiB`);
330
+ const path = entry.name.slice(root.length);
331
+ entries.push({
332
+ path,
333
+ data,
334
+ mode: entry.mode & 511
335
+ });
336
+ if (path === "SKILL.md") manifest = data;
337
+ }
338
+ if (!manifest) throw invalid$1("the package has no SKILL.md");
339
+ return {
340
+ entries,
341
+ manifest
342
+ };
343
+ }
344
+ // src/skills/zip-write.ts
345
+ /** The same limits the installer enforces for a downloaded Skill package. */
346
+ const MAX_PACKAGE_FILES = 1e3;
347
+ const MAX_PACKAGE_BYTES = 26214400;
348
+ const FILE_TYPE_MASK = 61440;
349
+ const FILE_TYPE_SYMLINK = 40960;
350
+ const FILE_TYPE_REGULAR = 32768;
351
+ function invalid(detail) {
352
+ return new HngrError({
353
+ type: "usage",
354
+ title: "The Skill directory is not a valid Capability Package",
355
+ detail
356
+ });
357
+ }
358
+ function hasDriveLetter(name) {
359
+ return /^[A-Za-z]:/.test(name);
360
+ }
361
+ /** Rejects archive paths that could escape an extraction; also used for local names. */
362
+ function validateArchivePath(name) {
363
+ if (name.includes("\\")) throw invalid(`entry ${JSON.stringify(name)} uses a backslash; use / as the separator`);
364
+ if (name.startsWith("/") || hasDriveLetter(name)) throw invalid(`entry ${JSON.stringify(name)} is an absolute path`);
365
+ const withoutTrailingSlash = name.endsWith("/") ? name.slice(0, -1) : name;
366
+ for (const segment of withoutTrailingSlash.split("/")) {
367
+ if (segment === "..") throw invalid(`entry ${JSON.stringify(name)} contains a .. segment`);
368
+ if (segment === "" || segment === "." || segment.includes("\0")) throw invalid(`entry ${JSON.stringify(name)} is not a clean relative path`);
369
+ }
370
+ }
371
+ const CRC_TABLE = (() => {
372
+ const table = /* @__PURE__ */ new Uint32Array(256);
373
+ for (let n = 0; n < 256; n += 1) {
374
+ let c = n;
375
+ for (let k = 0; k < 8; k += 1) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
376
+ table[n] = c >>> 0;
377
+ }
378
+ return table;
379
+ })();
380
+ function crc32(data) {
381
+ let crc = 4294967295;
382
+ for (const byte of data) crc = (CRC_TABLE[(crc ^ byte) & 255] ?? 0) ^ crc >>> 8;
383
+ return (crc ^ 4294967295) >>> 0;
384
+ }
385
+ /**
386
+ * Builds a ZIP archive in memory. Files are deflated; directory entries are
387
+ * unnecessary because every reader derives them from the file paths.
388
+ */
389
+ function buildZip(entries) {
390
+ const locals = [];
391
+ const centrals = [];
392
+ let offset = 0;
393
+ for (const entry of entries) {
394
+ const data = entry.data;
395
+ const stored = deflateRawSync(data);
396
+ const crc = crc32(data);
397
+ const name = Buffer.from(entry.path, "utf8");
398
+ const externalAttrs = (entry.mode & 511 | FILE_TYPE_REGULAR) * 65536 >>> 0;
399
+ const local = Buffer.alloc(30);
400
+ local.writeUInt32LE(67324752, 0);
401
+ local.writeUInt16LE(20, 4);
402
+ local.writeUInt16LE(0, 6);
403
+ local.writeUInt16LE(8, 8);
404
+ local.writeUInt16LE(0, 10);
405
+ local.writeUInt16LE(0, 12);
406
+ local.writeUInt32LE(crc, 14);
407
+ local.writeUInt32LE(stored.length, 18);
408
+ local.writeUInt32LE(data.length, 22);
409
+ local.writeUInt16LE(name.length, 26);
410
+ local.writeUInt16LE(0, 28);
411
+ locals.push(local, name, stored);
412
+ const central = Buffer.alloc(46);
413
+ central.writeUInt32LE(33639248, 0);
414
+ central.writeUInt16LE(798, 4);
415
+ central.writeUInt16LE(20, 6);
416
+ central.writeUInt16LE(0, 8);
417
+ central.writeUInt16LE(8, 10);
418
+ central.writeUInt16LE(0, 12);
419
+ central.writeUInt16LE(0, 14);
420
+ central.writeUInt32LE(crc, 16);
421
+ central.writeUInt32LE(stored.length, 20);
422
+ central.writeUInt32LE(data.length, 24);
423
+ central.writeUInt16LE(name.length, 28);
424
+ central.writeUInt16LE(0, 30);
425
+ central.writeUInt16LE(0, 32);
426
+ central.writeUInt16LE(0, 34);
427
+ central.writeUInt16LE(0, 36);
428
+ central.writeUInt32LE(externalAttrs, 38);
429
+ central.writeUInt32LE(offset, 42);
430
+ centrals.push(central, name);
431
+ offset += local.length + name.length + stored.length;
432
+ }
433
+ const centralDirectory = Buffer.concat(centrals);
434
+ const eocd = Buffer.alloc(22);
435
+ eocd.writeUInt32LE(101010256, 0);
436
+ eocd.writeUInt16LE(0, 4);
437
+ eocd.writeUInt16LE(0, 6);
438
+ eocd.writeUInt16LE(entries.length, 8);
439
+ eocd.writeUInt16LE(entries.length, 10);
440
+ eocd.writeUInt32LE(centralDirectory.length, 12);
441
+ eocd.writeUInt32LE(offset, 16);
442
+ eocd.writeUInt16LE(0, 20);
443
+ return Buffer.concat([
444
+ ...locals,
445
+ centralDirectory,
446
+ eocd
447
+ ]);
448
+ }
449
+ function lastSegment(name) {
450
+ return name.slice(name.lastIndexOf("/") + 1);
451
+ }
452
+ /** "dir/" when every path lives under one top-level directory, otherwise "". */
453
+ function packageRoot(names) {
454
+ let root = "";
455
+ let nested = false;
456
+ for (const name of names) {
457
+ const slash = name.indexOf("/");
458
+ const top = slash === -1 ? name : name.slice(0, slash);
459
+ if (root !== "" && top !== root) return "";
460
+ root = top;
461
+ nested = nested || slash !== -1;
462
+ }
463
+ return nested ? `${root}/` : "";
464
+ }
465
+ async function walk(base, relative, entries, totals) {
466
+ let dirents;
467
+ try {
468
+ dirents = await readdir(relative === "" ? base : join(base, relative), { withFileTypes: true });
469
+ } catch (err) {
470
+ throw new HngrError({
471
+ type: "io",
472
+ title: `Could not read ${relative === "" ? base : join(base, relative)}`,
473
+ detail: err instanceof Error ? err.message : String(err)
474
+ });
475
+ }
476
+ dirents.sort((left, right) => left.name.localeCompare(right.name));
477
+ for (const dirent of dirents) {
478
+ const path = relative === "" ? dirent.name : `${relative}/${dirent.name}`;
479
+ const absolute = join(base, path);
480
+ if (dirent.isSymbolicLink()) throw invalid(`entry ${JSON.stringify(path)} is a symbolic link`);
481
+ if (dirent.isDirectory()) {
482
+ await walk(base, path, entries, totals);
483
+ continue;
484
+ }
485
+ if (!dirent.isFile()) throw invalid(`entry ${JSON.stringify(path)} is not a regular file or directory`);
486
+ validateArchivePath(path);
487
+ const stats = await lstat(absolute);
488
+ const type = stats.mode & FILE_TYPE_MASK;
489
+ if (type === FILE_TYPE_SYMLINK) throw invalid(`entry ${JSON.stringify(path)} is a symbolic link`);
490
+ if (type !== FILE_TYPE_REGULAR && type !== 0) throw invalid(`entry ${JSON.stringify(path)} is not a regular file or directory`);
491
+ if (totals.bytes + stats.size > 26214400) throw invalid(`the directory unpacks to more than ${MAX_PACKAGE_BYTES / 1048576} MiB`);
492
+ const data = await readFile(absolute);
493
+ totals.bytes += data.length;
494
+ if (totals.bytes > 26214400) throw invalid(`the directory unpacks to more than ${MAX_PACKAGE_BYTES / 1048576} MiB`);
495
+ entries.push({
496
+ path,
497
+ data,
498
+ mode: stats.mode & 511
499
+ });
500
+ if (entries.length > 1e3) throw invalid(`the directory has ${entries.length} files; the limit is ${MAX_PACKAGE_FILES}`);
501
+ }
502
+ }
503
+ /**
504
+ * Reads and validates a local Skill directory, then packs it into a ZIP in
505
+ * memory. Every rule mirrors the installer's package validation, so a
506
+ * directory that passes here also passes the server. Nothing is uploaded.
507
+ */
508
+ async function packageSkillDirectory(dir) {
509
+ const base = resolve(dir);
510
+ let stats;
511
+ try {
512
+ stats = await lstat(base);
513
+ } catch (err) {
514
+ throw new HngrError({
515
+ type: "io",
516
+ title: `Could not read ${dir}`,
517
+ detail: err instanceof Error ? err.message : String(err)
518
+ });
519
+ }
520
+ if (!stats.isDirectory()) throw invalid(`${JSON.stringify(dir)} is not a directory`);
521
+ if ((stats.mode & FILE_TYPE_MASK) === FILE_TYPE_SYMLINK) throw invalid(`${JSON.stringify(dir)} is a symbolic link`);
522
+ const entries = [];
523
+ const totals = { bytes: 0 };
524
+ await walk(base, "", entries, totals);
525
+ const seen = /* @__PURE__ */ new Set();
526
+ for (const entry of entries) {
527
+ const folded = entry.path.toLowerCase();
528
+ if (seen.has(folded)) throw invalid(`entry ${JSON.stringify(entry.path)} appears more than once`);
529
+ seen.add(folded);
530
+ }
531
+ const manifests = entries.filter((entry) => lastSegment(entry.path).toLowerCase() === "skill.md");
532
+ if (manifests.length === 0) throw invalid("the directory has no SKILL.md");
533
+ if (manifests.length > 1) throw invalid(`the directory has more than one SKILL.md: ${manifests.map((entry) => entry.path).join(", ")}`);
534
+ const expected = `${packageRoot(entries.map((entry) => entry.path))}SKILL.md`;
535
+ if (manifests[0]?.path !== expected) throw invalid(`found ${manifests[0]?.path}, but the package needs SKILL.md at its root or inside its single top-level directory`);
536
+ return {
537
+ zip: buildZip(entries),
538
+ fileCount: entries.length,
539
+ byteCount: totals.bytes
540
+ };
541
+ }
542
+ // src/commands/skills.ts
543
+ function usage(title, detail) {
544
+ return new HngrError({
545
+ type: "usage",
546
+ title,
547
+ detail
548
+ });
549
+ }
550
+ function stringFlag(argv, name) {
551
+ const value = argv[name];
552
+ return typeof value === "string" && value !== "" ? value : void 0;
553
+ }
554
+ function displayPath(path, cwd) {
555
+ const rel = relative(cwd, path);
556
+ if (rel !== "" && !rel.startsWith("..") && !isAbsolute(rel)) return rel.split(sep).join("/");
557
+ return path;
558
+ }
559
+ function failureResult(parsed, err, version) {
560
+ return {
561
+ ...failedResult(parsed, err, version),
562
+ canonicalPath: null
563
+ };
564
+ }
565
+ /** Prompts need both stdin and stdout on a TTY plus a prompter that can multi-select. */
566
+ function canPrompt(ctx) {
567
+ return ctx.io.stdin.isTTY && ctx.io.stdout.isTTY && ctx.io.prompts?.multiselect !== void 0;
568
+ }
569
+ function noAgentSelectionError(examplePath, global) {
570
+ return usage("No agent target selected", `Pass --agent <id> (repeatable) or --all. For example: hngr skills add ${examplePath} --agent claude-code${global ? " --global" : ""}`);
571
+ }
572
+ /**
573
+ * One multi-select for every Agent Target, pre-selecting those whose skills
574
+ * directory is detected. Detection is only a pre-selection hint; a non-TTY
575
+ * invocation never consults the filesystem and must pass --agent or --all.
576
+ */
577
+ async function selectAgents(ctx, argv, examplePath, global) {
578
+ const requested = stringValues(argv.agent);
579
+ if (requested.length > 0) {
580
+ const unknown = requested.filter((id) => findAgent(id) === void 0);
581
+ if (unknown.length > 0) throw usage(`Unknown agent target: ${unknown.join(", ")}`, `Known targets: ${agentIds().join(", ")}.`);
582
+ return AGENT_TARGETS.filter((agent) => requested.includes(agent.id));
583
+ }
584
+ if (argv.all === true) return [...AGENT_TARGETS];
585
+ if (canPrompt(ctx)) {
586
+ const multiselect = ctx.io.prompts?.multiselect;
587
+ if (!multiselect) throw noAgentSelectionError(examplePath, global);
588
+ const cwd = process.cwd();
589
+ const choices = [];
590
+ for (const agent of AGENT_TARGETS) {
591
+ const dir = targetSkillsDir(agent, cwd, ctx.env, global);
592
+ choices.push({
593
+ option: {
594
+ value: agent.id,
595
+ label: `${agent.id} ${displayPath(dir, cwd)}`
596
+ },
597
+ detected: await pathExists(dirname(dir))
598
+ });
599
+ }
600
+ const selected = await multiselect("Select agent targets", choices.map((choice) => choice.option), choices.filter((choice) => choice.detected).map((choice) => choice.option.value));
601
+ if (selected.length === 0) throw noAgentSelectionError(examplePath, global);
602
+ return AGENT_TARGETS.filter((agent) => selected.includes(agent.id));
603
+ }
604
+ throw noAgentSelectionError(examplePath, global);
605
+ }
606
+ function frontmatterName(manifest) {
607
+ const text = manifest.toString("utf8");
608
+ const block = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
609
+ if (!block) return;
610
+ const line = /^name:\s*(.*)$/m.exec(block[1] ?? "");
611
+ if (!line) return;
612
+ const value = (line[1] ?? "").trim().replace(/^(["'])(.*)\1$/, "$2").trim();
613
+ return value === "" ? void 0 : value;
614
+ }
615
+ function assertNotReserved(name) {
616
+ if (isReservedDirName(name)) throw usage(`Reserved directory name: ${name}`, "The names `synced` and `anthropic-skills` are reserved for agent-owned skill stores.");
617
+ }
618
+ function planInstall(cwd, env, destName, agents, options) {
619
+ if (options.dir) {
620
+ const base = resolve(cwd, options.dir);
621
+ return {
622
+ canonical: null,
623
+ targets: [{
624
+ agent: null,
625
+ path: join(base, destName),
626
+ mode: "copy"
627
+ }]
628
+ };
629
+ }
630
+ const targets = [];
631
+ const seen = /* @__PURE__ */ new Set();
632
+ for (const agent of agents) {
633
+ if (!options.copy && !agent.link) continue;
634
+ const base = options.copy ? targetSkillsDir(agent, cwd, env, options.global) : agentSkillsDir(agent, cwd, env, options.global);
635
+ if (!base) continue;
636
+ const path = join(base, destName);
637
+ if (seen.has(path)) continue;
638
+ seen.add(path);
639
+ targets.push({
640
+ agent: agent.id,
641
+ path,
642
+ mode: options.copy ? "copy" : "symlink"
643
+ });
644
+ }
645
+ if (options.copy) {
646
+ if (targets.length === 0) throw usage("No copy target selected", "Pass --agent claude-code or --all.");
647
+ return {
648
+ canonical: null,
649
+ targets
650
+ };
651
+ }
652
+ return {
653
+ canonical: join(canonicalSkillsDir(cwd, env, options.global), destName),
654
+ targets
655
+ };
656
+ }
657
+ async function install(ctx, plan, entries) {
658
+ const cwd = process.cwd();
659
+ const display = (path) => displayPath(path, cwd);
660
+ const destinations = [plan.canonical, ...plan.targets.map((target) => target.path)].filter((path) => path !== null);
661
+ if (!ctx.flags.force) {
662
+ for (const path of destinations) if (await pathExists(path)) throw targetExistsError(display(path));
663
+ }
664
+ if (plan.canonical) await installAtomically(plan.canonical, entries, ctx.flags.force);
665
+ const installed = [];
666
+ let failure = null;
667
+ for (const target of plan.targets) try {
668
+ if (target.mode === "copy" || !plan.canonical) {
669
+ await copyAtomically(target.path, entries, ctx.flags.force);
670
+ installed.push({
671
+ agent: target.agent,
672
+ path: display(target.path),
673
+ mode: "copy",
674
+ status: "installed"
675
+ });
676
+ continue;
677
+ }
678
+ const result = await linkAtomically(plan.canonical, target.path, entries, ctx.flags.force);
679
+ if (result.warning) ctx.summary(result.warning);
680
+ installed.push({
681
+ agent: target.agent,
682
+ path: display(target.path),
683
+ mode: result.mode,
684
+ status: "installed"
685
+ });
686
+ } catch (err) {
687
+ failure ??= err;
688
+ installed.push({
689
+ agent: target.agent,
690
+ path: display(target.path),
691
+ mode: target.mode,
692
+ status: "failed"
693
+ });
694
+ }
695
+ return {
696
+ targets: installed,
697
+ failure
698
+ };
699
+ }
700
+ async function addOne(ctx, connection, parsed, destName, agents, dir, global, copy, state) {
701
+ const capability = await resolveSkill(connection, parsed);
702
+ const version = resolveVersion(capability, parsed);
703
+ state.version = version;
704
+ const plan = planInstall(process.cwd(), ctx.env, destName, agents, {
705
+ dir,
706
+ global,
707
+ copy
708
+ });
709
+ const canonicalPath = plan.canonical ? displayPath(plan.canonical, process.cwd()) : null;
710
+ if (ctx.flags.dryRun) {
711
+ if (!ctx.flags.force) {
712
+ for (const path of [plan.canonical, ...plan.targets.map((target) => target.path)]) if (path && await pathExists(path)) throw targetExistsError(displayPath(path, process.cwd()));
713
+ }
714
+ ctx.summary(`resolved ${parsed.namespace}/${parsed.name} to version ${version}`);
715
+ return { result: {
716
+ name: capability.name,
717
+ namespace: capability.namespace,
718
+ version,
719
+ status: "dry-run",
720
+ canonicalPath,
721
+ targets: plan.targets.map((target) => ({
722
+ agent: target.agent,
723
+ path: displayPath(target.path, process.cwd()),
724
+ mode: target.mode,
725
+ status: "dry-run"
726
+ })),
727
+ error: null
728
+ } };
729
+ }
730
+ const download = await downloadSkillPackage(connection, capability.id, version);
731
+ const pkg = readSkillPackage(download.bytes);
732
+ ctx.summary(`resolved ${parsed.namespace}/${parsed.name} to version ${version}`);
733
+ const declaredName = frontmatterName(pkg.manifest);
734
+ if (declaredName !== void 0 && declaredName !== destName) ctx.summary(`warning: SKILL.md declares name ${JSON.stringify(declaredName)} but the directory is ${JSON.stringify(destName)}`);
735
+ const outcome = await install(ctx, plan, pkg.entries);
736
+ const universal = agents.filter((agent) => !agent.link).map((agent) => agent.id);
737
+ const etag = download.etag ? ` (ETag ${download.etag})` : "";
738
+ const verb = outcome.failure ? "partially installed" : "installed";
739
+ ctx.progress(`${verb} ${capability.namespace}/${capability.name}@${version}${etag}`);
740
+ if (canonicalPath) ctx.progress(` canonical ${canonicalPath}`);
741
+ if (universal.length > 0) ctx.progress(` universal: ${universal.join(", ")}`);
742
+ for (const target of outcome.targets) ctx.progress(target.status === "failed" ? ` failed ${target.path}` : ` ${target.mode} ${target.path}`);
743
+ return { result: {
744
+ name: capability.name,
745
+ namespace: capability.namespace,
746
+ version,
747
+ status: outcome.failure ? "failed" : "installed",
748
+ canonicalPath,
749
+ targets: outcome.targets,
750
+ error: outcome.failure ? errorBody(outcome.failure) : null
751
+ } };
752
+ }
753
+ const CAPABILITIES_PATH = "/api/v1/capabilities";
754
+ function packagePart(pkg, name) {
755
+ return {
756
+ name: "package",
757
+ filename: `${name}.zip`,
758
+ contentType: "application/zip",
759
+ data: pkg.zip
760
+ };
761
+ }
762
+ /** Create and version forms share fields; the server requires package last. */
763
+ function skillPrepared(pkg, metadata) {
764
+ const createParts = () => {
765
+ const parts = [
766
+ {
767
+ name: "namespace",
768
+ value: metadata.namespace
769
+ },
770
+ {
771
+ name: "name",
772
+ value: metadata.name
773
+ },
774
+ {
775
+ name: "description",
776
+ value: metadata.description ?? ""
777
+ },
778
+ {
779
+ name: "visibility",
780
+ value: metadata.visibility
781
+ }
782
+ ];
783
+ if (metadata.readme !== void 0) parts.push({
784
+ name: "readme",
785
+ value: metadata.readme
786
+ });
787
+ if (metadata.tags.length > 0) parts.push({
788
+ name: "tags",
789
+ value: metadata.tags.join(",")
790
+ });
791
+ parts.push(packagePart(pkg, metadata.name));
792
+ return parts;
793
+ };
794
+ const versionParts = () => {
795
+ const parts = [];
796
+ if (metadata.readme !== void 0) parts.push({
797
+ name: "readme",
798
+ value: metadata.readme
799
+ });
800
+ parts.push(packagePart(pkg, metadata.name));
801
+ return parts;
802
+ };
803
+ return {
804
+ preview() {
805
+ const preview = {
806
+ namespace: metadata.namespace,
807
+ name: metadata.name
808
+ };
809
+ if (metadata.description !== void 0) preview.description = metadata.description;
810
+ preview.visibility = metadata.visibility;
811
+ if (metadata.readme !== void 0) preview.readme = metadata.readme;
812
+ if (metadata.tags.length > 0) preview.tags = metadata.tags.join(",");
813
+ preview.package = `<${pkg.fileCount} files, ${pkg.byteCount} bytes>`;
814
+ return preview;
815
+ },
816
+ async create(connection) {
817
+ return (await postMultipart(connection, CAPABILITIES_PATH, createParts())).json();
818
+ },
819
+ async version(connection, capabilityId) {
820
+ return (await postMultipart(connection, `${CAPABILITIES_PATH}/${encodeURIComponent(capabilityId)}/versions`, versionParts())).json();
821
+ }
822
+ };
823
+ }
824
+ /**
825
+ * Publishes local Skill directories. Each directory is validated and packed in
826
+ * memory; an existing capability receives a new version unless --new is set.
827
+ */
828
+ async function publish(ctx, argv) {
829
+ const rawPaths = positionalPaths(argv.paths);
830
+ if (rawPaths.length === 0) throw usage("No skill directory given", "Pass one or more local directories that contain SKILL.md.");
831
+ const flags = await readPublishFlags(argv, "skill");
832
+ const forcedName = explicitName(argv, rawPaths.length);
833
+ const items = rawPaths.map((dir) => ({
834
+ name: forcedName ?? basename(resolve(dir)),
835
+ prepare: async (metadata) => skillPrepared(await packageSkillDirectory(dir), metadata)
836
+ }));
837
+ return runPublish({
838
+ ctx,
839
+ kind: "skill",
840
+ info: SKILL_KIND,
841
+ ...flags,
842
+ items
843
+ });
844
+ }
845
+ async function add(ctx, argv) {
846
+ const rawPaths = positionalPaths(argv.paths);
847
+ if (rawPaths.length === 0) throw usage("No skill path given", "Pass one or more `<name>` or `<namespace>/<name>` arguments.");
848
+ const parsedPaths = rawPaths.map(parseSkillPath);
849
+ const dir = stringFlag(argv, "dir");
850
+ const as = stringFlag(argv, "as");
851
+ if (as !== void 0 && (as.includes("/") || as.includes("\\") || as === "." || as === "..")) throw usage(`Invalid --as name ${JSON.stringify(as)}`, "Pass a single directory name.");
852
+ const global = argv.global === true;
853
+ const copy = argv.copy === true;
854
+ if (dir !== void 0 && global) throw usage("--dir cannot be combined with --global");
855
+ if (dir !== void 0 && (stringValues(argv.agent).length > 0 || argv.all === true)) throw usage("--dir cannot be combined with --agent or --all");
856
+ if (as !== void 0) assertNotReserved(as);
857
+ for (const parsed of parsedPaths) assertNotReserved(as ?? parsed.name);
858
+ const agents = dir === void 0 ? await selectAgents(ctx, argv, rawPaths[0] ?? "", global) : [];
859
+ const connection = await resolveConnection(ctx.flags, ctx.env);
860
+ const { results, failed } = await runBatch({
861
+ ctx,
862
+ paths: parsedPaths,
863
+ run: async (parsed, state) => {
864
+ return (await addOne(ctx, connection, parsed, as ?? parsed.name, agents, dir, global, copy, state)).result;
865
+ },
866
+ failure: failureResult
867
+ });
868
+ ctx.writePayload({ results });
869
+ return failed ? 1 : 0;
870
+ }
871
+ export { add, publish };