@ahood/cli 0.2.2 → 0.2.3

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.
@@ -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
  }
@@ -4,7 +4,7 @@ import { pack } from "tar-stream";
4
4
  import { createGzip } from "node:zlib";
5
5
  import { apiJson, ApiError } 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
@@ -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
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,6 +23,7 @@ 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.",
@@ -25,22 +32,31 @@ export const COMMANDS_HELP = [
25
32
  },
26
33
  {
27
34
  usage: "ahood list-mine [--json]",
35
+ summary: "List your own skills, public and private.",
28
36
  desc: "List your own skills, public and private.",
29
37
  flags: ["--json Emit the raw skill objects instead of formatted lines."],
30
38
  },
31
39
  {
32
40
  usage: "ahood add <owner>/<skill>[@version]",
41
+ summary: "Install a skill into .claude/skills/, pinned in the lockfile.",
33
42
  desc: "Install a skill into .claude/skills/, pinned in the lockfile.",
34
43
  examples: ["ahood add alice/pdf-tools", "ahood add alice/pdf-tools@1.2.0"],
35
44
  },
36
45
  {
37
46
  usage: "ahood update [<owner>/<skill> ...]",
47
+ summary: "Move the lockfile pin(s) forward to the latest version, for one skill or all installed skills at once.",
38
48
  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.",
39
49
  },
40
- { usage: "ahood remove <owner>/<skill>", desc: "Uninstall and unpin (local only)." },
50
+ {
51
+ usage: "ahood remove <owner>/<skill>",
52
+ summary: "Uninstall and unpin a skill (local only).",
53
+ desc: "Uninstall and unpin (local only).",
54
+ },
41
55
  {
42
56
  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.",
57
+ summary: "Update a skill you own, changing only the flags you pass.",
58
+ desc: "Update a skill you own. Only the flags you pass are changed. Every flag also accepts " +
59
+ "the --flag=value form (e.g. --tagline=--fast and cheap), needed when a value itself starts with --.",
44
60
  flags: [
45
61
  "--tagline <text> Short one-line description.",
46
62
  "--tags <comma,separated> Replaces the skill's tag list.",
@@ -53,17 +69,24 @@ export const COMMANDS_HELP = [
53
69
  },
54
70
  {
55
71
  usage: "ahood unpublish <owner>/<skill> [--yes]",
72
+ summary: "Delete a skill from the registry for every consumer, not just your local install (prompts for confirmation unless --yes is passed).",
56
73
  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
74
  flags: ["--yes Skip the interactive confirmation, for scripts/CI."],
58
75
  },
59
- { usage: "ahood star <owner>/<skill>", desc: "Star a skill." },
60
- { usage: "ahood unstar <owner>/<skill>", desc: "Remove your star from a skill." },
76
+ { usage: "ahood star <owner>/<skill>", summary: "Star a skill.", desc: "Star a skill." },
77
+ {
78
+ usage: "ahood unstar <owner>/<skill>",
79
+ summary: "Remove your star from a skill.",
80
+ desc: "Remove your star from a skill.",
81
+ },
61
82
  {
62
83
  usage: "ahood publish <owner>/<skill>@<version> [--path <dir>] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>]",
84
+ 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
85
  desc: "Publish a new version of a skill from a folder containing SKILL.md. If the skill doesn't exist yet, this " +
64
86
  "creates it first -- pass --name (required in that case) and optionally --tagline/--tags/--license/--homepage/--repository. " +
65
87
  "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.",
88
+ "The legacy form `ahood publish <path> --owner <owner> --slug <skill> --version <x.y.z>` is still accepted. " +
89
+ "Every flag also accepts the --flag=value form, needed when a value itself starts with --.",
67
90
  flags: [
68
91
  "--name <text> Required only when creating the skill on this publish.",
69
92
  "--tagline <text> Short one-line description, used only when creating.",
@@ -80,20 +103,38 @@ export const COMMANDS_HELP = [
80
103
  },
81
104
  {
82
105
  usage: "ahood token create <name>|list [--json]|revoke <id>",
106
+ summary: "Manage personal API tokens.",
83
107
  desc: "Manage personal API tokens. `create` requires an existing browser-backed session.",
84
108
  examples: ["ahood token create ci-runner", "ahood token list --json", "ahood token revoke <id>"],
85
109
  },
86
110
  {
87
111
  usage: "ahood completion <bash|zsh|fish>",
112
+ summary: "Print a shell completion script for the command names.",
88
113
  desc: "Print a shell completion script for the command names.",
89
114
  examples: ["ahood completion bash >> ~/.bashrc"],
90
115
  },
91
116
  ];
92
- const COMMAND_ALIASES = { show: "view" };
117
+ // Exported so other consumers of the command list (e.g. shell completion) can
118
+ // surface aliases without needing their own copy of this map.
119
+ export const COMMAND_ALIASES = { show: "view" };
93
120
  export function findCommandHelp(command) {
94
121
  const resolved = COMMAND_ALIASES[command] ?? command;
95
122
  return COMMANDS_HELP.find((c) => c.usage.startsWith(`ahood ${resolved} `) || c.usage === `ahood ${resolved}`);
96
123
  }
124
+ // Renders a COMMANDS_HELP entry's usage line with any known aliases folded in
125
+ // after the primary command name, e.g. "ahood view <owner>/<skill> ..." ->
126
+ // "ahood view|show <owner>/<skill> ...". Used by the top-level --help listing
127
+ // so aliases (like "show" for "view") aren't invisible to users who only
128
+ // skim `ahood --help`.
129
+ export function usageWithAliases(entry) {
130
+ const parts = entry.usage.split(" ");
131
+ const name = parts[1];
132
+ const aliases = Object.keys(COMMAND_ALIASES).filter((alias) => COMMAND_ALIASES[alias] === name);
133
+ if (aliases.length === 0)
134
+ return entry.usage;
135
+ parts[1] = [name, ...aliases].join("|");
136
+ return parts.join(" ");
137
+ }
97
138
  export function formatCommandHelp(entry) {
98
139
  const lines = [entry.usage, "", entry.desc];
99
140
  if (entry.flags?.length)
@@ -103,8 +144,8 @@ export function formatCommandHelp(entry) {
103
144
  return lines.join("\n");
104
145
  }
105
146
  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(/\.$/, "")}.`);
147
+ const width = Math.max(...COMMANDS_HELP.map((c) => usageWithAliases(c).length));
148
+ const lines = COMMANDS_HELP.map((c) => ` ${usageWithAliases(c).padEnd(width + 2)}${c.summary}`);
108
149
  return [
109
150
  "ahood -- CLI for the ahood skills registry (https://ahood.vercel.app)",
110
151
  "",
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.2.3",
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",