@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.
- package/README.md +6 -3
- package/dist/commands/add.js +265 -9
- package/dist/commands/completion.js +2 -1
- package/dist/commands/edit.js +5 -4
- package/dist/commands/group.js +8 -7
- package/dist/commands/init.js +8 -7
- package/dist/commands/list.js +5 -1
- package/dist/commands/mcp.js +7 -0
- package/dist/commands/publish.js +52 -13
- package/dist/commands/read.js +26 -8
- package/dist/commands/remove.js +44 -5
- package/dist/commands/search.js +17 -11
- package/dist/commands/share.js +5 -4
- package/dist/commands/star.js +2 -2
- package/dist/commands/token.js +4 -3
- package/dist/commands/unpublish.js +5 -4
- package/dist/commands/update.js +20 -3
- package/dist/commands/versions.js +8 -3
- package/dist/commands/view.js +8 -4
- package/dist/commands/whoami.js +58 -33
- package/dist/config.js +3 -2
- package/dist/exit-code.js +9 -0
- package/dist/flags.js +2 -1
- package/dist/help.js +95 -18
- package/dist/index.js +2 -0
- package/dist/lockfile.js +16 -7
- package/dist/mcp/safe-tool.js +46 -0
- package/dist/mcp/server.js +20 -0
- package/dist/mcp/tools.js +61 -0
- package/dist/secret-prompt.js +74 -0
- package/dist/spec.js +33 -9
- package/dist/usage-error.js +8 -0
- package/package.json +4 -2
package/dist/commands/whoami.js
CHANGED
|
@@ -14,26 +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.
|
|
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
|
-
//
|
|
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
|
|
36
|
-
// 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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
86
|
+
console.log(JSON.stringify({ authenticated: null, error: result.error }));
|
|
75
87
|
else
|
|
76
|
-
console.error(`Could not verify your token: ${
|
|
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
|
|
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
|
|
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
|
|
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
|
|
149
|
-
"creates it first -- pass --name
|
|
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
|
-
"--
|
|
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
|
|
159
|
-
"--repository <url> The
|
|
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
|
|
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
|
|
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
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
|
22
|
-
//
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|