@ahood/cli 0.5.2 → 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.
- package/README.md +2 -0
- package/dist/commands/list.js +5 -1
- package/dist/commands/mcp.js +7 -0
- package/dist/commands/read.js +25 -8
- package/dist/commands/search.js +13 -8
- package/dist/commands/versions.js +5 -1
- package/dist/commands/view.js +4 -1
- package/dist/commands/whoami.js +58 -39
- package/dist/help.js +8 -0
- package/dist/index.js +2 -0
- package/dist/mcp/safe-tool.js +46 -0
- package/dist/mcp/server.js +20 -0
- package/dist/mcp/tools.js +61 -0
- package/package.json +4 -2
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
|
|
|
@@ -273,6 +274,7 @@ ahood completion fish > ~/.config/fish/completions/ahood.fish
|
|
|
273
274
|
- **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
275
|
- **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
276
|
- **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.
|
|
277
|
+
- **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
278
|
|
|
277
279
|
## Development
|
|
278
280
|
|
package/dist/commands/list.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/dist/commands/read.js
CHANGED
|
@@ -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
|
|
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
|
|
43
|
+
console.log(JSON.stringify({ version, content }));
|
|
27
44
|
return;
|
|
28
45
|
}
|
|
29
46
|
// Plain mode prints the raw content verbatim -- no formatting/labels/
|
package/dist/commands/search.js
CHANGED
|
@@ -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
|
|
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 (
|
|
16
|
-
throw new UsageError(`--limit must be a positive integer (got "${
|
|
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
|
|
19
|
-
|
|
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 >=
|
|
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
|
|
27
|
+
const list = await listSkillVersions(owner, skill);
|
|
24
28
|
if (jsonOutput) {
|
|
25
29
|
console.log(JSON.stringify(list));
|
|
26
30
|
return;
|
package/dist/commands/view.js
CHANGED
|
@@ -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
|
|
41
|
+
const detail = await viewSkill(owner, skill);
|
|
39
42
|
if (jsonOutput) {
|
|
40
43
|
console.log(JSON.stringify(detail));
|
|
41
44
|
return;
|
package/dist/commands/whoami.js
CHANGED
|
@@ -14,29 +14,20 @@ async function fetchProfile() {
|
|
|
14
14
|
return undefined;
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
39
|
-
// revoked token and a garbage token both reported success
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
78
|
+
console.log(JSON.stringify({ authenticated: false, reason: "invalid_token" }));
|
|
81
79
|
else
|
|
82
|
-
console.error(
|
|
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, 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
|
{
|
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
|
};
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ahood/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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,9 @@
|
|
|
32
32
|
"sync-readme": "node scripts/sync-readme.mjs"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"
|
|
35
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
36
|
+
"tar-stream": "^3.2.1",
|
|
37
|
+
"zod": "^4.5.4"
|
|
36
38
|
},
|
|
37
39
|
"devDependencies": {
|
|
38
40
|
"@types/node": "^20",
|