@ahood/cli 0.5.2 → 0.7.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
@@ -201,6 +201,7 @@ Sharing is additive -- it doesn't change `alice/pdf-tools`'s own public/private
201
201
  | `ahood whoami [--json]` | Reports whether your stored token still authenticates. |
202
202
  | `ahood token create <name>\|list [--json]\|revoke <id> [--yes]` | Manage personal API tokens. |
203
203
  | `ahood completion <bash\|zsh\|fish>` | Print a shell completion script for the command names. |
204
+ | `ahood mcp` | Start an MCP server exposing read-only skill commands as tools over stdio. |
204
205
 
205
206
  ### Skill
206
207
 
@@ -210,9 +211,11 @@ Sharing is additive -- it doesn't change `alice/pdf-tools`'s own public/private
210
211
  | `ahood skill view\|show <owner>/<skill> [--json] [--web]` | Show a single skill's details -- tags, license, homepage, repository, dates, and more -- without installing it (alias: ahood skill show). |
211
212
  | `ahood skill read <owner>/<skill> [--json]` | Print a skill's full SKILL.md content, without installing it. |
212
213
  | `ahood skill versions <owner>/<skill> [--json]` | List a skill's published-version history -- version, changelog, size, and publish date. |
214
+ | `ahood skill diff <owner>/<skill> <versionA> <versionB> [--json]` | Show what changed between two published versions -- a SKILL.md diff plus an added/removed/changed file summary. |
213
215
  | `ahood skill list [--json]` | List your own skills, public and private. |
214
216
  | `ahood skill add <owner>/<skill>[@version]` | Install a skill into .claude/skills/, pinned in the lockfile. |
215
217
  | `ahood skill update [<owner>/<skill> ...] [--dry-run] [--json]` | Move the lockfile pin(s) forward to the latest version, for one skill or all installed skills at once. |
218
+ | `ahood skill outdated [<owner>/<skill> ...] [--json]` | Read-only staleness check comparing current and latest versions (with changelog) for installed skills. |
216
219
  | `ahood skill remove <owner>/<skill>` | Uninstall and unpin a skill (local only). |
217
220
  | `ahood skill edit <owner>/<skill> [--tagline] [--tags] [--license] [--visibility] [--homepage] [--repository]` | Update a skill you own, changing only the flags you pass. |
218
221
  | `ahood skill unpublish <owner>/<skill>[@version] [--yes]` | Delete a skill from the registry for every consumer, or yank a single version, not just your local install (prompts for confirmation unless --yes is passed). |
@@ -221,7 +224,7 @@ Sharing is additive -- it doesn't change `alice/pdf-tools`'s own public/private
221
224
  | `ahood skill share <owner>/<skill> --group <group>` | Share a skill you own with a group, without changing its public/private visibility. |
222
225
  | `ahood skill unshare <owner>/<skill> --group <group>` | Stop sharing a skill you own with a group. |
223
226
  | `ahood skill init [name]` | Scaffold a new skill folder with a minimal, valid SKILL.md. |
224
- | `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]` | Publish a new version of a skill, agent, or mcp server manifest from a folder containing SKILL.md, AGENT.md, or server.json, creating the skill first if it doesn't already exist. |
227
+ | `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>] [--changelog <text>] [--json]` | Publish a new version of a skill, agent, or mcp server manifest from a folder containing SKILL.md, AGENT.md, or server.json, creating the skill first if it doesn't already exist. |
225
228
 
226
229
  ### Group
227
230
 
@@ -273,6 +276,7 @@ ahood completion fish > ~/.config/fish/completions/ahood.fish
273
276
  - **Structured output everywhere.** `--json` is available on every read command and on `publish`/`update`, so an agent never has to scrape human-formatted text.
274
277
  - **Predictable failure.** A stable, documented [exit code](#exit-codes) per failure class, and error messages that never leak raw upstream infrastructure details — safe to surface directly to an agent's reasoning loop.
275
278
  - **A real "start here."** `ahood skill init <name>` scaffolds a valid `SKILL.md` an agent can extend, rather than requiring it to know the frontmatter format up front.
279
+ - **A native MCP server, for agents that prefer tool calls to subprocess parsing.** `ahood mcp` starts a Model Context Protocol server over stdio, exposing `skill_search`, `skill_view`, `skill_read`, `skill_versions`, `skill_list`, and `whoami` as typed tools -- the same data the `--json` flags above already return, reachable as structured tool calls instead. Configure your MCP-aware agent host to run `ahood mcp` (or `npx @ahood/cli@latest mcp`) as a stdio server.
276
280
 
277
281
  ## Development
278
282
 
@@ -174,8 +174,11 @@ async function extractFreshVersion(buffer, destDir) {
174
174
  // Agent packages install as one file, not a directory (Claude Code's own
175
175
  // .claude/agents/*.md convention is flat, non-recursive) -- extracts just
176
176
  // AGENT.md's content from the downloaded tarball rather than writing every
177
- // entry to disk the way extractFreshVersion does for skills.
178
- async function extractSingleFileContent(buffer, entryName) {
177
+ // entry to disk the way extractFreshVersion does for skills. Exported so
178
+ // `diff.ts` can reuse the exact same in-memory single-file extraction to
179
+ // pull SKILL.md out of two downloaded version archives without ever writing
180
+ // either to disk (ahood-cli#88).
181
+ export async function extractSingleFileContent(buffer, entryName) {
179
182
  let decompressed;
180
183
  try {
181
184
  // Same decompression-bomb guard as extractTarGz above: a highly-
@@ -401,6 +404,24 @@ async function installMcpEntry(owner, skill, meta, buffer) {
401
404
  console.warn(`WARNING: ${MCP_CONFIG_PATH} now contains one or more secret values in plaintext -- do not commit it to version control.`);
402
405
  }
403
406
  }
407
+ // Downloads a specific version's archive and verifies it against the
408
+ // checksum in `meta`, WITHOUT extracting anything -- the shared first half
409
+ // of add()'s download flow below, pulled out so `diff.ts` (ahood-cli#88) can
410
+ // fetch two versions' archives the same verified way without duplicating
411
+ // the download/checksum logic.
412
+ export async function downloadVerifiedArchive(owner, skill, meta) {
413
+ const downloadRes = await apiFetch(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/download?version=${encodeURIComponent(meta.version)}`, { headers: { "X-Ahood-Source": "cli" }, redirect: "follow" });
414
+ if (!downloadRes.ok) {
415
+ const body = await downloadRes.text().catch(() => "");
416
+ throw new Error(`Download failed with status ${downloadRes.status}${body ? `: ${body}` : ""}`);
417
+ }
418
+ const buffer = await readBoundedBody(downloadRes, MAX_DOWNLOAD_BYTES);
419
+ const actualChecksum = createHash("sha256").update(buffer).digest("hex");
420
+ if (actualChecksum !== meta.checksum_sha256) {
421
+ throw new Error(`Checksum mismatch for ${owner}/${skill}@${meta.version}: expected ${meta.checksum_sha256}, got ${actualChecksum}. Refusing to install.`);
422
+ }
423
+ return buffer;
424
+ }
404
425
  export async function add(args) {
405
426
  const spec = args[0];
406
427
  if (!spec)
@@ -431,16 +452,7 @@ export async function add(args) {
431
452
  if (meta.kind !== "agent" && meta.kind !== "mcp" && meta.manifest.some((f) => f.path.startsWith("scripts/"))) {
432
453
  console.warn("WARNING: this skill includes a scripts/ directory. Review its contents before use.");
433
454
  }
434
- const downloadRes = await apiFetch(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/download?version=${encodeURIComponent(meta.version)}`, { headers: { "X-Ahood-Source": "cli" }, redirect: "follow" });
435
- if (!downloadRes.ok) {
436
- const body = await downloadRes.text().catch(() => "");
437
- throw new Error(`Download failed with status ${downloadRes.status}${body ? `: ${body}` : ""}`);
438
- }
439
- const buffer = await readBoundedBody(downloadRes, MAX_DOWNLOAD_BYTES);
440
- const actualChecksum = createHash("sha256").update(buffer).digest("hex");
441
- if (actualChecksum !== meta.checksum_sha256) {
442
- throw new Error(`Checksum mismatch for ${key}@${meta.version}: expected ${meta.checksum_sha256}, got ${actualChecksum}. Refusing to install.`);
443
- }
455
+ const buffer = await downloadVerifiedArchive(owner, skill, meta);
444
456
  if (meta.kind === "agent") {
445
457
  const content = await extractSingleFileContent(buffer, "AGENT.md");
446
458
  mkdirSync(AGENTS_ROOT, { recursive: true });
@@ -0,0 +1,92 @@
1
+ import { createPatch } from "diff";
2
+ import { fetchVersionMeta, downloadVerifiedArchive, extractSingleFileContent } from "./add.js";
3
+ import { parseOwnerSkill, SEMVER_RE } from "../spec.js";
4
+ import { UsageError } from "../usage-error.js";
5
+ const USAGE = "Usage: ahood skill diff <owner>/<skill> <versionA> <versionB> [--json]";
6
+ // Both versions must be explicit semver here, unlike `add`'s
7
+ // parseOwnerSkillVersion which lets "latest" default the version -- a diff
8
+ // is meaningless without two concrete, unambiguous versions to compare, and
9
+ // "latest" would silently mean two different things if the registry gained
10
+ // a new version between resolving vA and vB.
11
+ function validateVersion(version, spec) {
12
+ if (!SEMVER_RE.test(version)) {
13
+ throw new UsageError(`Invalid version "${version}" in "${spec}" -- expected a semver like 1.2.3.\n${USAGE}`);
14
+ }
15
+ }
16
+ // Extracts SKILL.md's content from a downloaded version archive. Unlike an
17
+ // agent's AGENT.md (always present, since that's the only file an agent
18
+ // package ever installs), a skill package could in principle omit
19
+ // SKILL.md's presence from the manifest we already fetched -- checked
20
+ // up front so a missing file produces a clean message instead of
21
+ // extractSingleFileContent's generic "not found in the downloaded archive".
22
+ async function fetchSkillMdContent(owner, skill, meta) {
23
+ if (!meta.manifest.some((f) => f.path === "SKILL.md" || f.path === "./SKILL.md")) {
24
+ throw new Error(`${owner}/${skill}@${meta.version} has no SKILL.md in its manifest.`);
25
+ }
26
+ const buffer = await downloadVerifiedArchive(owner, skill, meta);
27
+ const content = await extractSingleFileContent(buffer, "SKILL.md");
28
+ return content.toString("utf-8");
29
+ }
30
+ // Compares two versions' manifests by path. VersionMeta.manifest only
31
+ // carries `{path}` (no per-file size or checksum), so "added"/"removed" are
32
+ // fully determined by path presence, but "changed" can only be reported for
33
+ // SKILL.md itself (the one file whose content we've already downloaded and
34
+ // diffed above) -- there is no way to tell whether some other common path's
35
+ // *content* differs without downloading and comparing it too, which is out
36
+ // of scope for this command (see the resolution-plan comment on issue #88).
37
+ function diffManifests(manifestA, manifestB, skillMdChanged) {
38
+ const pathsA = new Set(manifestA.map((f) => f.path));
39
+ const pathsB = new Set(manifestB.map((f) => f.path));
40
+ const added = [...pathsB].filter((p) => !pathsA.has(p)).sort();
41
+ const removed = [...pathsA].filter((p) => !pathsB.has(p)).sort();
42
+ const changed = [];
43
+ if (skillMdChanged && pathsA.has("SKILL.md") && pathsB.has("SKILL.md")) {
44
+ changed.push("SKILL.md");
45
+ }
46
+ return { added, removed, changed };
47
+ }
48
+ export async function diff(args) {
49
+ const jsonOutput = args.includes("--json");
50
+ const positional = args.filter((a) => !a.startsWith("--"));
51
+ const [spec, versionA, versionB] = positional;
52
+ if (!spec || !versionA || !versionB)
53
+ throw new UsageError(USAGE);
54
+ const { owner, skill } = parseOwnerSkill(spec, USAGE);
55
+ validateVersion(versionA, `${spec} ${versionA}`);
56
+ validateVersion(versionB, `${spec} ${versionB}`);
57
+ const [metaA, metaB] = await Promise.all([
58
+ fetchVersionMeta(owner, skill, versionA),
59
+ fetchVersionMeta(owner, skill, versionB),
60
+ ]);
61
+ const [contentA, contentB] = await Promise.all([
62
+ fetchSkillMdContent(owner, skill, metaA),
63
+ fetchSkillMdContent(owner, skill, metaB),
64
+ ]);
65
+ const skillmdDiff = createPatch("SKILL.md", contentA, contentB, versionA, versionB);
66
+ const skillMdChanged = contentA !== contentB;
67
+ const manifest = diffManifests(metaA.manifest, metaB.manifest, skillMdChanged);
68
+ if (jsonOutput) {
69
+ console.log(JSON.stringify({ skillmd_diff: skillMdChanged ? skillmdDiff : "", manifest }));
70
+ return;
71
+ }
72
+ console.log(`${owner}/${skill}: ${versionA} -> ${versionB}`);
73
+ console.log("");
74
+ if (skillMdChanged) {
75
+ console.log(skillmdDiff);
76
+ }
77
+ else {
78
+ console.log("SKILL.md is unchanged between these versions.");
79
+ }
80
+ const summaryParts = [];
81
+ if (manifest.added.length > 0)
82
+ summaryParts.push(`Files added: ${manifest.added.join(", ")}`);
83
+ if (manifest.removed.length > 0)
84
+ summaryParts.push(`Files removed: ${manifest.removed.join(", ")}`);
85
+ if (manifest.changed.length > 0)
86
+ summaryParts.push(`Files changed: ${manifest.changed.join(", ")}`);
87
+ if (summaryParts.length > 0) {
88
+ console.log("");
89
+ for (const line of summaryParts)
90
+ console.log(line);
91
+ }
92
+ }
@@ -18,14 +18,17 @@ function normalizeToSlug(name) {
18
18
  .replace(/[^a-z0-9]+/g, "-")
19
19
  .replace(/^-+|-+$/g, "");
20
20
  }
21
- // publish.ts's only client-side check on SKILL.md is that the file exists
22
- // (see publish.ts's `existsSync(skillMdPath)`) -- actual frontmatter
21
+ // publish.ts's only hard client-side check on SKILL.md is that the file
22
+ // exists (see publish.ts's `existsSync(skillMdPath)`) -- actual frontmatter
23
23
  // validation happens server-side, after upload, as part of the processing
24
- // workflow described in publish.ts's POLL_INTERVAL_MS comment. There's no
25
- // parser to match locally, so this template instead follows the documented
26
- // Claude Code skill format: YAML frontmatter delimited by `---` lines, with
27
- // a `name` and a `description` field -- the same shape every real SKILL.md
28
- // in this ecosystem uses (see e.g. any installed skill under
24
+ // workflow described in publish.ts's POLL_INTERVAL_MS comment. (Publish does
25
+ // also do a best-effort, non-blocking scan of `description` at publish time
26
+ // -- ahood-cli#93 -- but that's a warning, not a parser, and this scaffold's
27
+ // job is to steer authors clear of it in the first place.) There's no real
28
+ // YAML parser to match locally, so this template instead follows the
29
+ // documented Claude Code skill format: YAML frontmatter delimited by `---`
30
+ // lines, with a `name` and a `description` field -- the same shape every
31
+ // real SKILL.md in this ecosystem uses (see e.g. any installed skill under
29
32
  // .claude/skills/<owner>/<skill>/SKILL.md). Keeping to exactly those two
30
33
  // fields, rather than inventing extra ones, means nothing here needs
31
34
  // updating just because publish.ts's own validation happens to be looser
@@ -40,6 +43,11 @@ name: ${name}
40
43
  # description: one or two sentences describing what this skill does and
41
44
  # when Claude should use it. This is the primary text Claude reads to
42
45
  # decide whether to invoke the skill, so be specific rather than generic.
46
+ # Keep it on a single line, under 1024 characters, and free of "<"/">"
47
+ # characters -- ahood skill publish warns about all three, because in
48
+ # registry-first usage (no local file, loaded via the registry/MCP server)
49
+ # this description is the *only* signal deciding whether the skill is ever
50
+ # triggered at all.
43
51
  description: TODO -- describe what this skill does and when Claude should use it.
44
52
  ---
45
53
 
@@ -1,7 +1,11 @@
1
1
  import { apiJson } from "../http.js";
2
+ export async function listOwnSkills() {
3
+ const { skills } = await apiJson("/api/v1/skills?mine=true");
4
+ return skills ?? [];
5
+ }
2
6
  export async function listSkills(args = []) {
3
7
  const jsonOutput = args.includes("--json");
4
- const { skills } = await apiJson("/api/v1/skills?mine=true");
8
+ const skills = await listOwnSkills();
5
9
  if (jsonOutput) {
6
10
  console.log(JSON.stringify(skills));
7
11
  return;
@@ -0,0 +1,7 @@
1
+ import { startMcpServer } from "../mcp/server.js";
2
+ // `ahood mcp` takes no subcommands/flags in v1. Unlike every other
3
+ // account-scoped command it never returns until the client disconnects --
4
+ // see startMcpServer's comment.
5
+ export async function mcp(_args) {
6
+ await startMcpServer();
7
+ }
@@ -27,7 +27,7 @@ async function pollVersionStatus(owner, skill, version) {
27
27
  }
28
28
  throw new Error(`Still processing after ${POLL_TIMEOUT_MS / 1000}s -- check \`ahood skill view ${owner}/${skill}\` later for the result.`);
29
29
  }
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" +
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>] [--changelog <text>] [--json]\n" +
31
31
  " or: ahood skill publish <path> --owner <owner> --slug <skill> --version <x.y.z> [--json]";
32
32
  // Matched by ENTRY NAME at every depth, not by path prefix, so a nested
33
33
  // `vendor/thing/.git` is skipped the same as a top-level one. Not a
@@ -63,6 +63,113 @@ function isExcluded(name) {
63
63
  return true;
64
64
  return EXCLUDED_PREFIXES.some((prefix) => name === prefix || name.startsWith(`${prefix}.`));
65
65
  }
66
+ // ahood-cli#93: in registry-first usage (an agent consuming this skill via
67
+ // the MCP server/registry, with no local SKILL.md ever loaded) the
68
+ // frontmatter `description` is the *only* signal an agent sees to decide
69
+ // whether to trigger this skill at all. A missing, oversized, or
70
+ // malformed-enough-to-mis-parse description means the skill silently never
71
+ // fires, and nothing today tells the author. This is a best-effort, targeted
72
+ // text scan -- not a real YAML parser (see init.ts's comment above
73
+ // buildSkillMd for why this codebase doesn't carry one) -- so it only needs
74
+ // to catch what a real skill author would plausibly write, not every legal
75
+ // YAML shape for `description:`.
76
+ const MAX_DESCRIPTION_LENGTH = 1024;
77
+ // Pulls just the `description:` value out of a SKILL.md's `---`-delimited
78
+ // frontmatter block. Returns undefined when the frontmatter or the key
79
+ // itself can't be found -- callers treat that the same as an empty
80
+ // description rather than throwing, since this scan is advisory only.
81
+ function extractSkillDescription(skillMd) {
82
+ const frontmatterMatch = skillMd.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
83
+ if (!frontmatterMatch)
84
+ return undefined;
85
+ const lines = frontmatterMatch[1].split(/\r?\n/);
86
+ const descIndex = lines.findIndex((line) => /^description:/.test(line));
87
+ if (descIndex === -1)
88
+ return undefined;
89
+ const firstLineValue = lines[descIndex].slice("description:".length).trim();
90
+ // YAML block scalar ("description: >" folded, or "description: |"
91
+ // literal), optionally with a chomping/indentation indicator like ">-" or
92
+ // "|2". Everything indented under it, until a dedented or blank line, is
93
+ // the value -- this is the *correct* way to write a genuinely multi-line
94
+ // description, so it does not trip the rawMultiLine heuristic below.
95
+ const blockScalarMatch = firstLineValue.match(/^([>|])[+-]?\d*$/);
96
+ if (blockScalarMatch) {
97
+ const folded = blockScalarMatch[1] === ">";
98
+ const collected = [];
99
+ for (let i = descIndex + 1; i < lines.length; i++) {
100
+ const line = lines[i];
101
+ if (line.trim() === "") {
102
+ collected.push("");
103
+ continue;
104
+ }
105
+ if (!/^\s/.test(line))
106
+ break; // dedented back to frontmatter's top level -- block scalar is over
107
+ collected.push(line.trim());
108
+ }
109
+ const value = folded ? collected.filter(Boolean).join(" ") : collected.join("\n");
110
+ return { value, rawMultiLine: false };
111
+ }
112
+ // Plain (or quoted) single-line form. Strip a matching pair of wrapping
113
+ // quotes for the length/character checks below -- not a full YAML string
114
+ // unescape, just enough to not count the quote characters themselves.
115
+ let value = firstLineValue;
116
+ if (value.length >= 2) {
117
+ const first = value[0];
118
+ const last = value[value.length - 1];
119
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
120
+ value = value.slice(1, -1);
121
+ }
122
+ }
123
+ // Heuristic for "the author typed real newlines into what should be one
124
+ // value": any non-blank line right after the description line that isn't
125
+ // itself a new top-level `key:` entry. Depending on its indentation, real
126
+ // YAML either folds this into the same scalar or fails to parse outright --
127
+ // neither is what a naive frontmatter-reading agent gets right, so either
128
+ // way it's worth flagging. This can't distinguish every edge case (e.g. a
129
+ // value that legitimately continues a nested structure), but skill
130
+ // frontmatter in practice is flat enough that it doesn't need to.
131
+ let rawMultiLine = false;
132
+ const extraLines = [];
133
+ for (let i = descIndex + 1; i < lines.length; i++) {
134
+ const line = lines[i];
135
+ if (line.trim() === "")
136
+ break;
137
+ if (!/^\s/.test(line) && /^[^\s:]+:/.test(line))
138
+ break; // next top-level key -- description's value ended
139
+ rawMultiLine = true;
140
+ extraLines.push(line.trim());
141
+ }
142
+ if (extraLines.length > 0)
143
+ value = [value, ...extraLines].filter(Boolean).join(" ");
144
+ return { value, rawMultiLine };
145
+ }
146
+ // Non-blocking: this only ever calls console.warn (never sets
147
+ // process.exitCode, never throws) so a weak description warns the author
148
+ // without affecting the exit code or --json mode's structured stdout output.
149
+ function warnAboutSkillDescription(skillMdPath) {
150
+ let content;
151
+ try {
152
+ content = readFileSync(skillMdPath, "utf8");
153
+ }
154
+ catch {
155
+ return; // publish's own existsSync check already guards this path being missing entirely
156
+ }
157
+ const extracted = extractSkillDescription(content);
158
+ const value = extracted?.value ?? "";
159
+ if (!value.trim()) {
160
+ console.warn(`WARNING: ${skillMdPath} has no (or an empty) description. In registry-first usage (an agent pulling this skill via the registry/MCP server, without ever loading this file) the description is the only signal deciding whether the skill is ever triggered -- without one, it effectively never will be.`);
161
+ return;
162
+ }
163
+ if (value.length > MAX_DESCRIPTION_LENGTH) {
164
+ console.warn(`WARNING: ${skillMdPath}'s description is ${value.length} characters, over the ${MAX_DESCRIPTION_LENGTH}-character recommended cap. An oversized description crowds out an agent's other context and is less likely to be read in full when it's deciding whether to trigger this skill.`);
165
+ }
166
+ if (extracted?.rawMultiLine) {
167
+ console.warn(`WARNING: ${skillMdPath}'s description spans multiple raw lines without a YAML block-scalar indicator (">" or "|"). This is easy to write by accident and breaks simple frontmatter parsers that only read a single line -- keep the description on one line, or use an explicit ">" (folded) or "|" (literal) block scalar if it genuinely needs to span multiple lines.`);
168
+ }
169
+ if (value.includes("<") || value.includes(">")) {
170
+ console.warn(`WARNING: ${skillMdPath}'s description contains a literal "<" or ">" character. These can be misread as markup or YAML block-scalar syntax by downstream parsers and renderers -- rewrite the description without them.`);
171
+ }
172
+ }
66
173
  async function tarGzDirectory(dir) {
67
174
  const tar = pack();
68
175
  const gzip = createGzip();
@@ -111,6 +218,7 @@ function parsePublishArgs(args) {
111
218
  const homepage = flagValue(args, "--homepage");
112
219
  const repository = flagValue(args, "--repository");
113
220
  const kind = flagValue(args, "--kind");
221
+ const changelog = flagValue(args, "--changelog");
114
222
  let legacyPath;
115
223
  const first = args[0];
116
224
  if (first && !first.startsWith("--")) {
@@ -141,7 +249,20 @@ function parsePublishArgs(args) {
141
249
  validateExternalUrl(homepage, "--homepage");
142
250
  if (repository !== undefined)
143
251
  validateExternalUrl(repository, "--repository");
144
- return { owner, skill, version, path: pathFlag ?? legacyPath ?? ".", name, tagline, tags, license, homepage, repository, kind };
252
+ return {
253
+ owner,
254
+ skill,
255
+ version,
256
+ path: pathFlag ?? legacyPath ?? ".",
257
+ name,
258
+ tagline,
259
+ tags,
260
+ license,
261
+ homepage,
262
+ repository,
263
+ kind,
264
+ changelog,
265
+ };
145
266
  }
146
267
  // Creates the skill under the caller's own account when versions/init 404s
147
268
  // -- skill creation is CLI-only now (there is no separate `ahood create`;
@@ -185,9 +306,16 @@ async function createSkillForPublish(owner, skill, opts, jsonOutput) {
185
306
  }
186
307
  return created;
187
308
  }
188
- async function initVersion(owner, skill, version, packageSizeBytes, createOpts, jsonOutput) {
309
+ async function initVersion(owner, skill, version, packageSizeBytes, changelog, createOpts, jsonOutput) {
189
310
  const path = `/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions/init`;
190
- const body = JSON.stringify({ version, package_size_bytes: packageSizeBytes });
311
+ // changelog_md matches the field name versions.ts's SkillVersion type already
312
+ // reads on the GET side (ahood-cli#87) -- only sent when --changelog was
313
+ // passed, same "omit rather than null" convention as every other optional
314
+ // flag in this file (see createSkillForPublish's body above).
315
+ const initBody = { version, package_size_bytes: packageSizeBytes };
316
+ if (changelog !== undefined)
317
+ initBody.changelog_md = changelog;
318
+ const body = JSON.stringify(initBody);
191
319
  try {
192
320
  const init = await apiJson(path, { method: "POST", headers: { "Content-Type": "application/json" }, body });
193
321
  return { init };
@@ -212,7 +340,7 @@ async function initVersion(owner, skill, version, packageSizeBytes, createOpts,
212
340
  export async function publish(args) {
213
341
  const jsonOutput = args.includes("--json");
214
342
  try {
215
- const { owner, skill, version, path, name, tagline, tags, license, homepage, repository, kind } = parsePublishArgs(args);
343
+ const { owner, skill, version, path, name, tagline, tags, license, homepage, repository, kind, changelog } = parsePublishArgs(args);
216
344
  const skillMdPath = join(path, "SKILL.md");
217
345
  const agentMdPath = join(path, "AGENT.md");
218
346
  const serverJsonPath = join(path, "server.json");
@@ -249,8 +377,16 @@ export async function publish(args) {
249
377
  }
250
378
  // else: hasSkillMd only -- resolvedKind stays undefined (unchanged existing behavior, server defaults to 'skill')
251
379
  }
380
+ // ahood-cli#93: fires for an explicit --kind skill, and for the
381
+ // inferred-skill case above (resolvedKind left undefined because
382
+ // SKILL.md was the only root file present) -- but not when SKILL.md
383
+ // merely happens to sit alongside an AGENT.md/server.json being
384
+ // published as --kind agent/mcp instead.
385
+ if (resolvedKind === "skill" || (resolvedKind === undefined && hasSkillMd)) {
386
+ warnAboutSkillDescription(skillMdPath);
387
+ }
252
388
  const archive = await tarGzDirectory(path);
253
- const { init, created } = await initVersion(owner, skill, version, archive.length, { name, tagline, tags, license, homepage, repository, kind: resolvedKind }, jsonOutput);
389
+ const { init, created } = await initVersion(owner, skill, version, archive.length, changelog, { name, tagline, tags, license, homepage, repository, kind: resolvedKind }, jsonOutput);
254
390
  // TS's lib.dom BodyInit (in scope here since tsconfig has no explicit
255
391
  // "lib" override, so DOM is included alongside the Node types) type-checks
256
392
  // Buffer/Uint8Array against `Uint8Array<ArrayBuffer>` specifically as of
@@ -2,19 +2,16 @@ import { apiJson } from "../http.js";
2
2
  import { parseOwnerSkill } from "../spec.js";
3
3
  import { UsageError } from "../usage-error.js";
4
4
  const USAGE = "Usage: ahood skill read <owner>/<skill> [--json]";
5
- export async function read(args) {
6
- const jsonOutput = args.includes("--json");
7
- const spec = args.find((a) => !a.startsWith("--"));
8
- if (!spec)
9
- throw new UsageError(USAGE);
10
- const { owner, skill } = parseOwnerSkill(spec, USAGE);
5
+ export async function readSkillMd(owner, skill) {
11
6
  const detail = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`);
12
7
  if (!detail.skill_versions) {
13
8
  throw new Error(`${owner}/${skill} has no published version.`);
14
9
  }
15
10
  // Mirrors view.ts's exact wording/tone for the same warning -- this
16
11
  // command reads straight from the same endpoint/version, so it must not
17
- // 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).
18
15
  if (detail.skill_versions.yanked_at) {
19
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}` : "."}`);
20
17
  }
@@ -22,8 +19,28 @@ export async function read(args) {
22
19
  if (!content) {
23
20
  throw new Error(`${owner}/${skill}@${detail.skill_versions.version} has no SKILL.md content available.`);
24
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);
25
42
  if (jsonOutput) {
26
- console.log(JSON.stringify({ version: detail.skill_versions.version, content }));
43
+ console.log(JSON.stringify({ version, content }));
27
44
  return;
28
45
  }
29
46
  // Plain mode prints the raw content verbatim -- no formatting/labels/
@@ -2,9 +2,16 @@ import { apiJson } from "../http.js";
2
2
  import { flagValue } from "../flags.js";
3
3
  import { UsageError } from "../usage-error.js";
4
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
+ }
5
12
  export async function search(args) {
6
13
  const jsonOutput = args.includes("--json");
7
- const limit = flagValue(args, "--limit");
14
+ const limitStr = flagValue(args, "--limit");
8
15
  const queryParts = args.filter((a, i) => a !== "--json" && a !== "--limit" && args[i - 1] !== "--limit");
9
16
  const unknownFlag = queryParts.find((a) => a.startsWith("--"));
10
17
  if (unknownFlag)
@@ -12,13 +19,11 @@ export async function search(args) {
12
19
  const query = queryParts.join(" ");
13
20
  if (!query)
14
21
  throw new UsageError(USAGE);
15
- if (limit !== undefined && (!/^\d+$/.test(limit) || Number(limit) < 1)) {
16
- throw new UsageError(`--limit must be a positive integer (got "${limit}").\n${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}`);
17
24
  }
18
- const qs = new URLSearchParams({ q: query });
19
- if (limit !== undefined)
20
- qs.set("per_page", limit);
21
- const { skills } = await apiJson(`/api/v1/skills?${qs}`);
25
+ const limit = limitStr !== undefined ? Number(limitStr) : undefined;
26
+ const skills = await searchSkills(query, limit);
22
27
  if (jsonOutput) {
23
28
  console.log(JSON.stringify(skills));
24
29
  return;
@@ -30,7 +35,7 @@ export async function search(args) {
30
35
  for (const skill of skills) {
31
36
  console.log(`${skill.profiles.username}/${skill.slug} - ${skill.name}${skill.tagline ? `: ${skill.tagline}` : ""} (${skill.downloads_count} downloads)`);
32
37
  }
33
- if (limit !== undefined && skills.length >= Number(limit)) {
38
+ if (limit !== undefined && skills.length >= limit) {
34
39
  console.log(`(showing up to ${limit} results -- pass a higher --limit for more)`);
35
40
  }
36
41
  }
@@ -14,13 +14,17 @@ function formatSize(bytes) {
14
14
  }
15
15
  return `${value.toFixed(1)} ${units[unitIndex]}`;
16
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
+ }
17
21
  export async function versions(args) {
18
22
  const jsonOutput = args.includes("--json");
19
23
  const spec = args.find((a) => !a.startsWith("--"));
20
24
  if (!spec)
21
25
  throw new UsageError(USAGE);
22
26
  const { owner, skill } = parseOwnerSkill(spec, USAGE);
23
- const { versions: list } = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions`);
27
+ const list = await listSkillVersions(owner, skill);
24
28
  if (jsonOutput) {
25
29
  console.log(JSON.stringify(list));
26
30
  return;
@@ -23,6 +23,9 @@ function openBrowser(url) {
23
23
  console.log(`Open this URL in your browser: ${url}`);
24
24
  }
25
25
  }
26
+ export async function viewSkill(owner, skill) {
27
+ return apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`);
28
+ }
26
29
  export async function view(args) {
27
30
  const jsonOutput = args.includes("--json");
28
31
  const web = args.includes("--web");
@@ -35,7 +38,7 @@ export async function view(args) {
35
38
  openBrowser(url);
36
39
  return;
37
40
  }
38
- const detail = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`);
41
+ const detail = await viewSkill(owner, skill);
39
42
  if (jsonOutput) {
40
43
  console.log(JSON.stringify(detail));
41
44
  return;
@@ -14,29 +14,20 @@ async function fetchProfile() {
14
14
  return undefined;
15
15
  }
16
16
  }
17
- export async function whoami(args = []) {
18
- const wantsJson = args.includes("--json");
17
+ // Pure auth-status check: no console output, no process.exitCode -- callers
18
+ // (the CLI's whoami() below, and the MCP whoami tool) decide how to present
19
+ // each outcome. Never throws; every failure mode is a normal return value.
20
+ export async function checkAuth() {
19
21
  const token = resolveToken();
20
22
  if (!token) {
21
- // Previously exited 0 here, defeating whoami's purpose as a scriptable
22
- // auth check -- "no token configured at all" must fail just like "token
23
- // rejected by the server" does below. Exit 4 ("authentication required
24
- // or rejected"), not the generic 1 -- this is exactly the case the
25
- // README's own exit-code table names as the canonical example of 4
26
- // (ahood-cli#80).
27
- if (wantsJson)
28
- console.log(JSON.stringify({ authenticated: false }));
29
- else
30
- console.error("Not logged in. Run `ahood login` (or set AHOOD_TOKEN).");
31
- process.exitCode = 4;
32
- return;
23
+ return { authenticated: false, reason: "not_logged_in" };
33
24
  }
34
25
  // There is no endpoint that returns an identity for a bearer caller yet, so
35
- // whoami can only answer "does this token still authenticate?". It probes
26
+ // this can only answer "does this token still authenticate?". It probes
36
27
  // /api/v1/auth/tokens, which is deliberately session-only (Task 3), and
37
28
  // reads the STATUS to tell the two failure modes apart -- swallowing every
38
- // error and printing "Authenticated." unconditionally (as this did) meant a
39
- // revoked token and a garbage token both reported success with exit code 0.
29
+ // error and reporting success unconditionally (as this used to) meant a
30
+ // revoked token and a garbage token both reported success.
40
31
  // 403 -> resolveCaller accepted the token, the route then rejected it for
41
32
  // being a token rather than a session. The token is valid.
42
33
  // 401 -> resolveCaller could not resolve the token at all: unknown,
@@ -48,38 +39,66 @@ export async function whoami(args = []) {
48
39
  // A session-backed caller (only reachable if this ever runs against a
49
40
  // cookie-bearing client) -- the token list came back.
50
41
  const profile = await fetchProfile();
51
- if (wantsJson)
52
- console.log(JSON.stringify({ authenticated: true, mode: "session", ...profile }));
53
- else
54
- console.log(profile ? `Authenticated as ${profile.username}.` : "Authenticated.");
42
+ return { authenticated: true, mode: "session", profile };
55
43
  }
56
44
  catch (error) {
57
45
  if (error instanceof ApiError && error.status === 403) {
58
46
  const profile = await fetchProfile();
59
- if (wantsJson)
60
- console.log(JSON.stringify({ authenticated: true, mode: "token", ...profile }));
61
- else
62
- console.log(profile
63
- ? `Authenticated as ${profile.username} (personal API token).`
64
- : "Authenticated with a personal API token.");
65
- return;
47
+ return { authenticated: true, mode: "token", profile };
66
48
  }
67
49
  if (error instanceof ApiError && error.status === 401) {
68
- // Same exit-4 reasoning as the "no token at all" branch above: the
69
- // token was rejected by the server, which is the other half of the
70
- // README's exit-code-4 definition ("or the token was refused").
71
- if (wantsJson)
72
- console.log(JSON.stringify({ authenticated: false, reason: "invalid_token" }));
73
- else
74
- console.error("Not authenticated -- your token is invalid or has been revoked.");
75
- process.exitCode = 4;
76
- return;
50
+ return { authenticated: false, reason: "invalid_token" };
77
51
  }
78
52
  const message = error instanceof Error ? error.message : String(error);
53
+ return { authenticated: null, error: message };
54
+ }
55
+ }
56
+ export async function whoami(args = []) {
57
+ const wantsJson = args.includes("--json");
58
+ const result = await checkAuth();
59
+ if (result.authenticated === false && result.reason === "not_logged_in") {
60
+ // Previously exited 0 here, defeating whoami's purpose as a scriptable
61
+ // auth check -- "no token configured at all" must fail just like "token
62
+ // rejected by the server" does below. Exit 4 ("authentication required
63
+ // or rejected"), not the generic 1 -- this is exactly the case the
64
+ // README's own exit-code table names as the canonical example of 4
65
+ // (ahood-cli#80).
66
+ if (wantsJson)
67
+ console.log(JSON.stringify({ authenticated: false }));
68
+ else
69
+ console.error("Not logged in. Run `ahood login` (or set AHOOD_TOKEN).");
70
+ process.exitCode = 4;
71
+ return;
72
+ }
73
+ if (result.authenticated === false && result.reason === "invalid_token") {
74
+ // Same exit-4 reasoning as the "no token at all" branch above: the
75
+ // token was rejected by the server, which is the other half of the
76
+ // README's exit-code-4 definition ("or the token was refused").
79
77
  if (wantsJson)
80
- console.log(JSON.stringify({ authenticated: null, error: message }));
78
+ console.log(JSON.stringify({ authenticated: false, reason: "invalid_token" }));
81
79
  else
82
- console.error(`Could not verify your token: ${message}`);
80
+ console.error("Not authenticated -- your token is invalid or has been revoked.");
81
+ process.exitCode = 4;
82
+ return;
83
+ }
84
+ if (result.authenticated === null) {
85
+ if (wantsJson)
86
+ console.log(JSON.stringify({ authenticated: null, error: result.error }));
87
+ else
88
+ console.error(`Could not verify your token: ${result.error}`);
83
89
  process.exitCode = 1;
90
+ return;
91
+ }
92
+ if (wantsJson) {
93
+ console.log(JSON.stringify({ authenticated: true, mode: result.mode, ...result.profile }));
94
+ return;
95
+ }
96
+ if (result.mode === "session") {
97
+ console.log(result.profile ? `Authenticated as ${result.profile.username}.` : "Authenticated.");
98
+ }
99
+ else {
100
+ console.log(result.profile
101
+ ? `Authenticated as ${result.profile.username} (personal API token).`
102
+ : "Authenticated with a personal API token.");
84
103
  }
85
104
  }
package/dist/help.js CHANGED
@@ -26,6 +26,14 @@ export const TOP_LEVEL_COMMANDS_HELP = [
26
26
  desc: "Print a shell completion script for the command names.",
27
27
  examples: ["ahood completion bash >> ~/.bashrc"],
28
28
  },
29
+ {
30
+ usage: "ahood mcp",
31
+ summary: "Start an MCP server exposing read-only skill commands as tools over stdio.",
32
+ desc: "Start a Model Context Protocol server over stdio, exposing skill_search, skill_view, skill_read, " +
33
+ "skill_versions, skill_list, skill_outdated, and whoami as MCP tools -- the same data ahood's --json " +
34
+ "commands already return, reachable as typed tool calls instead of parsed CLI output. Meant to be " +
35
+ "launched by an MCP-aware agent host (e.g. Claude Code's MCP server configuration), not run interactively.",
36
+ },
29
37
  ];
30
38
  export const SKILL_COMMANDS_HELP = [
31
39
  {
@@ -62,6 +70,17 @@ export const SKILL_COMMANDS_HELP = [
62
70
  desc: "List a skill's published-version history: version, changelog, size, and publish date. Most-recent first.",
63
71
  flags: ["--json Emit the raw version objects instead of formatted text."],
64
72
  },
73
+ {
74
+ usage: "ahood skill diff <owner>/<skill> <versionA> <versionB> [--json]",
75
+ summary: "Show what changed between two published versions -- a SKILL.md diff plus an added/removed/changed file summary.",
76
+ desc: "Show what changed between two published versions of a skill: a unified diff of SKILL.md's content, plus a " +
77
+ "summary of which files were added or removed based on the two versions' manifests (a common file's content " +
78
+ "can only be confirmed changed for SKILL.md itself, since manifests don't carry per-file checksums). Both " +
79
+ "versions must be explicit semver -- \"latest\" isn't accepted, since it could silently mean two different " +
80
+ "things if a new version is published between resolving each side.",
81
+ flags: ["--json Emit {skillmd_diff, manifest: {added, removed, changed}} instead of formatted text."],
82
+ examples: ["ahood skill diff alice/pdf-tools 1.0.0 1.1.0", "ahood skill diff alice/pdf-tools 1.0.0 1.1.0 --json"],
83
+ },
65
84
  {
66
85
  usage: "ahood skill list [--json]",
67
86
  summary: "List your own skills, public and private.",
@@ -88,6 +107,15 @@ export const SKILL_COMMANDS_HELP = [
88
107
  ],
89
108
  examples: ["ahood skill update --dry-run", "ahood skill update alice/pdf-tools --dry-run --json"],
90
109
  },
110
+ {
111
+ usage: "ahood skill outdated [<owner>/<skill> ...] [--json]",
112
+ summary: "Read-only staleness check comparing current and latest versions (with changelog) for installed skills.",
113
+ desc: "Read-only staleness check across installed skills -- current vs. latest version, plus the changelog for " +
114
+ "anything behind. With no argument, checks every installed skill; with one or more given, checks just " +
115
+ "those. Never moves a lockfile pin or installs anything; equivalent to `ahood skill update --dry-run`.",
116
+ flags: ["--json Emit structured preview objects instead of a formatted table."],
117
+ examples: ["ahood skill outdated", "ahood skill outdated alice/pdf-tools --json"],
118
+ },
91
119
  {
92
120
  usage: "ahood skill remove <owner>/<skill>",
93
121
  summary: "Uninstall and unpin a skill (local only).",
@@ -147,7 +175,7 @@ export const SKILL_COMMANDS_HELP = [
147
175
  examples: ["ahood skill init pdf-tools", "ahood skill init"],
148
176
  },
149
177
  {
150
- 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]",
178
+ 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>] [--changelog <text>] [--json]",
151
179
  summary: "Publish a new version of a skill, agent, or mcp server manifest from a folder containing SKILL.md, AGENT.md, or server.json, creating the skill first if it doesn't already exist.",
152
180
  desc: "Publish a new version of a skill, agent, or mcp server manifest from a folder containing SKILL.md, AGENT.md, or server.json. If the artifact " +
153
181
  "doesn't exist yet, this creates it first -- pass --name to set its display name, and --kind agent or --kind mcp if " +
@@ -164,6 +192,7 @@ export const SKILL_COMMANDS_HELP = [
164
192
  "--license <id> An SPDX license identifier, e.g. MIT, used only when creating.",
165
193
  "--homepage <url> The artifact's homepage URL, used only when creating.",
166
194
  "--repository <url> The artifact's source repository URL, used only when creating.",
195
+ "--changelog <text> What changed in this version, shown by `ahood skill versions`.",
167
196
  "--json Suppress human progress lines; print one {version,status,...} JSON object on completion (or {error} on failure).",
168
197
  ],
169
198
  examples: [
package/dist/index.js CHANGED
@@ -17,7 +17,9 @@ import { share, unshare } from "./commands/share.js";
17
17
  import { view } from "./commands/view.js";
18
18
  import { read } from "./commands/read.js";
19
19
  import { versions } from "./commands/versions.js";
20
+ import { diff } from "./commands/diff.js";
20
21
  import { completion } from "./commands/completion.js";
22
+ import { mcp } from "./commands/mcp.js";
21
23
  import { init } from "./commands/init.js";
22
24
  import { createGroup, listGroups, groupMembers, inviteLink, joinGroup, removeMember, leaveGroup, deleteGroup, } from "./commands/group.js";
23
25
  import { formatHelp, formatSkillHelp, formatGroupHelp, formatCommandHelp, findCommandHelp } from "./help.js";
@@ -32,9 +34,16 @@ const SKILL_COMMANDS = {
32
34
  show: view,
33
35
  read,
34
36
  versions,
37
+ diff,
35
38
  list: listSkills,
36
39
  add,
37
40
  update,
41
+ // A discoverable name for what `update --dry-run` already does (ahood-cli#91)
42
+ // -- forces --dry-run regardless of what the caller passes, so `outdated`
43
+ // can never accidentally move a pin forward. Reuses update()'s existing
44
+ // "no targets = every installed skill, explicit targets = just those"
45
+ // behavior as-is; no new logic.
46
+ outdated: (args) => update([...args, "--dry-run"]),
38
47
  remove,
39
48
  edit,
40
49
  unpublish,
@@ -69,6 +78,7 @@ const COMMANDS = {
69
78
  whoami,
70
79
  token,
71
80
  completion,
81
+ mcp,
72
82
  skill: dispatchSkill,
73
83
  group: dispatchGroup,
74
84
  };
@@ -0,0 +1,46 @@
1
+ import { ApiError, NetworkError } from "../http.js";
2
+ import { UsageError } from "../usage-error.js";
3
+ // Mirrors exit-code.ts's exitCodeFor categorization (ahood-cli#83) -- one
4
+ // error taxonomy for both the CLI's exit codes and MCP's error_code field,
5
+ // rather than maintaining two.
6
+ export function errorCodeFor(error) {
7
+ if (error instanceof UsageError)
8
+ return "usage_error";
9
+ if (error instanceof ApiError) {
10
+ if (error.status === 401 || error.status === 403)
11
+ return "auth_error";
12
+ if (error.status === 404)
13
+ return "not_found";
14
+ if (error.status >= 500)
15
+ return "network_error";
16
+ return "usage_error";
17
+ }
18
+ if (error instanceof NetworkError)
19
+ return "network_error";
20
+ return "general_error";
21
+ }
22
+ // Wraps an MCP tool's data-fetching logic so a thrown error never crosses
23
+ // the stdio boundary as a raw exception -- caught here and turned into a
24
+ // structured isError result instead. `fn` is the tool's own core function
25
+ // (e.g. searchSkills), already adapted to take the single zod-validated
26
+ // input object each MCP tool callback receives.
27
+ export function safeTool(fn) {
28
+ return async (input) => {
29
+ try {
30
+ const result = await fn(input);
31
+ // Defensive backstop: JSON.stringify(undefined) evaluates to the JS
32
+ // value `undefined`, not a string -- if any tool's core function ever
33
+ // returns undefined (e.g. a malformed API response missing an
34
+ // expected key), the MCP SDK rejects that as an invalid tool result
35
+ // and throws a raw protocol-level error instead of the structured
36
+ // isError result this wrapper exists to guarantee. Coalescing to null
37
+ // keeps `text` a string no matter what `fn` returns.
38
+ return { content: [{ type: "text", text: JSON.stringify(result ?? null) }] };
39
+ }
40
+ catch (error) {
41
+ const message = error instanceof Error ? error.message : String(error);
42
+ const body = { error: message, error_code: errorCodeFor(error) };
43
+ return { content: [{ type: "text", text: JSON.stringify(body) }], isError: true };
44
+ }
45
+ };
46
+ }
@@ -0,0 +1,20 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { registerTools } from "./tools.js";
4
+ import { CLI_VERSION } from "../version.js";
5
+ // Split from startMcpServer so tests can build a server and connect it to an
6
+ // InMemoryTransport instead of real stdio.
7
+ export function buildServer() {
8
+ const server = new McpServer({ name: "ahood", version: CLI_VERSION });
9
+ registerTools(server);
10
+ return server;
11
+ }
12
+ // Connects over stdio and blocks: StdioServerTransport reads process.stdin
13
+ // for the life of the process. This is expected -- an MCP host spawns
14
+ // `ahood mcp` as a long-lived subprocess and talks to it over stdio for as
15
+ // long as the session lasts.
16
+ export async function startMcpServer() {
17
+ const server = buildServer();
18
+ const transport = new StdioServerTransport();
19
+ await server.connect(transport);
20
+ }
@@ -0,0 +1,111 @@
1
+ import { z } from "zod";
2
+ import { safeTool } from "./safe-tool.js";
3
+ import { searchSkills } from "../commands/search.js";
4
+ import { viewSkill } from "../commands/view.js";
5
+ import { readSkillMd } from "../commands/read.js";
6
+ import { listSkillVersions } from "../commands/versions.js";
7
+ import { listOwnSkills } from "../commands/list.js";
8
+ import { checkAuth } from "../commands/whoami.js";
9
+ import { fetchVersionMeta } from "../commands/add.js";
10
+ import { validateSegment } from "../spec.js";
11
+ // MCP tool inputs arrive as separate owner/skill zod fields (not a single
12
+ // "<owner>/<skill>" spec string like the CLI's argv), so they bypass
13
+ // parseOwnerSkill entirely and go straight to the core function -- which
14
+ // URL-encodes them and hits the API. Running the same validateSegment checks
15
+ // the CLI gets via parseOwnerSkill here closes that gap: an invalid segment
16
+ // (e.g. "..") or an absurdly long one now fails locally as a UsageError
17
+ // (safeTool maps it to error_code "usage_error") instead of reaching the
18
+ // wire with the caller's bearer token.
19
+ function validateOwnerAndSkill(owner, skill) {
20
+ const spec = `${owner}/${skill}`;
21
+ validateSegment(owner, "owner", spec);
22
+ validateSegment(skill, "skill", spec);
23
+ }
24
+ // Registers every v1 MCP tool. Read-only by design (ahood-cli#83's spec) --
25
+ // each tool wraps the same core function the equivalent `ahood skill <verb>
26
+ // --json` CLI path already calls, so there is exactly one source of truth
27
+ // for what each of these returns.
28
+ export function registerTools(server) {
29
+ server.registerTool("skill_search", {
30
+ description: "Search published skills in the ahood registry.",
31
+ inputSchema: { query: z.string().min(1), limit: z.number().int().positive().optional() },
32
+ }, safeTool(async ({ query, limit }) => searchSkills(query, limit)));
33
+ server.registerTool("skill_view", {
34
+ description: "Show a skill's details (tags, license, homepage, repository, dates, etc.) without installing it.",
35
+ inputSchema: { owner: z.string().min(1), skill: z.string().min(1) },
36
+ }, safeTool(async ({ owner, skill }) => {
37
+ validateOwnerAndSkill(owner, skill);
38
+ return viewSkill(owner, skill);
39
+ }));
40
+ server.registerTool("skill_read", {
41
+ description: "Read a skill's full SKILL.md content without installing it.",
42
+ inputSchema: { owner: z.string().min(1), skill: z.string().min(1) },
43
+ }, safeTool(async ({ owner, skill }) => {
44
+ validateOwnerAndSkill(owner, skill);
45
+ return readSkillMd(owner, skill);
46
+ }));
47
+ server.registerTool("skill_versions", {
48
+ description: "List a skill's published-version history (version, changelog, size, publish date).",
49
+ inputSchema: { owner: z.string().min(1), skill: z.string().min(1) },
50
+ }, safeTool(async ({ owner, skill }) => {
51
+ validateOwnerAndSkill(owner, skill);
52
+ return listSkillVersions(owner, skill);
53
+ }));
54
+ server.registerTool("skill_list", {
55
+ description: "List the authenticated caller's own skills, public and private.",
56
+ inputSchema: {},
57
+ }, safeTool(async () => listOwnSkills()));
58
+ server.registerTool("skill_outdated", {
59
+ description: "Compare an explicit list of {owner, skill, version} pins (e.g. from an agent's own memory of what it " +
60
+ "installed via MCP calls) against the registry's latest published version for each, returning " +
61
+ "up-to-date status and changelog -- without reading or requiring any local lockfile. One entry " +
62
+ "failing to resolve (e.g. a 404 for an unpublished skill) doesn't fail the whole batch; that entry's " +
63
+ "result carries an `error` field instead.",
64
+ inputSchema: {
65
+ skills: z
66
+ .array(z.object({
67
+ owner: z.string().min(1),
68
+ skill: z.string().min(1),
69
+ version: z.string().min(1),
70
+ }))
71
+ .min(1),
72
+ },
73
+ }, safeTool(async ({ skills }) => {
74
+ const results = [];
75
+ for (const { owner, skill, version } of skills) {
76
+ const label = `${owner}/${skill}`;
77
+ try {
78
+ validateOwnerAndSkill(owner, skill);
79
+ const meta = await fetchVersionMeta(owner, skill, "latest");
80
+ const upToDate = version === meta.version;
81
+ results.push({
82
+ skill: label,
83
+ current_version: version,
84
+ latest_version: meta.version,
85
+ up_to_date: upToDate,
86
+ changelog_md: upToDate ? null : meta.changelog_md ?? null,
87
+ });
88
+ }
89
+ catch (error) {
90
+ // One skill being removed/yanked/unreachable/invalid must not abort
91
+ // resolution of the rest of the batch -- that's the whole point of
92
+ // accepting an array instead of a single owner/skill pair. Mirrors
93
+ // update.ts's own per-skill try/catch in its --dry-run loop.
94
+ const message = error instanceof Error ? error.message : String(error);
95
+ results.push({
96
+ skill: label,
97
+ current_version: version,
98
+ latest_version: null,
99
+ up_to_date: false,
100
+ changelog_md: null,
101
+ error: message,
102
+ });
103
+ }
104
+ }
105
+ return results;
106
+ }));
107
+ server.registerTool("whoami", {
108
+ description: "Report whether the configured token (AHOOD_TOKEN or a stored login) still authenticates.",
109
+ inputSchema: {},
110
+ }, safeTool(async () => checkAuth()));
111
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahood/cli",
3
- "version": "0.5.2",
3
+ "version": "0.7.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",
@@ -32,7 +32,10 @@
32
32
  "sync-readme": "node scripts/sync-readme.mjs"
33
33
  },
34
34
  "dependencies": {
35
- "tar-stream": "^3.2.1"
35
+ "@modelcontextprotocol/sdk": "^1.30.0",
36
+ "diff": "^9.0.0",
37
+ "tar-stream": "^3.2.1",
38
+ "zod": "^4.5.4"
36
39
  },
37
40
  "devDependencies": {
38
41
  "@types/node": "^20",