@ahood/cli 0.2.2 → 0.3.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/README.md CHANGED
@@ -42,12 +42,13 @@ For CI or any non-interactive environment, set `AHOOD_TOKEN` instead of running
42
42
  | `ahood view <owner>/<skill> [--json] [--web]` (alias: `show`) | Show a single skill's details -- tags, license, homepage, repository, dates, etc. -- without installing it, or open its page in a browser. |
43
43
  | `ahood list-mine [--json]` | List your own skills, public and private, with their visibility and download/star counts. |
44
44
  | `ahood add <owner>/<skill>[@version]` | Install into `.claude/skills/<owner>/<skill>/`, pinned in `.claude/skills.lock.json`. |
45
- | `ahood update [<owner>/<skill> ...]` | Move the lockfile pin(s) forward to the latest version. One skill failing to update doesn't stop the rest. |
45
+ | `ahood update [<owner>/<skill> ...] [--dry-run] [--json]` | Move the lockfile pin(s) forward to the latest version. One skill failing to update doesn't stop the rest. `--dry-run` previews current vs. latest version (plus the changelog for anything behind) without installing anything; add `--json` for structured output. |
46
46
  | `ahood remove <owner>/<skill>` | Uninstall and unpin (local only -- does not affect the published skill). |
47
47
  | `ahood edit <owner>/<skill> [--tagline] [--tags] [--license] [--visibility] [--homepage] [--repository]` | Update a skill you own. Only the flags you pass are changed; requires the `publish` scope. |
48
48
  | `ahood unpublish <owner>/<skill> [--yes]` | Deletes the skill from the registry for every consumer, not just your local install -- prompts for a typed "yes" confirmation, or pass `--yes` for scripts/CI. |
49
49
  | `ahood star <owner>/<skill>` | Star a skill. |
50
50
  | `ahood unstar <owner>/<skill>` | Remove your star from a skill. |
51
+ | `ahood init [name]` | Scaffold a new skill folder with a minimal, valid `SKILL.md` -- `./<name>/SKILL.md` if a name is given, `./SKILL.md` otherwise. Refuses to overwrite an existing `SKILL.md`. |
51
52
  | `ahood publish <owner>/<skill>@<version> [--path <dir>] [--name] [--tagline] [--tags] [--license] [--homepage] [--repository]` | Publish a new version of a skill from a folder containing `SKILL.md` (defaults to the current directory). If the skill doesn't exist yet, creates it first -- `--name` is required in that case; `--tagline`/`--tags`/`--license`/`--homepage`/`--repository` are optional and only used at creation. Processing (validation, checksumming, secret scanning) happens server-side after upload; this command polls and reports the final published/failed status. The legacy form `ahood publish <path> --owner <owner> --slug <skill> --version <x.y.z>` is still accepted. |
52
53
  | `ahood token create <name>\|list [--json]\|revoke <id>` | Manage personal API tokens. Creating a token requires an existing logged-in session -- see the docs. |
53
54
  | `ahood completion <bash\|zsh\|fish>` | Print a shell completion script for the command names. |
@@ -22,7 +22,7 @@ const MAX_ENTRY_COUNT = 10_000;
22
22
  // version in this codebase), so the yanked-skill warning below only fires
23
23
  // for an explicit @version -- confirmed by reading both route.ts files
24
24
  // rather than assumed.
25
- async function fetchVersionMeta(owner, skill, version) {
25
+ export async function fetchVersionMeta(owner, skill, version) {
26
26
  if (version === "latest") {
27
27
  const { skill_versions } = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`);
28
28
  if (!skill_versions)
@@ -31,6 +31,39 @@ async function fetchVersionMeta(owner, skill, version) {
31
31
  }
32
32
  return apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions/${encodeURIComponent(version)}`);
33
33
  }
34
+ // Reads a fetch Response body while enforcing MAX_DOWNLOAD_BYTES AS bytes
35
+ // arrive, instead of buffering the whole thing via arrayBuffer() and checking
36
+ // afterwards (ahood-cli#37). A content-length header, when present and
37
+ // already over cap, lets us bail before reading a single byte; but the header
38
+ // is attacker/server-controlled and sometimes just absent (chunked transfer,
39
+ // a mutated/redirected presigned URL), so it's a fast-path optimization only
40
+ // -- the streaming check below is what actually bounds memory in every case.
41
+ async function readBoundedBody(res, maxBytes) {
42
+ const contentLength = Number(res.headers.get("content-length") ?? NaN);
43
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) {
44
+ await res.body?.cancel().catch(() => { });
45
+ throw new Error(`Download is ${contentLength} bytes, over the ${maxBytes / (1024 * 1024)} MB limit -- refusing to install.`);
46
+ }
47
+ if (!res.body)
48
+ return Buffer.alloc(0);
49
+ const chunks = [];
50
+ let total = 0;
51
+ // Throwing out of a `for await` loop over a ReadableStream runs the async
52
+ // iterator's implicit `return()`, which cancels the underlying stream and
53
+ // releases its reader lock -- no separate res.body.cancel() call needed
54
+ // (and calling one here would fail anyway: the stream is locked to this
55
+ // loop's internal reader). This is what actually stops reading mid-download
56
+ // rather than draining the rest of a huge/malicious body first.
57
+ for await (const chunk of res.body) {
58
+ const buf = Buffer.from(chunk);
59
+ total += buf.length;
60
+ if (total > maxBytes) {
61
+ throw new Error(`Downloaded archive is over the ${maxBytes / (1024 * 1024)} MB limit -- refusing to install.`);
62
+ }
63
+ chunks.push(buf);
64
+ }
65
+ return Buffer.concat(chunks);
66
+ }
34
67
  export async function extractTarGz(buffer, destDir) {
35
68
  let decompressed;
36
69
  try {
@@ -160,14 +193,7 @@ export async function add(args) {
160
193
  const body = await downloadRes.text().catch(() => "");
161
194
  throw new Error(`Download failed with status ${downloadRes.status}${body ? `: ${body}` : ""}`);
162
195
  }
163
- const contentLength = Number(downloadRes.headers.get("content-length") ?? NaN);
164
- if (Number.isFinite(contentLength) && contentLength > MAX_DOWNLOAD_BYTES) {
165
- throw new Error(`Download is ${contentLength} bytes, over the ${MAX_DOWNLOAD_BYTES / (1024 * 1024)} MB limit -- refusing to install.`);
166
- }
167
- const buffer = Buffer.from(await downloadRes.arrayBuffer());
168
- if (buffer.length > MAX_DOWNLOAD_BYTES) {
169
- throw new Error(`Downloaded archive is ${buffer.length} bytes, over the ${MAX_DOWNLOAD_BYTES / (1024 * 1024)} MB limit -- refusing to install.`);
170
- }
196
+ const buffer = await readBoundedBody(downloadRes, MAX_DOWNLOAD_BYTES);
171
197
  const actualChecksum = createHash("sha256").update(buffer).digest("hex");
172
198
  if (actualChecksum !== meta.checksum_sha256) {
173
199
  throw new Error(`Checksum mismatch for ${key}@${meta.version}: expected ${meta.checksum_sha256}, got ${actualChecksum}. Refusing to install.`);
@@ -1,7 +1,11 @@
1
- import { COMMANDS_HELP } from "../help.js";
1
+ import { COMMANDS_HELP, COMMAND_ALIASES } from "../help.js";
2
2
  const USAGE = "Usage: ahood completion <bash|zsh|fish>";
3
+ // Includes alias names (e.g. "show" for "view") alongside the primary
4
+ // command names, so shell completion offers every name a user might type,
5
+ // not just the ones with their own COMMANDS_HELP entry.
3
6
  function commandNames() {
4
- return COMMANDS_HELP.map((c) => c.usage.split(" ")[1]);
7
+ const primary = COMMANDS_HELP.map((c) => c.usage.split(" ")[1]);
8
+ return [...primary, ...Object.keys(COMMAND_ALIASES)];
5
9
  }
6
10
  function bashCompletion() {
7
11
  const names = commandNames().join(" ");
@@ -1,6 +1,6 @@
1
1
  import { apiJson } from "../http.js";
2
2
  import { flagValue } from "../flags.js";
3
- import { parseOwnerSkill } from "../spec.js";
3
+ import { parseOwnerSkill, validateExternalUrl } from "../spec.js";
4
4
  const USAGE = "Usage: ahood edit <owner>/<skill> [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--visibility public|private] [--homepage <url>] [--repository <url>]";
5
5
  // Mirrors PATCH /api/v1/skills/{owner}/{skill}'s allow-list exactly
6
6
  // (lib/skills/mutations.ts's UpdateSkillInput) -- only a field the caller
@@ -30,11 +30,15 @@ export async function edit(args) {
30
30
  body.visibility = visibility;
31
31
  }
32
32
  const homepage = flagValue(args, "--homepage");
33
- if (homepage !== undefined)
33
+ if (homepage !== undefined) {
34
+ validateExternalUrl(homepage, "--homepage");
34
35
  body.homepage = homepage;
36
+ }
35
37
  const repository = flagValue(args, "--repository");
36
- if (repository !== undefined)
38
+ if (repository !== undefined) {
39
+ validateExternalUrl(repository, "--repository");
37
40
  body.repository = repository;
41
+ }
38
42
  if (Object.keys(body).length === 0) {
39
43
  throw new Error("Nothing to update -- pass at least one of --tagline, --tags, --license, --visibility, --homepage, --repository.");
40
44
  }
@@ -0,0 +1,56 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { basename, join, resolve } from "node:path";
3
+ const USAGE = "Usage: ahood init [name]";
4
+ // publish.ts's only client-side check on SKILL.md is that the file exists
5
+ // (see publish.ts's `existsSync(skillMdPath)`) -- actual frontmatter
6
+ // validation happens server-side, after upload, as part of the processing
7
+ // workflow described in publish.ts's POLL_INTERVAL_MS comment. There's no
8
+ // parser to match locally, so this template instead follows the documented
9
+ // Claude Code skill format: YAML frontmatter delimited by `---` lines, with
10
+ // a `name` and a `description` field -- the same shape every real SKILL.md
11
+ // in this ecosystem uses (see e.g. any installed skill under
12
+ // .claude/skills/<owner>/<skill>/SKILL.md). Keeping to exactly those two
13
+ // fields, rather than inventing extra ones, means nothing here needs
14
+ // updating just because publish.ts's own validation happens to be looser
15
+ // today.
16
+ function buildSkillMd(name) {
17
+ return `---
18
+ # name: a short, unique identifier for this skill (kebab-case is
19
+ # conventional, e.g. "pdf-tools"). Not the same as the <skill> slug you
20
+ # publish under -- ahood publish reads that from the command line -- but
21
+ # keeping them in sync avoids confusion.
22
+ name: ${name}
23
+ # description: one or two sentences describing what this skill does and
24
+ # when Claude should use it. This is the primary text Claude reads to
25
+ # decide whether to invoke the skill, so be specific rather than generic.
26
+ description: TODO -- describe what this skill does and when Claude should use it.
27
+ ---
28
+
29
+ ## Instructions
30
+
31
+ TODO: describe, step by step, what Claude should do when this skill is invoked.
32
+ `;
33
+ }
34
+ // Falls back to "my-skill" when the directory name itself isn't usable as a
35
+ // bare YAML scalar (empty, or starting with a character like "-" or "@" that
36
+ // would need quoting) -- e.g. running `ahood init` with no name directly in
37
+ // "/" (basename "") or in a directory whose name starts with punctuation.
38
+ function skillNameFor(dirPath) {
39
+ const base = basename(dirPath);
40
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(base) ? base : "my-skill";
41
+ }
42
+ export async function init(args) {
43
+ const name = args[0];
44
+ if (name !== undefined && name.startsWith("-"))
45
+ throw new Error(USAGE);
46
+ const targetDir = name ? resolve(name) : process.cwd();
47
+ const skillMdPath = join(targetDir, "SKILL.md");
48
+ if (existsSync(skillMdPath)) {
49
+ throw new Error(`SKILL.md already exists at ${skillMdPath} -- refusing to overwrite it.`);
50
+ }
51
+ mkdirSync(targetDir, { recursive: true });
52
+ writeFileSync(skillMdPath, buildSkillMd(name ?? skillNameFor(targetDir)));
53
+ console.log(`Created ${skillMdPath}`);
54
+ console.log("Fill in the description, then flesh out the ## Instructions section.");
55
+ console.log(`Run \`ahood publish <owner>/<skill>@<version>${name ? ` --path ${name}` : ""}\` when ready.`);
56
+ }
@@ -2,9 +2,9 @@ import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { pack } from "tar-stream";
4
4
  import { createGzip } from "node:zlib";
5
- import { apiJson, ApiError } from "../http.js";
5
+ import { apiJson, ApiError, sanitizeErrorMessage } from "../http.js";
6
6
  import { flagValue } from "../flags.js";
7
- import { parseOwnerSkill, SEMVER_RE } from "../spec.js";
7
+ import { parseOwnerSkill, SEMVER_RE, validateExternalUrl } from "../spec.js";
8
8
  // Processing (decompress/validate/checksum/scan/upload) now happens in a
9
9
  // background workflow, not inline in versions/complete's response (server
10
10
  // issue #120) -- complete returns 202 immediately, so this polls
@@ -26,8 +26,8 @@ async function pollVersionStatus(owner, skill, version) {
26
26
  }
27
27
  throw new Error(`Still processing after ${POLL_TIMEOUT_MS / 1000}s -- check \`ahood view ${owner}/${skill}\` later for the result.`);
28
28
  }
29
- const USAGE = "Usage: ahood publish <owner>/<skill>@<version> [--path <dir>] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>]\n" +
30
- " or: ahood publish <path> --owner <owner> --slug <skill> --version <x.y.z>";
29
+ const USAGE = "Usage: ahood publish <owner>/<skill>@<version> [--path <dir>] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--json]\n" +
30
+ " or: ahood publish <path> --owner <owner> --slug <skill> --version <x.y.z> [--json]";
31
31
  // Matched by ENTRY NAME at every depth, not by path prefix, so a nested
32
32
  // `vendor/thing/.git` is skipped the same as a top-level one. Not a
33
33
  // .gitignore parser (out of scope) -- just the fixed set of names that must
@@ -129,6 +129,13 @@ function parsePublishArgs(args) {
129
129
  if (!SEMVER_RE.test(version)) {
130
130
  throw new Error(`--version must be a semver like 1.2.3 (got "${version}").\n${USAGE}`);
131
131
  }
132
+ // Validated here, before any network call (including the tar/gzip of the
133
+ // skill directory) -- these only ever reach the server via
134
+ // createSkillForPublish's create-skill body (ahood-cli#34).
135
+ if (homepage !== undefined)
136
+ validateExternalUrl(homepage, "--homepage");
137
+ if (repository !== undefined)
138
+ validateExternalUrl(repository, "--repository");
132
139
  return { owner, skill, version, path: pathFlag ?? legacyPath ?? ".", name, tagline, tags, license, homepage, repository };
133
140
  }
134
141
  // Creates the skill under the caller's own account when versions/init 404s
@@ -137,7 +144,7 @@ function parsePublishArgs(args) {
137
144
  // createSkill requires a name; --tagline/--tags/--license/--homepage/
138
145
  // --repository are optional and only used here, never applied to an
139
146
  // already-existing skill.
140
- async function createSkillForPublish(owner, skill, opts) {
147
+ async function createSkillForPublish(owner, skill, opts, jsonOutput) {
141
148
  if (!opts.name) {
142
149
  throw new Error(`${owner}/${skill} doesn't exist yet -- pass --name to create it as part of this publish (e.g. --name "My Skill").`);
143
150
  }
@@ -166,60 +173,101 @@ async function createSkillForPublish(owner, skill, opts) {
166
173
  `are always created under your own account (${created.owner}). Retry with: ` +
167
174
  `ahood publish ${created.owner}/${skill}@<version>.`);
168
175
  }
169
- console.log(`Created ${created.owner}/${created.slug} -- publishing its first version now.`);
176
+ if (!jsonOutput) {
177
+ console.log(`Created ${created.owner}/${created.slug} -- publishing its first version now.`);
178
+ }
179
+ return created;
170
180
  }
171
- async function initVersion(owner, skill, version, packageSizeBytes, createOpts) {
181
+ async function initVersion(owner, skill, version, packageSizeBytes, createOpts, jsonOutput) {
172
182
  const path = `/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions/init`;
173
183
  const body = JSON.stringify({ version, package_size_bytes: packageSizeBytes });
174
184
  try {
175
- return await apiJson(path, { method: "POST", headers: { "Content-Type": "application/json" }, body });
185
+ const init = await apiJson(path, { method: "POST", headers: { "Content-Type": "application/json" }, body });
186
+ return { init };
176
187
  }
177
188
  catch (error) {
178
189
  if (!(error instanceof ApiError) || error.status !== 404)
179
190
  throw error;
180
- await createSkillForPublish(owner, skill, createOpts);
191
+ const created = await createSkillForPublish(owner, skill, createOpts, jsonOutput);
181
192
  // Retry exactly once. If this ALSO 404s (e.g. a race, or the create
182
193
  // silently didn't take), let that error propagate rather than looping.
183
- return await apiJson(path, { method: "POST", headers: { "Content-Type": "application/json" }, body });
194
+ const init = await apiJson(path, { method: "POST", headers: { "Content-Type": "application/json" }, body });
195
+ return { init, created };
184
196
  }
185
197
  }
198
+ // --json mode (ahood-cli#57): a CI/script caller needs one machine-readable
199
+ // object on stdout, not the human progress lines below scraped with a
200
+ // regex. jsonOutput therefore gates every intermediate console.log in this
201
+ // flow, and the whole body is wrapped in a try/catch so ANY failure --
202
+ // validation, network, or a rejected publish -- still yields exactly one
203
+ // JSON object (`{error}`) rather than a human message, while still
204
+ // rethrowing so index.ts's exit-code mapping (exitCodeFor) is unaffected.
186
205
  export async function publish(args) {
187
- const { owner, skill, version, path, name, tagline, tags, license, homepage, repository } = parsePublishArgs(args);
188
- const skillMdPath = join(path, "SKILL.md");
189
- if (!existsSync(skillMdPath)) {
190
- throw new Error(`No SKILL.md found at ${skillMdPath} -- publish must point at a skill folder's root.`);
191
- }
192
- const archive = await tarGzDirectory(path);
193
- const init = await initVersion(owner, skill, version, archive.length, { name, tagline, tags, license, homepage, repository });
194
- // TS's lib.dom BodyInit (in scope here since tsconfig has no explicit
195
- // "lib" override, so DOM is included alongside the Node types) type-checks
196
- // Buffer/Uint8Array against `Uint8Array<ArrayBuffer>` specifically as of
197
- // TS 5.7+'s ArrayBufferLike generics, which a Node Buffer's
198
- // `Uint8Array<ArrayBufferLike>` doesn't structurally satisfy even though
199
- // it is a valid BufferSource at runtime (this is exactly what Node's own
200
- // fetch/undici accepts) -- see undici-types' BodyInit, which includes
201
- // `NodeJS.ArrayBufferView` (i.e. Buffer) directly with no such
202
- // restriction. Asserting through BodyInit here, rather than reshaping
203
- // tsconfig's "lib" for the whole package, keeps this fix local to the one
204
- // call site.
205
- const putRes = await fetch(init.upload_url, {
206
- method: "PUT",
207
- body: archive,
208
- signal: AbortSignal.timeout(120_000),
209
- });
210
- if (!putRes.ok) {
211
- const body = await putRes.text().catch(() => "");
212
- throw new Error(`Upload failed with status ${putRes.status}${body ? `: ${body}` : ""} (version_id: ${init.version_id}, retry with the same command once the underlying issue is fixed).`);
206
+ const jsonOutput = args.includes("--json");
207
+ try {
208
+ const { owner, skill, version, path, name, tagline, tags, license, homepage, repository } = parsePublishArgs(args);
209
+ const skillMdPath = join(path, "SKILL.md");
210
+ if (!existsSync(skillMdPath)) {
211
+ throw new Error(`No SKILL.md found at ${skillMdPath} -- publish must point at a skill folder's root.`);
212
+ }
213
+ const archive = await tarGzDirectory(path);
214
+ const { init, created } = await initVersion(owner, skill, version, archive.length, { name, tagline, tags, license, homepage, repository }, jsonOutput);
215
+ // TS's lib.dom BodyInit (in scope here since tsconfig has no explicit
216
+ // "lib" override, so DOM is included alongside the Node types) type-checks
217
+ // Buffer/Uint8Array against `Uint8Array<ArrayBuffer>` specifically as of
218
+ // TS 5.7+'s ArrayBufferLike generics, which a Node Buffer's
219
+ // `Uint8Array<ArrayBufferLike>` doesn't structurally satisfy even though
220
+ // it is a valid BufferSource at runtime (this is exactly what Node's own
221
+ // fetch/undici accepts) -- see undici-types' BodyInit, which includes
222
+ // `NodeJS.ArrayBufferView` (i.e. Buffer) directly with no such
223
+ // restriction. Asserting through BodyInit here, rather than reshaping
224
+ // tsconfig's "lib" for the whole package, keeps this fix local to the one
225
+ // call site.
226
+ const putRes = await fetch(init.upload_url, {
227
+ method: "PUT",
228
+ body: archive,
229
+ signal: AbortSignal.timeout(120_000),
230
+ });
231
+ if (!putRes.ok) {
232
+ // The signed URL points at Supabase Storage directly, a different host
233
+ // than the API, so this never goes through apiJson -- sanitize by hand
234
+ // here for the same reason apiJson does (ahood-cli#31/#50): a raw
235
+ // proxy/WAF error page must not be spliced verbatim into a thrown Error.
236
+ const rawBody = await putRes.text().catch(() => "");
237
+ const body = rawBody ? sanitizeErrorMessage(rawBody) : "";
238
+ throw new Error(`Upload failed with status ${putRes.status}${body ? `: ${body}` : ""} (version_id: ${init.version_id}, retry with the same command once the underlying issue is fixed).`);
239
+ }
240
+ await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions/complete`, {
241
+ method: "POST",
242
+ headers: { "Content-Type": "application/json" },
243
+ body: JSON.stringify({ version_id: init.version_id }),
244
+ });
245
+ if (!jsonOutput)
246
+ console.log(`Uploaded ${owner}/${skill}@${version} -- processing...`);
247
+ const result = await pollVersionStatus(owner, skill, version);
248
+ if (result.status === "failed") {
249
+ throw new Error(`Publish failed: ${result.failure_reason ?? "unknown reason"}`);
250
+ }
251
+ if (jsonOutput) {
252
+ const output = {
253
+ version: result.version,
254
+ status: result.status,
255
+ };
256
+ if (result.failure_reason)
257
+ output.failure_reason = result.failure_reason;
258
+ if (created)
259
+ output.created = created;
260
+ console.log(JSON.stringify(output));
261
+ }
262
+ else {
263
+ console.log(`Published ${owner}/${skill}@${result.version} (${result.status})`);
264
+ }
213
265
  }
214
- await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions/complete`, {
215
- method: "POST",
216
- headers: { "Content-Type": "application/json" },
217
- body: JSON.stringify({ version_id: init.version_id }),
218
- });
219
- console.log(`Uploaded ${owner}/${skill}@${version} -- processing...`);
220
- const result = await pollVersionStatus(owner, skill, version);
221
- if (result.status === "failed") {
222
- throw new Error(`Publish failed: ${result.failure_reason ?? "unknown reason"}`);
266
+ catch (error) {
267
+ if (jsonOutput) {
268
+ const message = error instanceof Error ? error.message : String(error);
269
+ console.log(JSON.stringify({ error: message }));
270
+ }
271
+ throw error;
223
272
  }
224
- console.log(`Published ${owner}/${skill}@${result.version} (${result.status})`);
225
273
  }
@@ -1,19 +1,92 @@
1
- import { LOCKFILE_PATH } from "../spec.js";
1
+ import { LOCKFILE_PATH, parseOwnerSkill } from "../spec.js";
2
2
  import { readLockfile } from "../lockfile.js";
3
- import { add } from "./add.js";
4
- // `update` is the only command that moves the lockfile pin forward -- `add`
5
- // always resolves and pins whatever version it's given (or latest, once,
6
- // at install time), matching the ADR's "reproducible by default" design.
3
+ import { add, fetchVersionMeta } from "./add.js";
4
+ const USAGE = "Usage: ahood update [<owner>/<skill> ...] [--dry-run] [--json]";
5
+ async function previewSkill(ownerSlashSkill, currentVersion) {
6
+ const { owner, skill } = parseOwnerSkill(ownerSlashSkill, USAGE);
7
+ const meta = await fetchVersionMeta(owner, skill, "latest");
8
+ const upToDate = currentVersion === meta.version;
9
+ return {
10
+ skill: ownerSlashSkill,
11
+ current_version: currentVersion,
12
+ latest_version: meta.version,
13
+ up_to_date: upToDate,
14
+ changelog_md: upToDate ? null : meta.changelog_md ?? null,
15
+ };
16
+ }
17
+ function padRow(cells, widths) {
18
+ return cells.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd();
19
+ }
20
+ // Prints a side-by-side current/latest table for every previewed skill, then
21
+ // -- for only the skills that actually have an update available -- the
22
+ // changelog for the version they'd move to. Nothing here touches disk or the
23
+ // network beyond the read-only resolution already done in previewSkill().
24
+ function printDryRunTable(previews) {
25
+ const header = ["SKILL", "CURRENT", "LATEST", "STATUS"];
26
+ const rows = previews.map((p) => [
27
+ p.skill,
28
+ p.current_version ?? "(not installed)",
29
+ p.latest_version,
30
+ p.up_to_date ? "up to date" : "update available",
31
+ ]);
32
+ const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
33
+ console.log(padRow(header, widths));
34
+ for (const row of rows)
35
+ console.log(padRow(row, widths));
36
+ const changed = previews.filter((p) => !p.up_to_date);
37
+ if (changed.length === 0) {
38
+ console.log("\nAll skills are already up to date.");
39
+ return;
40
+ }
41
+ for (const p of changed) {
42
+ console.log(`\n${p.skill}: ${p.current_version ?? "(not installed)"} -> ${p.latest_version}`);
43
+ console.log(p.changelog_md ? p.changelog_md : "(no changelog available)");
44
+ }
45
+ }
7
46
  export async function update(args) {
47
+ const dryRun = args.includes("--dry-run");
48
+ const jsonOutput = args.includes("--json");
49
+ if (jsonOutput && !dryRun) {
50
+ throw new Error(`--json is only supported together with --dry-run.\n${USAGE}`);
51
+ }
52
+ const targets = args.filter((a) => a !== "--dry-run" && a !== "--json");
8
53
  const lockfile = readLockfile(LOCKFILE_PATH);
9
- const targets = args.length > 0 ? args : Object.keys(lockfile);
10
- if (targets.length === 0) {
54
+ const skillKeys = targets.length > 0 ? targets : Object.keys(lockfile);
55
+ if (skillKeys.length === 0) {
11
56
  console.log("No installed skills to update.");
12
57
  return;
13
58
  }
59
+ if (dryRun) {
60
+ // Read-only preview: resolves "latest" for each skill via the same
61
+ // fetchVersionMeta() add() itself uses, but never calls add() -- so
62
+ // nothing is downloaded, extracted, or written to the lockfile.
63
+ const previews = [];
64
+ const failures = [];
65
+ for (const key of skillKeys) {
66
+ try {
67
+ previews.push(await previewSkill(key, lockfile[key]?.version ?? null));
68
+ }
69
+ catch (error) {
70
+ const message = error instanceof Error ? error.message : String(error);
71
+ console.error(`Failed to resolve latest version for ${key}: ${message}`);
72
+ failures.push(key);
73
+ }
74
+ }
75
+ if (jsonOutput) {
76
+ console.log(JSON.stringify(previews));
77
+ }
78
+ else if (previews.length > 0) {
79
+ printDryRunTable(previews);
80
+ }
81
+ if (failures.length > 0) {
82
+ console.error(`${failures.length} of ${skillKeys.length} skill(s) could not be resolved: ${failures.join(", ")}`);
83
+ process.exitCode = 1;
84
+ }
85
+ return;
86
+ }
14
87
  const failures = [];
15
- for (const ownerSlashSkill of targets) {
16
- if (!lockfile[ownerSlashSkill] && args.length === 0) {
88
+ for (const ownerSlashSkill of skillKeys) {
89
+ if (!lockfile[ownerSlashSkill] && targets.length === 0) {
17
90
  // Only reachable when targets came from the lockfile itself, so this
18
91
  // would mean the lockfile changed under us mid-loop -- skip, don't abort.
19
92
  console.warn(`Skipping ${ownerSlashSkill}: not currently installed.`);
@@ -31,7 +104,7 @@ export async function update(args) {
31
104
  }
32
105
  }
33
106
  if (failures.length > 0) {
34
- console.error(`${failures.length} of ${targets.length} skill(s) failed to update: ${failures.join(", ")}`);
107
+ console.error(`${failures.length} of ${skillKeys.length} skill(s) failed to update: ${failures.join(", ")}`);
35
108
  process.exitCode = 1;
36
109
  }
37
110
  }
@@ -0,0 +1,40 @@
1
+ import { apiJson } from "../http.js";
2
+ import { parseOwnerSkill } from "../spec.js";
3
+ const USAGE = "Usage: ahood versions <owner>/<skill> [--json]";
4
+ function formatSize(bytes) {
5
+ if (bytes < 1024)
6
+ return `${bytes} B`;
7
+ const units = ["KB", "MB", "GB", "TB"];
8
+ let value = bytes / 1024;
9
+ let unitIndex = 0;
10
+ while (value >= 1024 && unitIndex < units.length - 1) {
11
+ value /= 1024;
12
+ unitIndex++;
13
+ }
14
+ return `${value.toFixed(1)} ${units[unitIndex]}`;
15
+ }
16
+ export async function versions(args) {
17
+ const jsonOutput = args.includes("--json");
18
+ const spec = args.find((a) => !a.startsWith("--"));
19
+ if (!spec)
20
+ throw new Error(USAGE);
21
+ const { owner, skill } = parseOwnerSkill(spec, USAGE);
22
+ const { versions: list } = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions`);
23
+ if (jsonOutput) {
24
+ console.log(JSON.stringify(list));
25
+ return;
26
+ }
27
+ if (list.length === 0) {
28
+ console.log(`${owner}/${skill} has no published versions.`);
29
+ return;
30
+ }
31
+ console.log(`${owner}/${skill} -- ${list.length} published version${list.length === 1 ? "" : "s"}`);
32
+ const field = (label, value) => console.log(` ${label.padEnd(13)}${value}`);
33
+ for (const v of list) {
34
+ console.log("");
35
+ console.log(v.version);
36
+ field("published:", v.created_at);
37
+ field("size:", formatSize(v.package_size_bytes));
38
+ field("changelog:", v.changelog_md ? v.changelog_md : "-");
39
+ }
40
+ }
@@ -1,5 +1,19 @@
1
1
  import { ApiError, apiJson } from "../http.js";
2
2
  import { resolveToken } from "../credentials.js";
3
+ // Best-effort enrichment: whoami's real job is answering "does this token
4
+ // still authenticate?", which is already settled by the time this runs.
5
+ // A failure here (network blip, an otherwise-valid token hitting a 500 on
6
+ // this specific route, etc.) must never turn a successful auth check into a
7
+ // command failure -- so every error is swallowed and callers fall back to
8
+ // the plain "Authenticated..." message instead.
9
+ async function fetchProfile() {
10
+ try {
11
+ return await apiJson("/api/v1/profile");
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ }
3
17
  export async function whoami(args = []) {
4
18
  const wantsJson = args.includes("--json");
5
19
  const token = resolveToken();
@@ -30,17 +44,21 @@ export async function whoami(args = []) {
30
44
  await apiJson("/api/v1/auth/tokens");
31
45
  // A session-backed caller (only reachable if this ever runs against a
32
46
  // cookie-bearing client) -- the token list came back.
47
+ const profile = await fetchProfile();
33
48
  if (wantsJson)
34
- console.log(JSON.stringify({ authenticated: true, mode: "session" }));
49
+ console.log(JSON.stringify({ authenticated: true, mode: "session", ...profile }));
35
50
  else
36
- console.log("Authenticated.");
51
+ console.log(profile ? `Authenticated as ${profile.username}.` : "Authenticated.");
37
52
  }
38
53
  catch (error) {
39
54
  if (error instanceof ApiError && error.status === 403) {
55
+ const profile = await fetchProfile();
40
56
  if (wantsJson)
41
- console.log(JSON.stringify({ authenticated: true, mode: "token" }));
57
+ console.log(JSON.stringify({ authenticated: true, mode: "token", ...profile }));
42
58
  else
43
- console.log("Authenticated with a personal API token.");
59
+ console.log(profile
60
+ ? `Authenticated as ${profile.username} (personal API token).`
61
+ : "Authenticated with a personal API token.");
44
62
  return;
45
63
  }
46
64
  if (error instanceof ApiError && error.status === 401) {
package/dist/flags.js CHANGED
@@ -1,13 +1,29 @@
1
1
  // Shared flag-value parsing. Throws instead of silently treating the next flag
2
2
  // as this flag's value (e.g. `--tagline --visibility public` used to send
3
3
  // "--visibility" as the tagline and drop --visibility entirely).
4
+ //
5
+ // Two forms are accepted:
6
+ // --flag value Space-separated. If `value` itself looks like a flag
7
+ // (starts with "--"), that's treated as "no value given"
8
+ // per the swallow-protection above, and this throws.
9
+ // --flag=value Explicit-equals. Everything after the first "=" is the
10
+ // value, verbatim, even if it starts with "--" -- this is
11
+ // the unambiguous escape hatch for values that genuinely
12
+ // start with "--" (e.g. a tagline like "--fast and cheap").
4
13
  export function flagValue(args, flag) {
5
- const index = args.indexOf(flag);
6
- if (index < 0)
7
- return undefined;
8
- const value = args[index + 1];
9
- if (value === undefined || value.startsWith("--")) {
10
- throw new Error(`${flag} requires a value.`);
14
+ const prefix = `${flag}=`;
15
+ for (let i = 0; i < args.length; i++) {
16
+ const arg = args[i];
17
+ if (arg.startsWith(prefix)) {
18
+ return arg.slice(prefix.length);
19
+ }
20
+ if (arg === flag) {
21
+ const value = args[i + 1];
22
+ if (value === undefined || value.startsWith("--")) {
23
+ throw new Error(`${flag} requires a value.`);
24
+ }
25
+ return value;
26
+ }
11
27
  }
12
- return value;
28
+ return undefined;
13
29
  }
package/dist/help.js CHANGED
@@ -1,13 +1,19 @@
1
1
  export const COMMANDS_HELP = [
2
- { usage: "ahood login", desc: "Device-code browser login, stores a token locally." },
3
- { usage: "ahood logout", desc: "Removes the stored token." },
2
+ {
3
+ usage: "ahood login",
4
+ summary: "Device-code browser login, stores a token locally.",
5
+ desc: "Device-code browser login, stores a token locally.",
6
+ },
7
+ { usage: "ahood logout", summary: "Removes the stored token.", desc: "Removes the stored token." },
4
8
  {
5
9
  usage: "ahood whoami [--json]",
10
+ summary: "Reports whether your stored token still authenticates.",
6
11
  desc: "Reports whether your stored token still authenticates.",
7
12
  flags: ["--json Emit a machine-readable result instead of prose."],
8
13
  },
9
14
  {
10
15
  usage: "ahood search <query> [--json] [--limit <n>]",
16
+ summary: "Search published skills.",
11
17
  desc: "Search published skills.",
12
18
  flags: [
13
19
  "--json Emit the raw skill objects instead of formatted lines.",
@@ -17,30 +23,51 @@ export const COMMANDS_HELP = [
17
23
  },
18
24
  {
19
25
  usage: "ahood view <owner>/<skill> [--json] [--web]",
26
+ summary: "Show a single skill's details -- tags, license, homepage, repository, dates, and more -- without installing it (alias: ahood show).",
20
27
  desc: "Show a single skill's details (tags, license, homepage, repository, dates, etc.) without installing it. Alias: ahood show.",
21
28
  flags: [
22
29
  "--json Emit the raw skill object instead of formatted lines.",
23
30
  "--web Open the skill's page in your browser instead of printing.",
24
31
  ],
25
32
  },
33
+ {
34
+ usage: "ahood versions <owner>/<skill> [--json]",
35
+ summary: "List a skill's published-version history -- version, changelog, size, and publish date.",
36
+ desc: "List a skill's published-version history: version, changelog, size, and publish date. Most-recent first.",
37
+ flags: ["--json Emit the raw version objects instead of formatted text."],
38
+ },
26
39
  {
27
40
  usage: "ahood list-mine [--json]",
41
+ summary: "List your own skills, public and private.",
28
42
  desc: "List your own skills, public and private.",
29
43
  flags: ["--json Emit the raw skill objects instead of formatted lines."],
30
44
  },
31
45
  {
32
46
  usage: "ahood add <owner>/<skill>[@version]",
47
+ summary: "Install a skill into .claude/skills/, pinned in the lockfile.",
33
48
  desc: "Install a skill into .claude/skills/, pinned in the lockfile.",
34
49
  examples: ["ahood add alice/pdf-tools", "ahood add alice/pdf-tools@1.2.0"],
35
50
  },
36
51
  {
37
- usage: "ahood update [<owner>/<skill> ...]",
52
+ usage: "ahood update [<owner>/<skill> ...] [--dry-run] [--json]",
53
+ summary: "Move the lockfile pin(s) forward to the latest version, for one skill or all installed skills at once.",
38
54
  desc: "Move the lockfile pin(s) forward to the latest version. With no argument, updates every installed skill; one failure doesn't stop the rest.",
55
+ flags: [
56
+ "--dry-run Preview current vs. latest version (and the changelog for anything behind) without installing anything.",
57
+ "--json With --dry-run, emit structured preview objects instead of a formatted table.",
58
+ ],
59
+ examples: ["ahood update --dry-run", "ahood update alice/pdf-tools --dry-run --json"],
60
+ },
61
+ {
62
+ usage: "ahood remove <owner>/<skill>",
63
+ summary: "Uninstall and unpin a skill (local only).",
64
+ desc: "Uninstall and unpin (local only).",
39
65
  },
40
- { usage: "ahood remove <owner>/<skill>", desc: "Uninstall and unpin (local only)." },
41
66
  {
42
67
  usage: "ahood edit <owner>/<skill> [--tagline] [--tags] [--license] [--visibility] [--homepage] [--repository]",
43
- desc: "Update a skill you own. Only the flags you pass are changed.",
68
+ summary: "Update a skill you own, changing only the flags you pass.",
69
+ desc: "Update a skill you own. Only the flags you pass are changed. Every flag also accepts " +
70
+ "the --flag=value form (e.g. --tagline=--fast and cheap), needed when a value itself starts with --.",
44
71
  flags: [
45
72
  "--tagline <text> Short one-line description.",
46
73
  "--tags <comma,separated> Replaces the skill's tag list.",
@@ -53,17 +80,31 @@ export const COMMANDS_HELP = [
53
80
  },
54
81
  {
55
82
  usage: "ahood unpublish <owner>/<skill> [--yes]",
83
+ summary: "Delete a skill from the registry for every consumer, not just your local install (prompts for confirmation unless --yes is passed).",
56
84
  desc: "Delete a skill from the registry for every consumer (not just your local install). Prompts for a typed \"yes\" unless --yes is passed.",
57
85
  flags: ["--yes Skip the interactive confirmation, for scripts/CI."],
58
86
  },
59
- { usage: "ahood star <owner>/<skill>", desc: "Star a skill." },
60
- { usage: "ahood unstar <owner>/<skill>", desc: "Remove your star from a skill." },
87
+ { usage: "ahood star <owner>/<skill>", summary: "Star a skill.", desc: "Star a skill." },
88
+ {
89
+ usage: "ahood unstar <owner>/<skill>",
90
+ summary: "Remove your star from a skill.",
91
+ desc: "Remove your star from a skill.",
92
+ },
93
+ {
94
+ usage: "ahood init [name]",
95
+ summary: "Scaffold a new skill folder with a minimal, valid SKILL.md.",
96
+ desc: "Scaffold a new skill folder with a minimal, valid SKILL.md. Creates ./<name>/SKILL.md if a name is given, " +
97
+ "or ./SKILL.md in the current directory otherwise. Refuses to overwrite an existing SKILL.md at the target path.",
98
+ examples: ["ahood init pdf-tools", "ahood init"],
99
+ },
61
100
  {
62
101
  usage: "ahood publish <owner>/<skill>@<version> [--path <dir>] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>]",
102
+ summary: "Publish a new version of a skill from a folder containing SKILL.md, creating the skill first if it doesn't already exist.",
63
103
  desc: "Publish a new version of a skill from a folder containing SKILL.md. If the skill doesn't exist yet, this " +
64
104
  "creates it first -- pass --name (required in that case) and optionally --tagline/--tags/--license/--homepage/--repository. " +
65
105
  "Processing happens server-side after upload; this command polls and reports the final published/failed status. " +
66
- "The legacy form `ahood publish <path> --owner <owner> --slug <skill> --version <x.y.z>` is still accepted.",
106
+ "The legacy form `ahood publish <path> --owner <owner> --slug <skill> --version <x.y.z>` is still accepted. " +
107
+ "Every flag also accepts the --flag=value form, needed when a value itself starts with --.",
67
108
  flags: [
68
109
  "--name <text> Required only when creating the skill on this publish.",
69
110
  "--tagline <text> Short one-line description, used only when creating.",
@@ -80,20 +121,38 @@ export const COMMANDS_HELP = [
80
121
  },
81
122
  {
82
123
  usage: "ahood token create <name>|list [--json]|revoke <id>",
124
+ summary: "Manage personal API tokens.",
83
125
  desc: "Manage personal API tokens. `create` requires an existing browser-backed session.",
84
126
  examples: ["ahood token create ci-runner", "ahood token list --json", "ahood token revoke <id>"],
85
127
  },
86
128
  {
87
129
  usage: "ahood completion <bash|zsh|fish>",
130
+ summary: "Print a shell completion script for the command names.",
88
131
  desc: "Print a shell completion script for the command names.",
89
132
  examples: ["ahood completion bash >> ~/.bashrc"],
90
133
  },
91
134
  ];
92
- const COMMAND_ALIASES = { show: "view" };
135
+ // Exported so other consumers of the command list (e.g. shell completion) can
136
+ // surface aliases without needing their own copy of this map.
137
+ export const COMMAND_ALIASES = { show: "view" };
93
138
  export function findCommandHelp(command) {
94
139
  const resolved = COMMAND_ALIASES[command] ?? command;
95
140
  return COMMANDS_HELP.find((c) => c.usage.startsWith(`ahood ${resolved} `) || c.usage === `ahood ${resolved}`);
96
141
  }
142
+ // Renders a COMMANDS_HELP entry's usage line with any known aliases folded in
143
+ // after the primary command name, e.g. "ahood view <owner>/<skill> ..." ->
144
+ // "ahood view|show <owner>/<skill> ...". Used by the top-level --help listing
145
+ // so aliases (like "show" for "view") aren't invisible to users who only
146
+ // skim `ahood --help`.
147
+ export function usageWithAliases(entry) {
148
+ const parts = entry.usage.split(" ");
149
+ const name = parts[1];
150
+ const aliases = Object.keys(COMMAND_ALIASES).filter((alias) => COMMAND_ALIASES[alias] === name);
151
+ if (aliases.length === 0)
152
+ return entry.usage;
153
+ parts[1] = [name, ...aliases].join("|");
154
+ return parts.join(" ");
155
+ }
97
156
  export function formatCommandHelp(entry) {
98
157
  const lines = [entry.usage, "", entry.desc];
99
158
  if (entry.flags?.length)
@@ -103,8 +162,8 @@ export function formatCommandHelp(entry) {
103
162
  return lines.join("\n");
104
163
  }
105
164
  export function formatHelp() {
106
- const width = Math.max(...COMMANDS_HELP.map((c) => c.usage.length));
107
- const lines = COMMANDS_HELP.map((c) => ` ${c.usage.padEnd(width + 2)}${c.desc.split(". ")[0].replace(/\.$/, "")}.`);
165
+ const width = Math.max(...COMMANDS_HELP.map((c) => usageWithAliases(c).length));
166
+ const lines = COMMANDS_HELP.map((c) => ` ${usageWithAliases(c).padEnd(width + 2)}${c.summary}`);
108
167
  return [
109
168
  "ahood -- CLI for the ahood skills registry (https://ahood.vercel.app)",
110
169
  "",
package/dist/http.js CHANGED
@@ -43,7 +43,7 @@ const MAX_ERROR_MESSAGE_LENGTH = 500;
43
43
  // -- a regression on any endpoint would otherwise dump multi-KB infra details
44
44
  // straight to a user's terminal. Anything HTML-shaped or implausibly long for
45
45
  // a normal error string is replaced with a short, safe summary instead.
46
- function sanitizeErrorMessage(message) {
46
+ export function sanitizeErrorMessage(message) {
47
47
  const looksLikeHtml = /<!DOCTYPE|<html[\s>]/i.test(message);
48
48
  if (!looksLikeHtml && message.length <= MAX_ERROR_MESSAGE_LENGTH)
49
49
  return message;
package/dist/index.js CHANGED
@@ -13,7 +13,9 @@ import { unpublish } from "./commands/unpublish.js";
13
13
  import { listMine } from "./commands/list-mine.js";
14
14
  import { star, unstar } from "./commands/star.js";
15
15
  import { view } from "./commands/view.js";
16
+ import { versions } from "./commands/versions.js";
16
17
  import { completion } from "./commands/completion.js";
18
+ import { init } from "./commands/init.js";
17
19
  import { formatHelp, formatCommandHelp, findCommandHelp } from "./help.js";
18
20
  import { ApiError } from "./http.js";
19
21
  import { exitCodeFor } from "./exit-code.js";
@@ -27,6 +29,7 @@ const COMMANDS = {
27
29
  update,
28
30
  remove,
29
31
  publish,
32
+ init,
30
33
  token,
31
34
  edit,
32
35
  unpublish,
@@ -35,6 +38,7 @@ const COMMANDS = {
35
38
  unstar,
36
39
  view,
37
40
  show: view, // alias -- "ahood show" is the name issue #30 proposed; same command as "view"
41
+ versions,
38
42
  completion,
39
43
  };
40
44
  function levenshtein(a, b) {
package/dist/spec.js CHANGED
@@ -4,11 +4,25 @@ import { join } from "node:path";
4
4
  // "/", "\", or a leading dot -- this is what closes the path-traversal hole in add/remove
5
5
  // (a spec like "alice/.." previously resolved outside .claude/skills/ entirely).
6
6
  const SEGMENT_RE = /^[a-z0-9][a-z0-9._-]*$/i;
7
- export const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
7
+ export const SEMVER_RE = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
8
+ // The backend enforces these same caps on write (app/api/v1/profile/route.ts's
9
+ // MAX_USERNAME_LENGTH for owner usernames; lib/skills/mutations.ts's
10
+ // createSkill 3-64 check for skill slugs), so a segment over these lengths
11
+ // can never match a real owner/skill anyway. Rejecting it here -- before any
12
+ // request is built -- turns a validly-charset-formatted but absurdly long
13
+ // segment into a clean local error instead of a network round trip that, at
14
+ // a truly pathological length (thousands of characters), hits a raw infra/
15
+ // CDN-layer URL-length limit and 502s before the backend's own routing ever
16
+ // runs (ahood-cli#38).
17
+ const MAX_SEGMENT_LENGTH = { owner: 32, skill: 64 };
8
18
  export function validateSegment(value, kind, spec) {
9
19
  if (!SEGMENT_RE.test(value) || value === "." || value === "..") {
10
20
  throw new Error(`Invalid ${kind} "${value}" in "${spec}" -- must start with a letter/digit and contain only letters, digits, ".", "_", or "-".`);
11
21
  }
22
+ const maxLength = MAX_SEGMENT_LENGTH[kind];
23
+ if (value.length > maxLength) {
24
+ throw new Error(`Invalid ${kind} in "${spec.slice(0, 60)}${spec.length > 60 ? "..." : ""}" -- ${kind} segment is too long (${value.length} characters; must be at most ${maxLength}).`);
25
+ }
12
26
  }
13
27
  export function parseOwnerSkill(spec, usage) {
14
28
  if (!spec)
@@ -36,6 +50,29 @@ export function parseOwnerSkillVersion(spec, usage) {
36
50
  const { owner, skill } = parseOwnerSkill(ownerSkill, usage);
37
51
  return { owner, skill, version };
38
52
  }
53
+ // Applied to --homepage/--repository in edit and publish (ahood-cli#34):
54
+ // these are arbitrary user-supplied external links that the API stores
55
+ // verbatim and the web frontend renders as a raw <a href> -- a
56
+ // javascript:/data: value would be a stored-XSS vector the moment that
57
+ // page renders it. Rejecting anything but http(s) here, before any network
58
+ // call, mirrors config.ts's getApiUrl() validation pattern but with no
59
+ // localhost special-casing (there's no "trusted local dev" case for a
60
+ // public link). An empty string is allowed through un-validated so
61
+ // `edit --homepage ""` can still be used to clear a previously-set value.
62
+ export function validateExternalUrl(value, flag) {
63
+ if (value === "")
64
+ return;
65
+ let url;
66
+ try {
67
+ url = new URL(value);
68
+ }
69
+ catch {
70
+ throw new Error(`${flag} must be a valid http:// or https:// URL (got "${value}").`);
71
+ }
72
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
73
+ throw new Error(`${flag} must use http:// or https:// (got scheme "${url.protocol}" from "${value}").`);
74
+ }
75
+ }
39
76
  export const SKILLS_ROOT = join(".claude", "skills");
40
77
  export const LOCKFILE_PATH = join(".claude", "skills.lock.json");
41
78
  export function skillDir(owner, skill) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahood/cli",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "CLI for ahood, a registry for installing and publishing Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/AlexKay28/ahood-cli",