@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.
@@ -14,26 +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.
24
- if (wantsJson)
25
- console.log(JSON.stringify({ authenticated: false }));
26
- else
27
- console.error("Not logged in. Run `ahood login` (or set AHOOD_TOKEN).");
28
- process.exitCode = 1;
29
- return;
23
+ return { authenticated: false, reason: "not_logged_in" };
30
24
  }
31
25
  // There is no endpoint that returns an identity for a bearer caller yet, so
32
- // whoami can only answer "does this token still authenticate?". It probes
26
+ // this can only answer "does this token still authenticate?". It probes
33
27
  // /api/v1/auth/tokens, which is deliberately session-only (Task 3), and
34
28
  // reads the STATUS to tell the two failure modes apart -- swallowing every
35
- // error and printing "Authenticated." unconditionally (as this did) meant a
36
- // 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.
37
31
  // 403 -> resolveCaller accepted the token, the route then rejected it for
38
32
  // being a token rather than a session. The token is valid.
39
33
  // 401 -> resolveCaller could not resolve the token at all: unknown,
@@ -45,35 +39,66 @@ export async function whoami(args = []) {
45
39
  // A session-backed caller (only reachable if this ever runs against a
46
40
  // cookie-bearing client) -- the token list came back.
47
41
  const profile = await fetchProfile();
48
- if (wantsJson)
49
- console.log(JSON.stringify({ authenticated: true, mode: "session", ...profile }));
50
- else
51
- console.log(profile ? `Authenticated as ${profile.username}.` : "Authenticated.");
42
+ return { authenticated: true, mode: "session", profile };
52
43
  }
53
44
  catch (error) {
54
45
  if (error instanceof ApiError && error.status === 403) {
55
46
  const profile = await fetchProfile();
56
- if (wantsJson)
57
- console.log(JSON.stringify({ authenticated: true, mode: "token", ...profile }));
58
- else
59
- console.log(profile
60
- ? `Authenticated as ${profile.username} (personal API token).`
61
- : "Authenticated with a personal API token.");
62
- return;
47
+ return { authenticated: true, mode: "token", profile };
63
48
  }
64
49
  if (error instanceof ApiError && error.status === 401) {
65
- if (wantsJson)
66
- console.log(JSON.stringify({ authenticated: false, reason: "invalid_token" }));
67
- else
68
- console.error("Not authenticated -- your token is invalid or has been revoked.");
69
- process.exitCode = 1;
70
- return;
50
+ return { authenticated: false, reason: "invalid_token" };
71
51
  }
72
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").
77
+ if (wantsJson)
78
+ console.log(JSON.stringify({ authenticated: false, reason: "invalid_token" }));
79
+ else
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) {
73
85
  if (wantsJson)
74
- console.log(JSON.stringify({ authenticated: null, error: message }));
86
+ console.log(JSON.stringify({ authenticated: null, error: result.error }));
75
87
  else
76
- console.error(`Could not verify your token: ${message}`);
88
+ console.error(`Could not verify your token: ${result.error}`);
77
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.");
78
103
  }
79
104
  }
package/dist/config.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { UsageError } from "./usage-error.js";
1
2
  const DEFAULT_API_URL = "https://ahood.vercel.app";
2
3
  let warnedAboutOverride = false;
3
4
  // AHOOD_API_URL is trusted with the bearer token on every request (see
@@ -15,7 +16,7 @@ export function getApiUrl() {
15
16
  url = new URL(trimmed);
16
17
  }
17
18
  catch {
18
- throw new Error(`AHOOD_API_URL is not a valid URL: "${configured}"`);
19
+ throw new UsageError(`AHOOD_API_URL is not a valid URL: "${configured}"`);
19
20
  }
20
21
  // Plain http:// is only allowed for localhost and the RFC 2606 reserved
21
22
  // test TLDs (.test/.invalid/.example/.localhost), which can never resolve
@@ -26,7 +27,7 @@ export function getApiUrl() {
26
27
  url.hostname === "::1" ||
27
28
  /\.(test|invalid|example|localhost)$/i.test(url.hostname);
28
29
  if (url.protocol !== "https:" && !(url.protocol === "http:" && isLocalHost)) {
29
- throw new Error(`AHOOD_API_URL must use https:// (http:// is only allowed for localhost/test hosts); got "${configured}".`);
30
+ throw new UsageError(`AHOOD_API_URL must use https:// (http:// is only allowed for localhost/test hosts); got "${configured}".`);
30
31
  }
31
32
  if (trimmed !== DEFAULT_API_URL && !warnedAboutOverride) {
32
33
  console.error(`warning: using non-default API endpoint from AHOOD_API_URL: ${trimmed}`);
package/dist/exit-code.js CHANGED
@@ -1,10 +1,19 @@
1
1
  import { ApiError, NetworkError } from "./http.js";
2
+ import { UsageError } from "./usage-error.js";
2
3
  // A hung/black-holed connection throws undici's generic TypeError("fetch
3
4
  // failed") -- http.ts wraps that into NetworkError so it gets its own exit
4
5
  // code here, distinct from a server-returned ApiError. Kept in its own
5
6
  // module (rather than inline in index.ts) so it's importable in tests
6
7
  // without triggering index.ts's unconditional top-level main() call.
7
8
  export function exitCodeFor(error) {
9
+ // A local validation failure (missing/malformed argument, bad flag value)
10
+ // is exactly the "bad arguments" half of exit code 2's documented meaning
11
+ // -- same code the server-rejected branch below already uses, so a caller
12
+ // branching on exit code sees one consistent "fix your input" signal
13
+ // regardless of whether the check ran locally or round-tripped to the
14
+ // server (ahood-cli#80).
15
+ if (error instanceof UsageError)
16
+ return 2;
8
17
  if (error instanceof ApiError) {
9
18
  if (error.status === 401 || error.status === 403)
10
19
  return 4;
package/dist/flags.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { UsageError } from "./usage-error.js";
1
2
  // Shared flag-value parsing. Throws instead of silently treating the next flag
2
3
  // as this flag's value (e.g. `--tagline --visibility public` used to send
3
4
  // "--visibility" as the tagline and drop --visibility entirely).
@@ -20,7 +21,7 @@ export function flagValue(args, flag) {
20
21
  if (arg === flag) {
21
22
  const value = args[i + 1];
22
23
  if (value === undefined || value.startsWith("--")) {
23
- throw new Error(`${flag} requires a value.`);
24
+ throw new UsageError(`${flag} requires a value.`);
24
25
  }
25
26
  return value;
26
27
  }
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, and whoami as MCP tools -- the same data ahood's --json commands already " +
34
+ "return, reachable as typed tool calls instead of parsed CLI output. Meant to be launched by an " +
35
+ "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
  {
@@ -71,7 +79,11 @@ export const SKILL_COMMANDS_HELP = [
71
79
  {
72
80
  usage: "ahood skill add <owner>/<skill>[@version]",
73
81
  summary: "Install a skill into .claude/skills/, pinned in the lockfile.",
74
- desc: "Install a skill into .claude/skills/, pinned in the lockfile.",
82
+ desc: "Install a skill into .claude/skills/, pinned in the lockfile. An artifact published with --kind agent " +
83
+ "installs instead as a single file at .claude/agents/<owner>@<skill>.md (Claude Code's own subagent " +
84
+ "loader scans .claude/agents/*.md as flat files), not a .claude/skills/ directory. An artifact published " +
85
+ "with --kind mcp installs by merging an entry into .mcp.json instead, prompting for any secret " +
86
+ "environment variables its server.json declares that aren't already set in your shell.",
75
87
  examples: ["ahood skill add alice/pdf-tools", "ahood skill add alice/pdf-tools@1.2.0"],
76
88
  },
77
89
  {
@@ -143,20 +155,23 @@ export const SKILL_COMMANDS_HELP = [
143
155
  examples: ["ahood skill init pdf-tools", "ahood skill init"],
144
156
  },
145
157
  {
146
- usage: "ahood skill publish <owner>/<skill>@<version> [--path <dir>] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--json]",
147
- summary: "Publish a new version of a skill from a folder containing SKILL.md, creating the skill first if it doesn't already exist.",
148
- desc: "Publish a new version of a skill from a folder containing SKILL.md. If the skill doesn't exist yet, this " +
149
- "creates it first -- pass --name (required in that case) and optionally --tagline/--tags/--license/--homepage/--repository. " +
158
+ 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]",
159
+ 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.",
160
+ 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 " +
161
+ "doesn't exist yet, this creates it first -- pass --name to set its display name, and --kind agent or --kind mcp if " +
162
+ "publishing an AGENT.md or server.json (auto-detected from the folder contents when only one of SKILL.md/AGENT.md/server.json is present). " +
163
+ "--name is required when creating; optionally also pass --tagline/--tags/--license/--homepage/--repository. " +
150
164
  "Processing happens server-side after upload; this command polls and reports the final published/failed status. " +
151
165
  "The legacy form `ahood skill publish <path> --owner <owner> --slug <skill> --version <x.y.z>` is still accepted. " +
152
166
  "Every flag also accepts the --flag=value form, needed when a value itself starts with --.",
153
167
  flags: [
154
- "--name <text> Required only when creating the skill on this publish.",
168
+ "--kind skill|agent|mcp What kind of artifact this is. Auto-detected from SKILL.md/AGENT.md/server.json when omitted.",
169
+ "--name <text> Required only when creating the artifact on this publish.",
155
170
  "--tagline <text> Short one-line description, used only when creating.",
156
171
  "--tags <comma,separated> Initial tags, used only when creating.",
157
172
  "--license <id> An SPDX license identifier, e.g. MIT, used only when creating.",
158
- "--homepage <url> The skill's homepage URL, used only when creating.",
159
- "--repository <url> The skill's source repository URL, used only when creating.",
173
+ "--homepage <url> The artifact's homepage URL, used only when creating.",
174
+ "--repository <url> The artifact's source repository URL, used only when creating.",
160
175
  "--json Suppress human progress lines; print one {version,status,...} JSON object on completion (or {error} on failure).",
161
176
  ],
162
177
  examples: [
@@ -278,10 +293,74 @@ export function formatCommandHelp(entry) {
278
293
  lines.push("", "Examples:", ...entry.examples.map((e) => ` ${e}`));
279
294
  return lines.join("\n");
280
295
  }
296
+ const FALLBACK_TERMINAL_WIDTH = 80;
297
+ // Piped/redirected output (a script capturing `ahood skill --help`, a test)
298
+ // has no TTY and so no column count -- falls back to a fixed width rather
299
+ // than wrapping to whatever width happened to be inherited, so scripted use
300
+ // gets stable, repeatable output.
301
+ function terminalWidth() {
302
+ const columns = process.stdout.columns;
303
+ return columns && columns > 20 ? columns : FALLBACK_TERMINAL_WIDTH;
304
+ }
305
+ // Greedy word wrap -- doesn't split words, so a single word longer than
306
+ // `width` is left on its own overlength line rather than being cut mid-word.
307
+ function wrapText(text, width) {
308
+ if (width < 10)
309
+ return [text];
310
+ const words = text.split(" ");
311
+ const lines = [];
312
+ let current = "";
313
+ for (const word of words) {
314
+ const candidate = current ? `${current} ${word}` : word;
315
+ if (candidate.length > width && current) {
316
+ lines.push(current);
317
+ current = word;
318
+ }
319
+ else {
320
+ current = candidate;
321
+ }
322
+ }
323
+ if (current)
324
+ lines.push(current);
325
+ return lines;
326
+ }
327
+ // Longest usage string an aligned label column will stretch to accommodate
328
+ // -- past this, one outlier (e.g. `skill publish`'s usage line, with every
329
+ // flag spelled out) would otherwise push every other row's description
330
+ // column out with it, which is what made `ahood skill --help` wrap to
331
+ // 300+ characters per line with no relation to the terminal's actual width
332
+ // (ahood-cli#81). A row whose usage is longer than this gets its summary on
333
+ // its own indented line instead of sharing the row.
334
+ const MAX_LABEL_COLUMN = 50;
335
+ // Renders a two-column command table (usage on the left, one-line summary on
336
+ // the right, aligned and wrapped to the terminal width) -- shared by
337
+ // formatSkillHelp/formatGroupHelp/formatHelp so all three `--help` listings
338
+ // wrap the same way.
339
+ function formatCommandTable(rows) {
340
+ const longest = Math.max(...rows.map((r) => r.usage.length));
341
+ const labelWidth = Math.min(longest, MAX_LABEL_COLUMN);
342
+ const columnWidth = labelWidth + 4; // 2-space left margin + 2-space gutter after the label
343
+ const summaryWidth = Math.max(20, terminalWidth() - columnWidth);
344
+ const indent = " ".repeat(columnWidth);
345
+ const lines = [];
346
+ for (const row of rows) {
347
+ const summaryLines = wrapText(row.summary, summaryWidth);
348
+ if (row.usage.length <= labelWidth) {
349
+ lines.push(` ${row.usage.padEnd(labelWidth + 2)}${summaryLines[0]}`);
350
+ }
351
+ else {
352
+ lines.push(` ${row.usage}`);
353
+ lines.push(`${indent}${summaryLines[0]}`);
354
+ }
355
+ for (const continuation of summaryLines.slice(1)) {
356
+ lines.push(`${indent}${continuation}`);
357
+ }
358
+ }
359
+ return lines;
360
+ }
281
361
  // `ahood skill --help` -- the group-level listing for every skill verb.
282
362
  export function formatSkillHelp() {
283
- const width = Math.max(...SKILL_COMMANDS_HELP.map((c) => usageWithAliases(c).length));
284
- const lines = SKILL_COMMANDS_HELP.map((c) => ` ${usageWithAliases(c).padEnd(width + 2)}${c.summary}`);
363
+ const lines = formatCommandTable(SKILL_COMMANDS_HELP.map((c) => ({ usage: usageWithAliases(c), summary: c.summary })));
285
364
  return [
286
365
  "ahood skill -- manage skills in the ahood registry",
287
366
  "",
@@ -293,8 +372,7 @@ export function formatSkillHelp() {
293
372
  }
294
373
  // `ahood group --help` -- the group-level listing for every group verb.
295
374
  export function formatGroupHelp() {
296
- const width = Math.max(...GROUP_COMMANDS_HELP.map((c) => usageWithAliases(c).length));
297
- const lines = GROUP_COMMANDS_HELP.map((c) => ` ${usageWithAliases(c).padEnd(width + 2)}${c.summary}`);
375
+ const lines = formatCommandTable(GROUP_COMMANDS_HELP.map((c) => ({ usage: usageWithAliases(c), summary: c.summary })));
298
376
  return [
299
377
  "ahood group -- create private groups, invite members, and share skills with them",
300
378
  "",
@@ -312,12 +390,11 @@ export function formatHelp() {
312
390
  const skillGroupSummary = "Search, install, and publish skills -- run `ahood skill --help` for the full list.";
313
391
  const groupGroupUsage = "ahood group <command>";
314
392
  const groupGroupSummary = "Create private groups and share skills with them -- run `ahood group --help` for the full list.";
315
- const width = Math.max(...TOP_LEVEL_COMMANDS_HELP.map((c) => usageWithAliases(c).length), skillGroupUsage.length, groupGroupUsage.length);
316
- const lines = [
317
- ...TOP_LEVEL_COMMANDS_HELP.map((c) => ` ${usageWithAliases(c).padEnd(width + 2)}${c.summary}`),
318
- ` ${skillGroupUsage.padEnd(width + 2)}${skillGroupSummary}`,
319
- ` ${groupGroupUsage.padEnd(width + 2)}${groupGroupSummary}`,
320
- ];
393
+ const lines = formatCommandTable([
394
+ ...TOP_LEVEL_COMMANDS_HELP.map((c) => ({ usage: usageWithAliases(c), summary: c.summary })),
395
+ { usage: skillGroupUsage, summary: skillGroupSummary },
396
+ { usage: groupGroupUsage, summary: groupGroupSummary },
397
+ ]);
321
398
  return [
322
399
  "ahood -- CLI for the ahood skills registry (https://ahood.vercel.app)",
323
400
  "",
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ import { view } from "./commands/view.js";
18
18
  import { read } from "./commands/read.js";
19
19
  import { versions } from "./commands/versions.js";
20
20
  import { completion } from "./commands/completion.js";
21
+ import { mcp } from "./commands/mcp.js";
21
22
  import { init } from "./commands/init.js";
22
23
  import { createGroup, listGroups, groupMembers, inviteLink, joinGroup, removeMember, leaveGroup, deleteGroup, } from "./commands/group.js";
23
24
  import { formatHelp, formatSkillHelp, formatGroupHelp, formatCommandHelp, findCommandHelp } from "./help.js";
@@ -69,6 +70,7 @@ const COMMANDS = {
69
70
  whoami,
70
71
  token,
71
72
  completion,
73
+ mcp,
72
74
  skill: dispatchSkill,
73
75
  group: dispatchGroup,
74
76
  };
package/dist/lockfile.js CHANGED
@@ -15,20 +15,29 @@ export function readLockfile(path) {
15
15
  throw new Error(`Lockfile at ${path} is corrupted and could not be parsed as JSON. Fix or delete it before continuing.`);
16
16
  }
17
17
  }
18
- function writeLockfile(path, lockfile) {
18
+ // Generalized so callers other than the lockfile itself (e.g. add.ts's
19
+ // .mcp.json merge, which sits right next to the lockfile and can hold other
20
+ // servers' secrets) get the same crash-safety guarantee instead of a bare
21
+ // writeFileSync that risks truncating the file on interruption.
22
+ export function writeJsonFileAtomic(path, data) {
19
23
  mkdirSync(dirname(path), { recursive: true });
20
24
  // Write-then-rename so a process interrupted mid-write never leaves a
21
- // truncated lockfile on disk -- a reader always sees either the old or the
22
- // new complete content, never a partial one.
25
+ // truncated file on disk -- a reader always sees either the old or the new
26
+ // complete content, never a partial one.
23
27
  const tmpPath = `${path}.tmp-${process.pid}-${process.hrtime.bigint()}`;
24
- writeFileSync(tmpPath, JSON.stringify(lockfile, null, 2) + "\n");
28
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n");
25
29
  renameSync(tmpPath, path);
26
30
  }
31
+ function writeLockfile(path, lockfile) {
32
+ writeJsonFileAtomic(path, lockfile);
33
+ }
27
34
  // Simple advisory lock via mkdir's atomicity (EEXIST on a second caller),
28
- // so two concurrent `ahood add`/`remove` invocations against the same
35
+ // so two concurrent `ahood skill add`/`remove` invocations against the same
29
36
  // project don't race a read-modify-write and silently drop one another's
30
- // entry.
31
- function withLock(path, fn) {
37
+ // entry. Exported so other same-directory JSON files with the same
38
+ // read-modify-write shape (e.g. add.ts's .mcp.json merge) can reuse it
39
+ // instead of duplicating the pattern.
40
+ export function withLock(path, fn) {
32
41
  mkdirSync(dirname(path), { recursive: true });
33
42
  const lockDir = `${path}.lock`;
34
43
  const deadline = Date.now() + 5000;
@@ -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,61 @@
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 { validateSegment } from "../spec.js";
10
+ // MCP tool inputs arrive as separate owner/skill zod fields (not a single
11
+ // "<owner>/<skill>" spec string like the CLI's argv), so they bypass
12
+ // parseOwnerSkill entirely and go straight to the core function -- which
13
+ // URL-encodes them and hits the API. Running the same validateSegment checks
14
+ // the CLI gets via parseOwnerSkill here closes that gap: an invalid segment
15
+ // (e.g. "..") or an absurdly long one now fails locally as a UsageError
16
+ // (safeTool maps it to error_code "usage_error") instead of reaching the
17
+ // wire with the caller's bearer token.
18
+ function validateOwnerAndSkill(owner, skill) {
19
+ const spec = `${owner}/${skill}`;
20
+ validateSegment(owner, "owner", spec);
21
+ validateSegment(skill, "skill", spec);
22
+ }
23
+ // Registers every v1 MCP tool. Read-only by design (ahood-cli#83's spec) --
24
+ // each tool wraps the same core function the equivalent `ahood skill <verb>
25
+ // --json` CLI path already calls, so there is exactly one source of truth
26
+ // for what each of these returns.
27
+ export function registerTools(server) {
28
+ server.registerTool("skill_search", {
29
+ description: "Search published skills in the ahood registry.",
30
+ inputSchema: { query: z.string().min(1), limit: z.number().int().positive().optional() },
31
+ }, safeTool(async ({ query, limit }) => searchSkills(query, limit)));
32
+ server.registerTool("skill_view", {
33
+ description: "Show a skill's details (tags, license, homepage, repository, dates, etc.) without installing it.",
34
+ inputSchema: { owner: z.string().min(1), skill: z.string().min(1) },
35
+ }, safeTool(async ({ owner, skill }) => {
36
+ validateOwnerAndSkill(owner, skill);
37
+ return viewSkill(owner, skill);
38
+ }));
39
+ server.registerTool("skill_read", {
40
+ description: "Read a skill's full SKILL.md content without installing it.",
41
+ inputSchema: { owner: z.string().min(1), skill: z.string().min(1) },
42
+ }, safeTool(async ({ owner, skill }) => {
43
+ validateOwnerAndSkill(owner, skill);
44
+ return readSkillMd(owner, skill);
45
+ }));
46
+ server.registerTool("skill_versions", {
47
+ description: "List a skill's published-version history (version, changelog, size, publish date).",
48
+ inputSchema: { owner: z.string().min(1), skill: z.string().min(1) },
49
+ }, safeTool(async ({ owner, skill }) => {
50
+ validateOwnerAndSkill(owner, skill);
51
+ return listSkillVersions(owner, skill);
52
+ }));
53
+ server.registerTool("skill_list", {
54
+ description: "List the authenticated caller's own skills, public and private.",
55
+ inputSchema: {},
56
+ }, safeTool(async () => listOwnSkills()));
57
+ server.registerTool("whoami", {
58
+ description: "Report whether the configured token (AHOOD_TOKEN or a stored login) still authenticates.",
59
+ inputSchema: {},
60
+ }, safeTool(async () => checkAuth()));
61
+ }
@@ -0,0 +1,74 @@
1
+ import { createInterface } from "node:readline";
2
+ // Masked secret-value prompt, extending confirm.ts's TTY-safe "close" race
3
+ // (see confirm.ts's own comment): the same closed-stdin-never-resolves
4
+ // problem, and the same fix -- a "close" listener rejects if it fires
5
+ // before an answer is captured. Kept as its own module rather than folded
6
+ // into confirm.ts because that module's contract is yes/no, not a typed
7
+ // value, and because masking requires raw-mode keystroke handling that a
8
+ // plain rl.question() call cannot do (readline always echoes verbatim when
9
+ // its input is a TTY).
10
+ export function promptSecret(promptText) {
11
+ const stdin = process.stdin;
12
+ if (!stdin.isTTY) {
13
+ // Non-interactive stdin (piped input, CI, or these tests' fake
14
+ // Readable): fall back to an ordinary line-buffered question. There is
15
+ // no keystroke stream to mask in this mode -- the whole line arrives as
16
+ // one chunk -- and masking would only hide a value that's already
17
+ // sitting unmasked in whatever produced the pipe.
18
+ const rl = createInterface({ input: stdin, output: process.stdout });
19
+ return new Promise((resolve, reject) => {
20
+ let settled = false;
21
+ rl.once("close", () => {
22
+ if (settled)
23
+ return;
24
+ settled = true;
25
+ reject(new Error("Prompt was not answered (stdin closed)."));
26
+ });
27
+ rl.question(promptText, (answer) => {
28
+ settled = true;
29
+ rl.close();
30
+ resolve(answer);
31
+ });
32
+ });
33
+ }
34
+ process.stdout.write(promptText);
35
+ stdin.setRawMode(true);
36
+ stdin.resume();
37
+ stdin.setEncoding("utf8");
38
+ return new Promise((resolve, reject) => {
39
+ let value = "";
40
+ function cleanup() {
41
+ stdin.removeListener("data", onData);
42
+ stdin.setRawMode(false);
43
+ stdin.pause();
44
+ }
45
+ function onData(chunk) {
46
+ for (const char of chunk) {
47
+ if (char === "\n" || char === "\r") {
48
+ cleanup();
49
+ process.stdout.write("\n");
50
+ resolve(value);
51
+ return;
52
+ }
53
+ if (char === "\u0003") {
54
+ // Ctrl-C
55
+ cleanup();
56
+ process.stdout.write("\n");
57
+ reject(new Error("Prompt cancelled (Ctrl-C)."));
58
+ return;
59
+ }
60
+ if (char === "\u007f" || char === "\b") {
61
+ // Backspace/delete
62
+ if (value.length > 0) {
63
+ value = value.slice(0, -1);
64
+ process.stdout.write("\b \b");
65
+ }
66
+ continue;
67
+ }
68
+ value += char;
69
+ process.stdout.write("*");
70
+ }
71
+ }
72
+ stdin.on("data", onData);
73
+ });
74
+ }