@ahood/cli 0.5.1 → 0.6.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.
@@ -5,6 +5,7 @@ import { createGzip } from "node:zlib";
5
5
  import { apiJson, ApiError, sanitizeErrorMessage } from "../http.js";
6
6
  import { flagValue } from "../flags.js";
7
7
  import { parseOwnerSkill, SEMVER_RE, validateExternalUrl } from "../spec.js";
8
+ import { UsageError } from "../usage-error.js";
8
9
  // Processing (decompress/validate/checksum/scan/upload) now happens in a
9
10
  // background workflow, not inline in versions/complete's response (server
10
11
  // issue #120) -- complete returns 202 immediately, so this polls
@@ -24,10 +25,10 @@ async function pollVersionStatus(owner, skill, version) {
24
25
  return result;
25
26
  await sleep(POLL_INTERVAL_MS);
26
27
  }
27
- throw new Error(`Still processing after ${POLL_TIMEOUT_MS / 1000}s -- check \`ahood view ${owner}/${skill}\` later for the result.`);
28
+ throw new Error(`Still processing after ${POLL_TIMEOUT_MS / 1000}s -- check \`ahood skill view ${owner}/${skill}\` later for the result.`);
28
29
  }
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]";
30
+ const USAGE = "Usage: ahood skill publish <owner>/<skill>@<version> [--path <dir>] [--kind skill|agent|mcp] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--json]\n" +
31
+ " or: ahood skill publish <path> --owner <owner> --slug <skill> --version <x.y.z> [--json]";
31
32
  // Matched by ENTRY NAME at every depth, not by path prefix, so a nested
32
33
  // `vendor/thing/.git` is skipped the same as a top-level one. Not a
33
34
  // .gitignore parser (out of scope) -- just the fixed set of names that must
@@ -109,25 +110,29 @@ function parsePublishArgs(args) {
109
110
  const license = flagValue(args, "--license");
110
111
  const homepage = flagValue(args, "--homepage");
111
112
  const repository = flagValue(args, "--repository");
113
+ const kind = flagValue(args, "--kind");
112
114
  let legacyPath;
113
115
  const first = args[0];
114
116
  if (first && !first.startsWith("--")) {
115
117
  if (first.includes("/") && first.includes("@")) {
116
- // Primary form: ahood publish <owner>/<skill>@<version>
118
+ // Primary form: ahood skill publish <owner>/<skill>@<version>
117
119
  const parsed = parseOwnerSkill(first.slice(0, first.lastIndexOf("@")), USAGE);
118
120
  owner = owner ?? parsed.owner;
119
121
  skill = skill ?? parsed.skill;
120
122
  version = version ?? first.slice(first.lastIndexOf("@") + 1);
121
123
  }
122
124
  else {
123
- // Legacy form: ahood publish <path> --owner ... --slug ... --version ...
125
+ // Legacy form: ahood skill publish <path> --owner ... --slug ... --version ...
124
126
  legacyPath = first;
125
127
  }
126
128
  }
127
129
  if (!owner || !skill || !version)
128
- throw new Error(USAGE);
130
+ throw new UsageError(USAGE);
129
131
  if (!SEMVER_RE.test(version)) {
130
- throw new Error(`--version must be a semver like 1.2.3 (got "${version}").\n${USAGE}`);
132
+ throw new UsageError(`--version must be a semver like 1.2.3 (got "${version}").\n${USAGE}`);
133
+ }
134
+ if (kind !== undefined && kind !== "skill" && kind !== "agent" && kind !== "mcp") {
135
+ throw new UsageError(`--kind must be "skill", "agent", or "mcp" (got "${kind}").\n${USAGE}`);
131
136
  }
132
137
  // Validated here, before any network call (including the tar/gzip of the
133
138
  // skill directory) -- these only ever reach the server via
@@ -136,7 +141,7 @@ function parsePublishArgs(args) {
136
141
  validateExternalUrl(homepage, "--homepage");
137
142
  if (repository !== undefined)
138
143
  validateExternalUrl(repository, "--repository");
139
- return { owner, skill, version, path: pathFlag ?? legacyPath ?? ".", name, tagline, tags, license, homepage, repository };
144
+ return { owner, skill, version, path: pathFlag ?? legacyPath ?? ".", name, tagline, tags, license, homepage, repository, kind };
140
145
  }
141
146
  // Creates the skill under the caller's own account when versions/init 404s
142
147
  // -- skill creation is CLI-only now (there is no separate `ahood create`;
@@ -159,6 +164,8 @@ async function createSkillForPublish(owner, skill, opts, jsonOutput) {
159
164
  body.homepage = opts.homepage;
160
165
  if (opts.repository !== undefined)
161
166
  body.repository = opts.repository;
167
+ if (opts.kind !== undefined)
168
+ body.kind = opts.kind;
162
169
  const created = await apiJson("/api/v1/skills", {
163
170
  method: "POST",
164
171
  headers: { "Content-Type": "application/json" },
@@ -171,7 +178,7 @@ async function createSkillForPublish(owner, skill, opts, jsonOutput) {
171
178
  if (created.owner !== owner) {
172
179
  throw new Error(`Created ${created.owner}/${created.slug}, but this publish targeted "${owner}/${skill}" -- skills ` +
173
180
  `are always created under your own account (${created.owner}). Retry with: ` +
174
- `ahood publish ${created.owner}/${skill}@<version>.`);
181
+ `ahood skill publish ${created.owner}/${skill}@<version>.`);
175
182
  }
176
183
  if (!jsonOutput) {
177
184
  console.log(`Created ${created.owner}/${created.slug} -- publishing its first version now.`);
@@ -205,13 +212,45 @@ async function initVersion(owner, skill, version, packageSizeBytes, createOpts,
205
212
  export async function publish(args) {
206
213
  const jsonOutput = args.includes("--json");
207
214
  try {
208
- const { owner, skill, version, path, name, tagline, tags, license, homepage, repository } = parsePublishArgs(args);
215
+ const { owner, skill, version, path, name, tagline, tags, license, homepage, repository, kind } = parsePublishArgs(args);
209
216
  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.`);
217
+ const agentMdPath = join(path, "AGENT.md");
218
+ const serverJsonPath = join(path, "server.json");
219
+ const hasSkillMd = existsSync(skillMdPath);
220
+ const hasAgentMd = existsSync(agentMdPath);
221
+ const hasServerJson = existsSync(serverJsonPath);
222
+ let resolvedKind = kind;
223
+ if (kind === "agent") {
224
+ if (!hasAgentMd)
225
+ throw new Error(`No AGENT.md found at ${agentMdPath} -- publish --kind agent must point at a folder containing AGENT.md.`);
226
+ }
227
+ else if (kind === "skill") {
228
+ if (!hasSkillMd)
229
+ throw new Error(`No SKILL.md found at ${skillMdPath} -- publish --kind skill must point at a folder containing SKILL.md.`);
230
+ }
231
+ else if (kind === "mcp") {
232
+ if (!hasServerJson)
233
+ throw new Error(`No server.json found at ${serverJsonPath} -- publish --kind mcp must point at a folder containing server.json.`);
234
+ }
235
+ else {
236
+ // kind === undefined -- infer from whichever root file(s) are actually present
237
+ const present = [hasSkillMd && "SKILL.md", hasAgentMd && "AGENT.md", hasServerJson && "server.json"].filter(Boolean);
238
+ if (present.length > 1) {
239
+ throw new Error(`Multiple root files found at ${path} (${present.join(", ")}) -- pass --kind skill, --kind agent, or --kind mcp to disambiguate.`);
240
+ }
241
+ if (present.length === 0) {
242
+ throw new Error(`No SKILL.md, AGENT.md, or server.json found at ${path} -- publish must point at a skill, agent, or mcp folder's root.`);
243
+ }
244
+ if (hasAgentMd) {
245
+ resolvedKind = "agent"; // inferred: only AGENT.md present, no --kind needed
246
+ }
247
+ else if (hasServerJson) {
248
+ resolvedKind = "mcp"; // inferred: only server.json present, no --kind needed
249
+ }
250
+ // else: hasSkillMd only -- resolvedKind stays undefined (unchanged existing behavior, server defaults to 'skill')
212
251
  }
213
252
  const archive = await tarGzDirectory(path);
214
- const { init, created } = await initVersion(owner, skill, version, archive.length, { name, tagline, tags, license, homepage, repository }, jsonOutput);
253
+ const { init, created } = await initVersion(owner, skill, version, archive.length, { name, tagline, tags, license, homepage, repository, kind: resolvedKind }, jsonOutput);
215
254
  // TS's lib.dom BodyInit (in scope here since tsconfig has no explicit
216
255
  // "lib" override, so DOM is included alongside the Node types) type-checks
217
256
  // Buffer/Uint8Array against `Uint8Array<ArrayBuffer>` specifically as of
@@ -1,19 +1,17 @@
1
1
  import { apiJson } from "../http.js";
2
2
  import { parseOwnerSkill } from "../spec.js";
3
+ import { UsageError } from "../usage-error.js";
3
4
  const USAGE = "Usage: ahood skill read <owner>/<skill> [--json]";
4
- export async function read(args) {
5
- const jsonOutput = args.includes("--json");
6
- const spec = args.find((a) => !a.startsWith("--"));
7
- if (!spec)
8
- throw new Error(USAGE);
9
- const { owner, skill } = parseOwnerSkill(spec, USAGE);
5
+ export async function readSkillMd(owner, skill) {
10
6
  const detail = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`);
11
7
  if (!detail.skill_versions) {
12
8
  throw new Error(`${owner}/${skill} has no published version.`);
13
9
  }
14
10
  // Mirrors view.ts's exact wording/tone for the same warning -- this
15
11
  // command reads straight from the same endpoint/version, so it must not
16
- // be silent about handing back a yanked version's content.
12
+ // be silent about handing back a yanked version's content. Written to
13
+ // stderr, not stdout -- safe for MCP callers too (only stdout is reserved
14
+ // for the JSON-RPC stream).
17
15
  if (detail.skill_versions.yanked_at) {
18
16
  console.warn(`WARNING: ${detail.owner}/${detail.slug}@${detail.skill_versions.version} has been yanked${detail.skill_versions.yanked_reason ? `: ${detail.skill_versions.yanked_reason}` : "."}`);
19
17
  }
@@ -21,8 +19,28 @@ export async function read(args) {
21
19
  if (!content) {
22
20
  throw new Error(`${owner}/${skill}@${detail.skill_versions.version} has no SKILL.md content available.`);
23
21
  }
22
+ return {
23
+ version: detail.skill_versions.version,
24
+ content,
25
+ // The CLI already warns about a yanked version on stderr above, but an
26
+ // MCP caller has no visibility into stderr -- unlike skill_view, whose
27
+ // returned object already surfaces yanked_at/yanked_reason, skill_read
28
+ // was silently dropping this signal for MCP callers entirely. read()
29
+ // below still destructures only {version, content} for the CLI's
30
+ // --json output, so this addition doesn't change that shape.
31
+ yanked_at: detail.skill_versions.yanked_at ?? null,
32
+ yanked_reason: detail.skill_versions.yanked_reason ?? null,
33
+ };
34
+ }
35
+ export async function read(args) {
36
+ const jsonOutput = args.includes("--json");
37
+ const spec = args.find((a) => !a.startsWith("--"));
38
+ if (!spec)
39
+ throw new UsageError(USAGE);
40
+ const { owner, skill } = parseOwnerSkill(spec, USAGE);
41
+ const { version, content } = await readSkillMd(owner, skill);
24
42
  if (jsonOutput) {
25
- console.log(JSON.stringify({ version: detail.skill_versions.version, content }));
43
+ console.log(JSON.stringify({ version, content }));
26
44
  return;
27
45
  }
28
46
  // Plain mode prints the raw content verbatim -- no formatting/labels/
@@ -1,12 +1,13 @@
1
- import { existsSync, readdirSync, rmSync, rmdirSync } from "node:fs";
1
+ import { existsSync, readdirSync, readFileSync, rmSync, rmdirSync, unlinkSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { removeLockfileEntry } from "../lockfile.js";
4
- import { LOCKFILE_PATH, parseOwnerSkill, skillDir } from "../spec.js";
5
- const USAGE = "Usage: ahood remove <owner>/<skill>";
4
+ import { LOCKFILE_PATH, parseOwnerSkill, skillDir, agentPath, MCP_CONFIG_PATH } from "../spec.js";
5
+ import { UsageError } from "../usage-error.js";
6
+ const USAGE = "Usage: ahood skill remove <owner>/<skill>";
6
7
  export async function remove(args) {
7
8
  const spec = args[0];
8
9
  if (!spec)
9
- throw new Error(USAGE);
10
+ throw new UsageError(USAGE);
10
11
  const { owner, skill } = parseOwnerSkill(spec, USAGE);
11
12
  const key = `${owner}/${skill}`;
12
13
  const dir = skillDir(owner, skill);
@@ -20,11 +21,49 @@ export async function remove(args) {
20
21
  const ownerDir = dirname(dir);
21
22
  if (existsSync(ownerDir) && readdirSync(ownerDir).length === 0)
22
23
  rmdirSync(ownerDir);
24
+ // An agent installs as a single flat file (.claude/agents/<owner>@<skill>.md),
25
+ // never a directory under .claude/skills/ -- skillDir's rmSync above never
26
+ // touches it. Without this, removing an installed agent silently left the
27
+ // file on disk (still loaded by Claude Code forever) while reporting
28
+ // success and clearing the lockfile entry, so a later `update` wouldn't
29
+ // catch it either (ahood-cli final review finding #2).
30
+ const agentFile = agentPath(owner, skill);
31
+ const agentExisted = existsSync(agentFile);
32
+ if (agentExisted)
33
+ unlinkSync(agentFile);
23
34
  const hadLockfileEntry = removeLockfileEntry(LOCKFILE_PATH, key);
24
- if (!dirExisted && !hadLockfileEntry) {
35
+ if (!dirExisted && !agentExisted && !hadLockfileEntry) {
25
36
  console.error(`${key} was not installed -- nothing to remove.`);
26
37
  process.exitCode = 1;
27
38
  return;
28
39
  }
40
+ // An mcp-kind install has no directory or agent file on disk -- its only
41
+ // footprint here is the lockfile entry just cleared above and a live
42
+ // entry in .mcp.json (which add.ts's installMcpEntry merged in, possibly
43
+ // holding a resolved secret in its `env`). remove() doesn't attempt a real
44
+ // mcp removal, but staying silent about that entry repeats the exact
45
+ // false-assurance bug already fixed once for agent installs above: a user
46
+ // who removes an mcp artifact because they no longer trust it would be
47
+ // told it's gone while the MCP server (and its credential) still runs on
48
+ // the next Claude Code start -- and since the lockfile pin is now cleared,
49
+ // nothing will ever surface this again via `list`/`update`. So: warn
50
+ // instead of pretending it's gone.
51
+ if (existsSync(MCP_CONFIG_PATH)) {
52
+ try {
53
+ const parsed = JSON.parse(readFileSync(MCP_CONFIG_PATH, "utf-8"));
54
+ const mcpServers = parsed?.mcpServers;
55
+ if (mcpServers &&
56
+ typeof mcpServers === "object" &&
57
+ !Array.isArray(mcpServers) &&
58
+ Object.prototype.hasOwnProperty.call(mcpServers, skill)) {
59
+ console.warn(`WARNING: ${key} still has an entry in ${MCP_CONFIG_PATH} (which may contain secrets you entered) -- remove it manually.`);
60
+ }
61
+ }
62
+ catch {
63
+ // A malformed .mcp.json isn't this command's problem to fix or crash
64
+ // on -- add.ts's readMcpConfig is the strict validator for that path.
65
+ // Skip the warning rather than throw here.
66
+ }
67
+ }
29
68
  console.log(`Removed ${key}`);
30
69
  }
@@ -1,23 +1,29 @@
1
1
  import { apiJson } from "../http.js";
2
2
  import { flagValue } from "../flags.js";
3
- const USAGE = "Usage: ahood search <query> [--json] [--limit <n>]";
3
+ import { UsageError } from "../usage-error.js";
4
+ const USAGE = "Usage: ahood skill search <query> [--json] [--limit <n>]";
5
+ export async function searchSkills(query, limit) {
6
+ const qs = new URLSearchParams({ q: query });
7
+ if (limit !== undefined)
8
+ qs.set("per_page", String(limit));
9
+ const { skills } = await apiJson(`/api/v1/skills?${qs}`);
10
+ return skills ?? [];
11
+ }
4
12
  export async function search(args) {
5
13
  const jsonOutput = args.includes("--json");
6
- const limit = flagValue(args, "--limit");
14
+ const limitStr = flagValue(args, "--limit");
7
15
  const queryParts = args.filter((a, i) => a !== "--json" && a !== "--limit" && args[i - 1] !== "--limit");
8
16
  const unknownFlag = queryParts.find((a) => a.startsWith("--"));
9
17
  if (unknownFlag)
10
- throw new Error(`Unknown flag: ${unknownFlag}\n${USAGE}`);
18
+ throw new UsageError(`Unknown flag: ${unknownFlag}\n${USAGE}`);
11
19
  const query = queryParts.join(" ");
12
20
  if (!query)
13
- throw new Error(USAGE);
14
- if (limit !== undefined && (!/^\d+$/.test(limit) || Number(limit) < 1)) {
15
- throw new Error(`--limit must be a positive integer (got "${limit}").\n${USAGE}`);
21
+ throw new UsageError(USAGE);
22
+ if (limitStr !== undefined && (!/^\d+$/.test(limitStr) || Number(limitStr) < 1)) {
23
+ throw new UsageError(`--limit must be a positive integer (got "${limitStr}").\n${USAGE}`);
16
24
  }
17
- const qs = new URLSearchParams({ q: query });
18
- if (limit !== undefined)
19
- qs.set("per_page", limit);
20
- const { skills } = await apiJson(`/api/v1/skills?${qs}`);
25
+ const limit = limitStr !== undefined ? Number(limitStr) : undefined;
26
+ const skills = await searchSkills(query, limit);
21
27
  if (jsonOutput) {
22
28
  console.log(JSON.stringify(skills));
23
29
  return;
@@ -29,7 +35,7 @@ export async function search(args) {
29
35
  for (const skill of skills) {
30
36
  console.log(`${skill.profiles.username}/${skill.slug} - ${skill.name}${skill.tagline ? `: ${skill.tagline}` : ""} (${skill.downloads_count} downloads)`);
31
37
  }
32
- if (limit !== undefined && skills.length >= Number(limit)) {
38
+ if (limit !== undefined && skills.length >= limit) {
33
39
  console.log(`(showing up to ${limit} results -- pass a higher --limit for more)`);
34
40
  }
35
41
  }
@@ -1,6 +1,7 @@
1
1
  import { apiJson } from "../http.js";
2
2
  import { flagValue } from "../flags.js";
3
3
  import { parseOwnerSkill } from "../spec.js";
4
+ import { UsageError } from "../usage-error.js";
4
5
  // These are skill-entity verbs (reached as `ahood skill share`/`ahood skill
5
6
  // unshare`, registered in SKILL_COMMANDS in index.ts), not group verbs --
6
7
  // they mutate a skill's sharing, not a group. Grouped together in one file
@@ -16,11 +17,11 @@ const UNSHARE_USAGE = "Usage: ahood skill unshare <owner>/<skill> --group <group
16
17
  export async function share(args) {
17
18
  const spec = args[0];
18
19
  if (!spec || spec.startsWith("--"))
19
- throw new Error(SHARE_USAGE);
20
+ throw new UsageError(SHARE_USAGE);
20
21
  const { owner, skill } = parseOwnerSkill(spec, SHARE_USAGE);
21
22
  const groupSlug = flagValue(args, "--group");
22
23
  if (!groupSlug)
23
- throw new Error(SHARE_USAGE);
24
+ throw new UsageError(SHARE_USAGE);
24
25
  await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/share`, {
25
26
  method: "POST",
26
27
  headers: { "Content-Type": "application/json" },
@@ -31,11 +32,11 @@ export async function share(args) {
31
32
  export async function unshare(args) {
32
33
  const spec = args[0];
33
34
  if (!spec || spec.startsWith("--"))
34
- throw new Error(UNSHARE_USAGE);
35
+ throw new UsageError(UNSHARE_USAGE);
35
36
  const { owner, skill } = parseOwnerSkill(spec, UNSHARE_USAGE);
36
37
  const groupSlug = flagValue(args, "--group");
37
38
  if (!groupSlug)
38
- throw new Error(UNSHARE_USAGE);
39
+ throw new UsageError(UNSHARE_USAGE);
39
40
  await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/share/${encodeURIComponent(groupSlug)}`, { method: "DELETE" });
40
41
  console.log(`Unshared ${owner}/${skill} from ${groupSlug}.`);
41
42
  }
@@ -6,7 +6,7 @@ import { parseOwnerSkill } from "../spec.js";
6
6
  // deletes any matching row), so starring an already-starred skill or
7
7
  // unstarring one you never starred is a no-op 200, not an error.
8
8
  export async function star(args) {
9
- const USAGE = "Usage: ahood star <owner>/<skill>";
9
+ const USAGE = "Usage: ahood skill star <owner>/<skill>";
10
10
  const { owner, skill } = parseOwnerSkill(args[0] ?? "", USAGE);
11
11
  await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/star`, {
12
12
  method: "POST",
@@ -14,7 +14,7 @@ export async function star(args) {
14
14
  console.log(`Starred ${owner}/${skill}.`);
15
15
  }
16
16
  export async function unstar(args) {
17
- const USAGE = "Usage: ahood unstar <owner>/<skill>";
17
+ const USAGE = "Usage: ahood skill unstar <owner>/<skill>";
18
18
  const { owner, skill } = parseOwnerSkill(args[0] ?? "", USAGE);
19
19
  await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/star`, {
20
20
  method: "DELETE",
@@ -1,5 +1,6 @@
1
1
  import { apiJson } from "../http.js";
2
2
  import { confirm } from "../confirm.js";
3
+ import { UsageError } from "../usage-error.js";
3
4
  export async function token(args) {
4
5
  const [subcommand, ...rest] = args;
5
6
  switch (subcommand) {
@@ -10,13 +11,13 @@ export async function token(args) {
10
11
  case "revoke":
11
12
  return tokenRevoke(rest);
12
13
  default:
13
- throw new Error("Usage: ahood token create <name>|list [--json]|revoke <id> [--yes]");
14
+ throw new UsageError("Usage: ahood token create <name>|list [--json]|revoke <id> [--yes]");
14
15
  }
15
16
  }
16
17
  async function tokenCreate(args) {
17
18
  const name = args[0];
18
19
  if (!name)
19
- throw new Error("Usage: ahood token create <name>");
20
+ throw new UsageError("Usage: ahood token create <name>");
20
21
  // Note: this itself requires an existing session-backed token or a
21
22
  // browser login -- per docs/adr/backend/0001-backend-services.md's Phase
22
23
  // 3 section, tokens can't mint tokens (POST /auth/tokens is session-only,
@@ -52,7 +53,7 @@ async function tokenRevoke(args) {
52
53
  const yes = args.includes("--yes");
53
54
  const id = args.find((a) => !a.startsWith("--"));
54
55
  if (!id)
55
- throw new Error("Usage: ahood token revoke <id> [--yes]");
56
+ throw new UsageError("Usage: ahood token revoke <id> [--yes]");
56
57
  // --yes bypasses both the lookup and the prompt entirely, preserving the
57
58
  // original one-shot behavior (a single DELETE call) for scripts/CI that
58
59
  // already call `ahood token revoke <id> --yes` unattended.
@@ -2,7 +2,8 @@ import { apiJson } from "../http.js";
2
2
  import { confirm } from "../confirm.js";
3
3
  import { parseOwnerSkill, parseOwnerSkillVersion } from "../spec.js";
4
4
  import { fetchVersionMeta } from "./add.js";
5
- const USAGE = "Usage: ahood unpublish <owner>/<skill>[@version] [--yes]";
5
+ import { UsageError } from "../usage-error.js";
6
+ const USAGE = "Usage: ahood skill unpublish <owner>/<skill>[@version] [--yes]";
6
7
  // A bare "owner/skill" has no "@" at all, so lastIndexOf("@") is -1 and this
7
8
  // is a whole-skill unpublish (preserves the exact pre-existing behavior).
8
9
  // "owner/skill@1.2.3" has an "@" at index > 0 and is a single-version yank
@@ -16,7 +17,7 @@ export async function unpublish(args) {
16
17
  const yes = args.includes("--yes");
17
18
  const spec = args.find((a) => !a.startsWith("--"));
18
19
  if (!spec)
19
- throw new Error(USAGE);
20
+ throw new UsageError(USAGE);
20
21
  if (isVersioned(spec)) {
21
22
  const { owner, skill, version: parsedVersion } = parseOwnerSkillVersion(spec, USAGE);
22
23
  // parseOwnerSkillVersion only validates *syntax* -- an omitted "@version"
@@ -63,7 +64,7 @@ export async function unpublish(args) {
63
64
  // the whole request, since the yank already took effect. Discarding the
64
65
  // response body would silently drop that signal (ahood-cli#68): the
65
66
  // pointer could now be stale with nothing in the terminal to say so, and
66
- // a later `ahood add owner/skill` trusts latest_version_id completely.
67
+ // a later `ahood skill add owner/skill` trusts latest_version_id completely.
67
68
  if (result.latest_version_warning) {
68
69
  console.warn(`WARNING: ${result.latest_version_warning}`);
69
70
  }
@@ -82,5 +83,5 @@ export async function unpublish(args) {
82
83
  return;
83
84
  }
84
85
  await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`, { method: "DELETE" });
85
- console.log(`Unpublished ${key}. Run \`ahood remove ${key}\` to also remove your local copy.`);
86
+ console.log(`Unpublished ${key}. Run \`ahood skill remove ${key}\` to also remove your local copy.`);
86
87
  }
@@ -1,7 +1,8 @@
1
1
  import { LOCKFILE_PATH, parseOwnerSkill } from "../spec.js";
2
2
  import { readLockfile } from "../lockfile.js";
3
3
  import { add, fetchVersionMeta } from "./add.js";
4
- const USAGE = "Usage: ahood update [<owner>/<skill> ...] [--dry-run] [--json]";
4
+ import { UsageError } from "../usage-error.js";
5
+ const USAGE = "Usage: ahood skill update [<owner>/<skill> ...] [--dry-run] [--json]";
5
6
  async function previewSkill(ownerSlashSkill, currentVersion) {
6
7
  const { owner, skill } = parseOwnerSkill(ownerSlashSkill, USAGE);
7
8
  const meta = await fetchVersionMeta(owner, skill, "latest");
@@ -47,7 +48,7 @@ export async function update(args) {
47
48
  const dryRun = args.includes("--dry-run");
48
49
  const jsonOutput = args.includes("--json");
49
50
  if (jsonOutput && !dryRun) {
50
- throw new Error(`--json is only supported together with --dry-run.\n${USAGE}`);
51
+ throw new UsageError(`--json is only supported together with --dry-run.\n${USAGE}`);
51
52
  }
52
53
  const targets = args.filter((a) => a !== "--dry-run" && a !== "--json");
53
54
  const lockfile = readLockfile(LOCKFILE_PATH);
@@ -88,7 +89,7 @@ export async function update(args) {
88
89
  for (const ownerSlashSkill of skillKeys) {
89
90
  if (!lockfile[ownerSlashSkill]) {
90
91
  // Reachable two ways: an explicitly-named skill that was never
91
- // installed (`ahood add` is the right command for that -- `update`
92
+ // installed (`ahood skill add` is the right command for that -- `update`
92
93
  // must not silently install and pin it), or -- when targets came from
93
94
  // the lockfile itself -- the lockfile changing out from under us
94
95
  // mid-loop. Either way: skip, don't abort.
@@ -96,6 +97,22 @@ export async function update(args) {
96
97
  continue;
97
98
  }
98
99
  try {
100
+ // add() always hits the .mcp.json collision check for an mcp-kind
101
+ // entry that's already installed (that's the very definition of
102
+ // "already installed" for that kind), so calling it unconditionally
103
+ // here would make `ahood skill update` with no arguments permanently
104
+ // report a failure and exit 1 for every user who has ever installed
105
+ // an mcp artifact, even when nothing needs updating. fetchVersionMeta
106
+ // already resolves `kind` as part of the normal "latest" lookup add()
107
+ // itself does, so resolving it here first lets this skip cleanly
108
+ // instead of updating and hitting that guaranteed error. This is a
109
+ // skip-and-report-cleanly fix, not real mcp-update support.
110
+ const { owner, skill } = parseOwnerSkill(ownerSlashSkill, USAGE);
111
+ const meta = await fetchVersionMeta(owner, skill, "latest");
112
+ if (meta.kind === "mcp") {
113
+ console.warn(`Skipping ${ownerSlashSkill}: mcp artifacts aren't updatable via this command yet.`);
114
+ continue;
115
+ }
99
116
  await add([ownerSlashSkill]); // no @version -- resolves to latest again
100
117
  }
101
118
  catch (error) {
@@ -1,6 +1,7 @@
1
1
  import { apiJson } from "../http.js";
2
2
  import { parseOwnerSkill } from "../spec.js";
3
- const USAGE = "Usage: ahood versions <owner>/<skill> [--json]";
3
+ import { UsageError } from "../usage-error.js";
4
+ const USAGE = "Usage: ahood skill versions <owner>/<skill> [--json]";
4
5
  function formatSize(bytes) {
5
6
  if (bytes < 1024)
6
7
  return `${bytes} B`;
@@ -13,13 +14,17 @@ function formatSize(bytes) {
13
14
  }
14
15
  return `${value.toFixed(1)} ${units[unitIndex]}`;
15
16
  }
17
+ export async function listSkillVersions(owner, skill) {
18
+ const { versions } = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions`);
19
+ return versions ?? [];
20
+ }
16
21
  export async function versions(args) {
17
22
  const jsonOutput = args.includes("--json");
18
23
  const spec = args.find((a) => !a.startsWith("--"));
19
24
  if (!spec)
20
- throw new Error(USAGE);
25
+ throw new UsageError(USAGE);
21
26
  const { owner, skill } = parseOwnerSkill(spec, USAGE);
22
- const { versions: list } = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions`);
27
+ const list = await listSkillVersions(owner, skill);
23
28
  if (jsonOutput) {
24
29
  console.log(JSON.stringify(list));
25
30
  return;
@@ -2,7 +2,8 @@ import { spawn } from "node:child_process";
2
2
  import { apiJson } from "../http.js";
3
3
  import { getApiUrl } from "../config.js";
4
4
  import { parseOwnerSkill } from "../spec.js";
5
- const USAGE = "Usage: ahood view <owner>/<skill> [--json] [--web]";
5
+ import { UsageError } from "../usage-error.js";
6
+ const USAGE = "Usage: ahood skill view <owner>/<skill> [--json] [--web]";
6
7
  // Spawned with argv as an array (not a shell string), so a crafted owner/skill
7
8
  // can never inject shell syntax -- and both segments are already restricted
8
9
  // to a safe charset by parseOwnerSkill before this ever runs.
@@ -22,26 +23,29 @@ function openBrowser(url) {
22
23
  console.log(`Open this URL in your browser: ${url}`);
23
24
  }
24
25
  }
26
+ export async function viewSkill(owner, skill) {
27
+ return apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`);
28
+ }
25
29
  export async function view(args) {
26
30
  const jsonOutput = args.includes("--json");
27
31
  const web = args.includes("--web");
28
32
  const spec = args.find((a) => !a.startsWith("--"));
29
33
  if (!spec)
30
- throw new Error(USAGE);
34
+ throw new UsageError(USAGE);
31
35
  const { owner, skill } = parseOwnerSkill(spec, USAGE);
32
36
  const url = `${getApiUrl()}/${owner}/${skill}`;
33
37
  if (web) {
34
38
  openBrowser(url);
35
39
  return;
36
40
  }
37
- const detail = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`);
41
+ const detail = await viewSkill(owner, skill);
38
42
  if (jsonOutput) {
39
43
  console.log(JSON.stringify(detail));
40
44
  return;
41
45
  }
42
46
  const field = (label, value) => console.log(` ${label.padEnd(13)}${value}`);
43
47
  console.log(`${detail.owner}/${detail.slug}`);
44
- // Mirrors the wording/tone of `ahood add`'s yanked warning (src/commands/add.ts)
48
+ // Mirrors the wording/tone of `ahood skill add`'s yanked warning (src/commands/add.ts)
45
49
  // and the website's skill detail page banner -- this is the one CLI surface
46
50
  // whose entire job is "let me check this out before deciding to install it",
47
51
  // so it must not be the one place that stays silent about a yanked version.