@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 CHANGED
@@ -81,12 +81,13 @@ Requires Node.js 18 or later.
81
81
  | `AHOOD_API_URL` | Overrides the registry endpoint (default: `https://ahood.vercel.app`). Must be `https://`, except for `localhost`/`127.0.0.1` or a `.test`/`.invalid`/`.example`/`.localhost` host, which may use plain `http://` for local development. |
82
82
  | `XDG_CONFIG_HOME` | If set, browser-login credentials are stored under `$XDG_CONFIG_HOME/ahood/credentials.json` instead of the default `~/.config/ahood/credentials.json`. |
83
83
 
84
- Two files `ahood` reads and writes in your project, neither of which needs to be gitignored (the lockfile is meant to be committed, same as `package-lock.json`):
84
+ Paths `ahood` reads and writes in your project, none of which need to be gitignored (the lockfile is meant to be committed, same as `package-lock.json`):
85
85
 
86
86
  | Path | What's in it |
87
87
  | --- | --- |
88
88
  | `.claude/skills/<owner>/<skill>/` | Installed skill files. |
89
- | `.claude/skills.lock.json` | Exact installed version + checksum per skill, written by `add`/`update`, read by every install to verify integrity. |
89
+ | `.claude/agents/<owner>@<skill>.md` | An installed agent, as a single file (`@` owner/skill separator, since Claude Code's own subagent loader scans `.claude/agents/*.md` flat, non-recursively). |
90
+ | `.claude/skills.lock.json` | Exact installed version + checksum per skill or agent, written by `add`/`update`, read by every install to verify integrity. |
90
91
 
91
92
  ## Usage examples
92
93
 
@@ -200,6 +201,7 @@ Sharing is additive -- it doesn't change `alice/pdf-tools`'s own public/private
200
201
  | `ahood whoami [--json]` | Reports whether your stored token still authenticates. |
201
202
  | `ahood token create <name>\|list [--json]\|revoke <id> [--yes]` | Manage personal API tokens. |
202
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. |
203
205
 
204
206
  ### Skill
205
207
 
@@ -220,7 +222,7 @@ Sharing is additive -- it doesn't change `alice/pdf-tools`'s own public/private
220
222
  | `ahood skill share <owner>/<skill> --group <group>` | Share a skill you own with a group, without changing its public/private visibility. |
221
223
  | `ahood skill unshare <owner>/<skill> --group <group>` | Stop sharing a skill you own with a group. |
222
224
  | `ahood skill init [name]` | Scaffold a new skill folder with a minimal, valid SKILL.md. |
223
- | `ahood skill publish <owner>/<skill>@<version> [--path <dir>] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--json]` | Publish a new version of a skill from a folder containing SKILL.md, creating the skill first if it doesn't already exist. |
225
+ | `ahood skill publish <owner>/<skill>@<version> [--path <dir>] [--kind skill\|agent\|mcp] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--json]` | Publish a new version of a skill, agent, or mcp server manifest from a folder containing SKILL.md, AGENT.md, or server.json, creating the skill first if it doesn't already exist. |
224
226
 
225
227
  ### Group
226
228
 
@@ -272,6 +274,7 @@ ahood completion fish > ~/.config/fish/completions/ahood.fish
272
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.
273
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.
274
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.
275
278
 
276
279
  ## Development
277
280
 
@@ -1,12 +1,14 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdirSync, renameSync, rmSync, rmdirSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, renameSync, rmSync, rmdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
3
3
  import { basename, dirname, join, resolve, sep } from "node:path";
4
4
  import * as tarStream from "tar-stream";
5
5
  import { gunzipSync } from "node:zlib";
6
6
  import { apiFetch, apiJson } from "../http.js";
7
- import { LOCKFILE_PATH, parseOwnerSkillVersion, skillDir } from "../spec.js";
8
- import { readLockfile, writeLockfileEntry } from "../lockfile.js";
9
- const USAGE = "Usage: ahood add <owner>/<skill>[@version]";
7
+ import { LOCKFILE_PATH, parseOwnerSkillVersion, skillDir, agentPath, AGENTS_ROOT, MCP_CONFIG_PATH } from "../spec.js";
8
+ import { readLockfile, writeLockfileEntry, withLock, writeJsonFileAtomic } from "../lockfile.js";
9
+ import { promptSecret } from "../secret-prompt.js";
10
+ import { UsageError } from "../usage-error.js";
11
+ const USAGE = "Usage: ahood skill add <owner>/<skill>[@version]";
10
12
  const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024; // 50 MB compressed
11
13
  const MAX_EXTRACTED_BYTES = 200 * 1024 * 1024; // 200 MB decompressed
12
14
  const MAX_ENTRY_COUNT = 10_000;
@@ -24,10 +26,16 @@ const MAX_ENTRY_COUNT = 10_000;
24
26
  // rather than assumed.
25
27
  export async function fetchVersionMeta(owner, skill, version) {
26
28
  if (version === "latest") {
27
- const { skill_versions } = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`);
29
+ // `kind` comes back as a TOP-LEVEL sibling of `skill_versions` on this
30
+ // route (GET /api/v1/skills/{owner}/{skill}), not nested inside it --
31
+ // confirmed against the real route.ts response shape. Destructuring only
32
+ // `skill_versions` (as this used to do) silently drops `kind`, which
33
+ // makes an agent install fall through to the skill-directory path with
34
+ // no error anywhere (ahood-cli final review finding #1).
35
+ const { skill_versions, kind } = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`);
28
36
  if (!skill_versions)
29
37
  throw new Error(`${owner}/${skill} has no published version`);
30
- return { ...skill_versions, yanked_at: null };
38
+ return { ...skill_versions, kind, yanked_at: null };
31
39
  }
32
40
  return apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions/${encodeURIComponent(version)}`);
33
41
  }
@@ -163,10 +171,240 @@ async function extractFreshVersion(buffer, destDir) {
163
171
  rmSync(destDir, { recursive: true, force: true });
164
172
  renameSync(tempDir, destDir);
165
173
  }
174
+ // Agent packages install as one file, not a directory (Claude Code's own
175
+ // .claude/agents/*.md convention is flat, non-recursive) -- extracts just
176
+ // AGENT.md's content from the downloaded tarball rather than writing every
177
+ // entry to disk the way extractFreshVersion does for skills.
178
+ async function extractSingleFileContent(buffer, entryName) {
179
+ let decompressed;
180
+ try {
181
+ // Same decompression-bomb guard as extractTarGz above: a highly-
182
+ // compressed payload whose checksum matches (that check runs on the
183
+ // compressed bytes) must not be allowed to allocate unboundedly on
184
+ // decompression.
185
+ decompressed = gunzipSync(buffer, { maxOutputLength: MAX_EXTRACTED_BYTES });
186
+ }
187
+ catch (error) {
188
+ throw new Error(`Archive decompresses to more than ${MAX_EXTRACTED_BYTES / (1024 * 1024)} MB, or is not valid gzip -- refusing to extract.`, { cause: error });
189
+ }
190
+ const extract = tarStream.extract();
191
+ let found = null;
192
+ await new Promise((resolvePromise, reject) => {
193
+ extract.on("entry", (header, stream, next) => {
194
+ // Normalize a leading "./" the same way extractTarGz and the server's
195
+ // extract-and-checksum.ts normalizeEntryPath do -- a plain `tar czf`
196
+ // commonly prefixes entries with "./", which would otherwise pass
197
+ // server-side validation/publish but then fail to install here with
198
+ // "AGENT.md not found" (ahood-cli final review finding #3).
199
+ const normalizedName = header.name.replace(/^\.\//, "");
200
+ if (normalizedName !== entryName) {
201
+ stream.resume();
202
+ next();
203
+ return;
204
+ }
205
+ // Only take the FIRST matching entry, mirroring the server side's
206
+ // `files.find(...)` lookup -- otherwise a tar with two entries at the
207
+ // same normalized name would resolve to whichever one happens to come
208
+ // last here, diverging from what the server validated against
209
+ // (ahood-cli final review finding #4).
210
+ if (found !== null) {
211
+ stream.resume();
212
+ next();
213
+ return;
214
+ }
215
+ const chunks = [];
216
+ stream.on("data", (chunk) => chunks.push(chunk));
217
+ stream.on("end", () => {
218
+ found = Buffer.concat(chunks);
219
+ next();
220
+ });
221
+ stream.on("error", reject);
222
+ });
223
+ extract.on("finish", () => resolvePromise());
224
+ extract.on("error", reject);
225
+ extract.end(decompressed);
226
+ });
227
+ if (!found)
228
+ throw new Error(`${entryName} not found in the downloaded archive`);
229
+ return found;
230
+ }
231
+ // Every manifest field used below reaches the terminal (an error message, or
232
+ // -- worse -- the text printed immediately before a masked secret prompt)
233
+ // but comes verbatim from a third-party-published server.json: server-side
234
+ // validation only checks these are strings, not that they're free of
235
+ // control/escape characters. A malicious description could otherwise smuggle
236
+ // a terminal control sequence (cursor movement, line-clear) that repaints
237
+ // what the user sees at the exact moment they're about to type a credential.
238
+ // Strips C0/C1 control characters (including ESC, \x1b) and caps length so a
239
+ // single field can't also flood the terminal.
240
+ function sanitizeForTerminal(text) {
241
+ return text.replace(/[\x00-\x1f\x7f-\x9f]/g, " ").slice(0, 200);
242
+ }
243
+ // Only npm+npx is supported in v1 (docs/superpowers/specs/2026-09-03-mcp-server-artifacts-design.md) --
244
+ // server-side publish validation (lib/publish/parse-server-manifest.ts) already
245
+ // rejects anything else, so this check is defense-in-depth against a
246
+ // manifest that somehow reached this point unvalidated, same posture as
247
+ // extractTarGz's own re-check of path containment "even though the server's
248
+ // publish-time validateEntries is supposed to have rejected it already."
249
+ function buildMcpServerConfig(manifest, env) {
250
+ const manifestName = sanitizeForTerminal(manifest.name);
251
+ if (manifest.packages && manifest.packages.length === 1) {
252
+ const pkg = manifest.packages[0];
253
+ if (pkg.registry_type !== "npm" || pkg.runtime_hint !== "npx") {
254
+ throw new Error(`${manifestName}'s server.json uses registry_type "${pkg.registry_type}"/runtime_hint "${pkg.runtime_hint}", which this version of ahood does not know how to install (only npm+npx is supported).`);
255
+ }
256
+ const config = { command: "npx", args: ["-y", `${pkg.identifier}@${pkg.version}`] };
257
+ if (Object.keys(env).length > 0)
258
+ config.env = env;
259
+ return config;
260
+ }
261
+ if (manifest.remotes && manifest.remotes.length === 1) {
262
+ const remote = manifest.remotes[0];
263
+ return remote.headers ? { url: remote.url, headers: remote.headers } : { url: remote.url };
264
+ }
265
+ throw new Error(`${manifestName}'s server.json has neither a single 'packages' entry nor a single 'remotes' entry.`);
266
+ }
267
+ // A conflicting .mcp.json entry is summarized back to the user in the
268
+ // collision error below so they can identify what they'd be overwriting --
269
+ // but this entry is, by definition, one ahood did NOT write (that's what
270
+ // makes it a collision), so it's a hand-authored entry that routinely passes
271
+ // a credential as a CLI arg (e.g. `"args": ["-y", "@thing", "--api-key",
272
+ // "sk-live-..."]`) or in a URL query string (`"url": "https://host/sse?
273
+ // token=..."`) -- neither of which a small "env"/"headers"-keyed redaction
274
+ // allowlist would ever touch. Worse than a rare edge case: update.ts calls
275
+ // add() for every locked entry and prints caught error messages, so a leak
276
+ // here would fire on a routine `ahood skill update`, not just a rare manual
277
+ // re-add.
278
+ //
279
+ // So rather than trying to allowlist every place a secret might hide, this
280
+ // prints only structural information: the entry's top-level key names, its
281
+ // `command` (a package identifier, not a secret), the key names inside
282
+ // `env`/`headers` (so the user can see WHICH variable conflicts without its
283
+ // value), and the conflicting URL's hostname (never its full value, since
284
+ // query strings are a common place to find a token). No leaf string value
285
+ // from `args`, a header's value, an env var's value, or a URL's path/query
286
+ // is ever included.
287
+ function summarizeConflictingEntry(entry) {
288
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry))
289
+ return String(entry);
290
+ const obj = entry;
291
+ const parts = [`keys: ${Object.keys(obj).join(", ") || "(none)"}`];
292
+ if (typeof obj.command === "string")
293
+ parts.push(`command: ${obj.command}`);
294
+ for (const key of ["env", "headers"]) {
295
+ const value = obj[key];
296
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
297
+ parts.push(`${key} keys: ${Object.keys(value).join(", ")}`);
298
+ }
299
+ }
300
+ if (typeof obj.url === "string") {
301
+ try {
302
+ parts.push(`url host: ${new URL(obj.url).hostname}`);
303
+ }
304
+ catch {
305
+ // Not a parseable URL -- omit rather than risk printing something
306
+ // sensitive verbatim.
307
+ }
308
+ }
309
+ return parts.join("; ");
310
+ }
311
+ // Reads and validates .mcp.json's shape, defaulting to an empty config when
312
+ // the file doesn't exist yet. Guards `mcpServers` itself (not just the
313
+ // top-level object) against being missing, `null`, or an array: a bare
314
+ // `fileContents.mcpServers === undefined` check alone would let
315
+ // `"mcpServers": null` reach the collision check below as an unactionable
316
+ // raw TypeError, and would let `"mcpServers": []` pass the collision check
317
+ // silently (an array's numeric-index write is then dropped entirely by
318
+ // JSON.stringify), reporting a successful install that wrote nothing.
319
+ function readMcpConfig() {
320
+ if (!existsSync(MCP_CONFIG_PATH))
321
+ return { mcpServers: {} };
322
+ let parsed;
323
+ try {
324
+ parsed = JSON.parse(readFileSync(MCP_CONFIG_PATH, "utf-8"));
325
+ }
326
+ catch {
327
+ throw new Error(`${MCP_CONFIG_PATH} exists but is not valid JSON -- fix or remove it before running this command.`);
328
+ }
329
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
330
+ throw new Error(`${MCP_CONFIG_PATH} exists but its top level is not a JSON object.`);
331
+ }
332
+ const fileContents = parsed;
333
+ if (fileContents.mcpServers === undefined) {
334
+ fileContents.mcpServers = {};
335
+ }
336
+ else if (typeof fileContents.mcpServers !== "object" ||
337
+ fileContents.mcpServers === null ||
338
+ Array.isArray(fileContents.mcpServers)) {
339
+ throw new Error(`${MCP_CONFIG_PATH}'s "mcpServers" key exists but is not a JSON object -- fix or remove it before running this command.`);
340
+ }
341
+ return fileContents;
342
+ }
343
+ // Keyed by ahood's own validated `skill` slug (SEGMENT_RE-checked in
344
+ // spec.ts), not the manifest's self-reported `name` field: two different
345
+ // published packages could easily share a generic internal name like
346
+ // "weather-server", and a JSON object key built from unvalidated manifest
347
+ // text is a footgun this sidesteps entirely by never using it as a key.
348
+ function assertNoCollision(fileContents, owner, skill) {
349
+ const mcpServers = fileContents.mcpServers;
350
+ if (Object.prototype.hasOwnProperty.call(mcpServers, skill)) {
351
+ throw new Error(`${MCP_CONFIG_PATH} already has an entry named "${skill}" (${summarizeConflictingEntry(mcpServers[skill])}).\n` +
352
+ `Remove it manually first if you want to reinstall ${owner}/${skill}.`);
353
+ }
354
+ }
355
+ async function installMcpEntry(owner, skill, meta, buffer) {
356
+ const content = await extractSingleFileContent(buffer, "server.json");
357
+ const manifest = JSON.parse(content.toString("utf-8"));
358
+ // Validate the manifest's shape (unsupported registry_type/runtime_hint,
359
+ // or neither a single 'packages' nor a single 'remotes' entry) and check
360
+ // for an existing .mcp.json collision BEFORE prompting for any secret --
361
+ // an install that's going to be refused anyway must not first cost the
362
+ // user a masked secret prompt. buildMcpServerConfig is called here with an
363
+ // empty env purely to run its validation/throw; the result is discarded
364
+ // and rebuilt below once secretEnv is known.
365
+ buildMcpServerConfig(manifest, {});
366
+ assertNoCollision(readMcpConfig(), owner, skill);
367
+ const envVars = manifest.packages?.[0]?.environment_variables ?? [];
368
+ const secretEnv = {};
369
+ for (const variable of envVars) {
370
+ if (!variable.is_secret)
371
+ continue;
372
+ const fromEnv = process.env[variable.name];
373
+ // Truthiness, not `!== undefined` -- matches credentials.ts's
374
+ // resolveToken(), the precedent this feature was explicitly modeled on
375
+ // (per the design spec), which uses `if (process.env.AHOOD_TOKEN)`
376
+ // specifically so an empty-string env var falls through to the real
377
+ // source instead of silently installing an empty secret.
378
+ secretEnv[variable.name] = fromEnv
379
+ ? fromEnv
380
+ : await promptSecret(`${sanitizeForTerminal(variable.name)} (${sanitizeForTerminal(variable.description)}): `);
381
+ }
382
+ const serverConfig = buildMcpServerConfig(manifest, secretEnv);
383
+ // Read-modify-write under an advisory lock, mirroring lockfile.ts's own
384
+ // writeLockfileEntry: .mcp.json sits right next to the lockfile and can
385
+ // hold other servers' secrets, so it gets the same protection against a
386
+ // truncated write or a concurrent `add` racing the same read-modify-write.
387
+ // Re-reads and re-checks the collision here rather than trusting the
388
+ // pre-check above, since arbitrary time may have passed since then --
389
+ // including, in the interactive case, however long the user took at the
390
+ // secret prompt -- during which another process could have written the
391
+ // same entry.
392
+ withLock(MCP_CONFIG_PATH, () => {
393
+ const fileContents = readMcpConfig();
394
+ assertNoCollision(fileContents, owner, skill);
395
+ fileContents.mcpServers[skill] = serverConfig;
396
+ writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents);
397
+ });
398
+ writeLockfileEntry(LOCKFILE_PATH, `${owner}/${skill}`, { version: meta.version, checksum_sha256: meta.checksum_sha256 });
399
+ console.log(`Installed ${owner}/${skill}@${meta.version} into ${MCP_CONFIG_PATH} as "${skill}"`);
400
+ if (Object.keys(secretEnv).length > 0) {
401
+ console.warn(`WARNING: ${MCP_CONFIG_PATH} now contains one or more secret values in plaintext -- do not commit it to version control.`);
402
+ }
403
+ }
166
404
  export async function add(args) {
167
405
  const spec = args[0];
168
406
  if (!spec)
169
- throw new Error(USAGE);
407
+ throw new UsageError(USAGE);
170
408
  const { owner, skill, version: requestedVersion } = parseOwnerSkillVersion(spec, USAGE);
171
409
  const key = `${owner}/${skill}`;
172
410
  const meta = await fetchVersionMeta(owner, skill, requestedVersion);
@@ -184,8 +422,13 @@ export async function add(args) {
184
422
  }
185
423
  // scripts/ warning: mirrors the web detail page's banner (platform ADR's
186
424
  // Open Risk #7) -- a CLI-only user installing via `add` would otherwise
187
- // never see this at all.
188
- if (meta.manifest.some((f) => f.path.startsWith("scripts/"))) {
425
+ // never see this at all. Skipped for an agent install: only AGENT.md's
426
+ // content is ever extracted/written on that path (extractSingleFileContent
427
+ // never touches anything else in the archive), so a scripts/ entry in the
428
+ // manifest can never actually land on disk there -- the warning would be
429
+ // both mislabeled ("skill") and about a risk that can't materialize
430
+ // (ahood-cli final review finding #5).
431
+ if (meta.kind !== "agent" && meta.kind !== "mcp" && meta.manifest.some((f) => f.path.startsWith("scripts/"))) {
189
432
  console.warn("WARNING: this skill includes a scripts/ directory. Review its contents before use.");
190
433
  }
191
434
  const downloadRes = await apiFetch(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/download?version=${encodeURIComponent(meta.version)}`, { headers: { "X-Ahood-Source": "cli" }, redirect: "follow" });
@@ -198,6 +441,19 @@ export async function add(args) {
198
441
  if (actualChecksum !== meta.checksum_sha256) {
199
442
  throw new Error(`Checksum mismatch for ${key}@${meta.version}: expected ${meta.checksum_sha256}, got ${actualChecksum}. Refusing to install.`);
200
443
  }
444
+ if (meta.kind === "agent") {
445
+ const content = await extractSingleFileContent(buffer, "AGENT.md");
446
+ mkdirSync(AGENTS_ROOT, { recursive: true });
447
+ const destPath = agentPath(owner, skill);
448
+ writeFileSync(destPath, content);
449
+ writeLockfileEntry(LOCKFILE_PATH, key, { version: meta.version, checksum_sha256: meta.checksum_sha256 });
450
+ console.log(`Installed ${key}@${meta.version} to ${destPath}`);
451
+ return;
452
+ }
453
+ if (meta.kind === "mcp") {
454
+ await installMcpEntry(owner, skill, meta, buffer);
455
+ return;
456
+ }
201
457
  // Owner-namespaced on disk, mirroring npm's node_modules/@scope/package.
202
458
  const destDir = skillDir(owner, skill);
203
459
  await extractFreshVersion(buffer, destDir);
@@ -1,4 +1,5 @@
1
1
  import { TOP_LEVEL_COMMANDS_HELP, SKILL_COMMANDS_HELP, COMMAND_ALIASES } from "../help.js";
2
+ import { UsageError } from "../usage-error.js";
2
3
  const USAGE = "Usage: ahood completion <bash|zsh|fish>";
3
4
  // Position 1 (the word right after "ahood"): every top-level command plus
4
5
  // the "skill" entity group itself.
@@ -69,6 +70,6 @@ export async function completion(args) {
69
70
  console.log(fishCompletion());
70
71
  return;
71
72
  default:
72
- throw new Error(USAGE);
73
+ throw new UsageError(USAGE);
73
74
  }
74
75
  }
@@ -1,7 +1,8 @@
1
1
  import { apiJson } from "../http.js";
2
2
  import { flagValue } from "../flags.js";
3
3
  import { parseOwnerSkill, validateExternalUrl } from "../spec.js";
4
- const USAGE = "Usage: ahood edit <owner>/<skill> [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--visibility public|private] [--homepage <url>] [--repository <url>]";
4
+ import { UsageError } from "../usage-error.js";
5
+ const USAGE = "Usage: ahood skill edit <owner>/<skill> [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--visibility public|private] [--homepage <url>] [--repository <url>]";
5
6
  // Mirrors PATCH /api/v1/skills/{owner}/{skill}'s allow-list exactly
6
7
  // (lib/skills/mutations.ts's UpdateSkillInput) -- only a field the caller
7
8
  // explicitly passed a flag for is included in the body, so an omitted flag
@@ -10,7 +11,7 @@ const USAGE = "Usage: ahood edit <owner>/<skill> [--tagline <text>] [--tags <com
10
11
  export async function edit(args) {
11
12
  const spec = args[0];
12
13
  if (!spec || spec.startsWith("--"))
13
- throw new Error(USAGE);
14
+ throw new UsageError(USAGE);
14
15
  const { owner, skill } = parseOwnerSkill(spec, USAGE);
15
16
  const body = {};
16
17
  const tagline = flagValue(args, "--tagline");
@@ -25,7 +26,7 @@ export async function edit(args) {
25
26
  const visibility = flagValue(args, "--visibility");
26
27
  if (visibility !== undefined) {
27
28
  if (visibility !== "public" && visibility !== "private") {
28
- throw new Error(`--visibility must be "public" or "private" (got "${visibility}").`);
29
+ throw new UsageError(`--visibility must be "public" or "private" (got "${visibility}").`);
29
30
  }
30
31
  body.visibility = visibility;
31
32
  }
@@ -40,7 +41,7 @@ export async function edit(args) {
40
41
  body.repository = repository;
41
42
  }
42
43
  if (Object.keys(body).length === 0) {
43
- throw new Error("Nothing to update -- pass at least one of --tagline, --tags, --license, --visibility, --homepage, --repository.");
44
+ throw new UsageError("Nothing to update -- pass at least one of --tagline, --tags, --license, --visibility, --homepage, --repository.");
44
45
  }
45
46
  const updated = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`, {
46
47
  method: "PATCH",
@@ -2,6 +2,7 @@ import { apiJson } from "../http.js";
2
2
  import { flagValue } from "../flags.js";
3
3
  import { confirm } from "../confirm.js";
4
4
  import { getApiUrl } from "../config.js";
5
+ import { UsageError } from "../usage-error.js";
5
6
  const CREATE_USAGE = "Usage: ahood group create <name> [--description <text>]";
6
7
  const LIST_USAGE = "Usage: ahood group list [--json]";
7
8
  const MEMBERS_USAGE = "Usage: ahood group members <group> [--json]";
@@ -18,7 +19,7 @@ const DELETE_USAGE = "Usage: ahood group delete <group> [--yes]";
18
19
  export async function createGroup(args) {
19
20
  const name = args[0];
20
21
  if (!name || name.startsWith("--"))
21
- throw new Error(CREATE_USAGE);
22
+ throw new UsageError(CREATE_USAGE);
22
23
  const description = flagValue(args, "--description");
23
24
  const body = { name };
24
25
  if (description !== undefined)
@@ -52,7 +53,7 @@ export async function groupMembers(args) {
52
53
  const jsonOutput = args.includes("--json");
53
54
  const slug = args.find((a) => !a.startsWith("--"));
54
55
  if (!slug)
55
- throw new Error(MEMBERS_USAGE);
56
+ throw new UsageError(MEMBERS_USAGE);
56
57
  const { group, members } = await apiJson(`/api/v1/groups/${encodeURIComponent(slug)}`);
57
58
  if (jsonOutput) {
58
59
  console.log(JSON.stringify({ group, members }));
@@ -73,7 +74,7 @@ export async function inviteLink(args) {
73
74
  const jsonOutput = args.includes("--json");
74
75
  const slug = args.find((a) => !a.startsWith("--"));
75
76
  if (!slug)
76
- throw new Error(INVITE_LINK_USAGE);
77
+ throw new UsageError(INVITE_LINK_USAGE);
77
78
  const { token, expiresAt } = await apiJson(`/api/v1/groups/${encodeURIComponent(slug)}/invites`, { method: "POST" });
78
79
  const url = `${getApiUrl()}/groups/join?token=${encodeURIComponent(token)}`;
79
80
  if (jsonOutput) {
@@ -105,7 +106,7 @@ function extractToken(input) {
105
106
  export async function joinGroup(args) {
106
107
  const input = args.find((a) => !a.startsWith("--"));
107
108
  if (!input)
108
- throw new Error(JOIN_USAGE);
109
+ throw new UsageError(JOIN_USAGE);
109
110
  const token = extractToken(input);
110
111
  const { groupSlug, groupName } = await apiJson(`/api/v1/groups/invites/${encodeURIComponent(token)}/accept`, { method: "POST" });
111
112
  console.log(`Joined ${groupName} (${groupSlug}).`);
@@ -117,7 +118,7 @@ export async function removeMember(args) {
117
118
  const positional = args.filter((a) => !a.startsWith("--"));
118
119
  const [slug, username] = positional;
119
120
  if (!slug || !username)
120
- throw new Error(REMOVE_MEMBER_USAGE);
121
+ throw new UsageError(REMOVE_MEMBER_USAGE);
121
122
  await apiJson(`/api/v1/groups/${encodeURIComponent(slug)}/members/${encodeURIComponent(username)}`, { method: "DELETE" });
122
123
  console.log(`Removed ${username} from ${slug}.`);
123
124
  }
@@ -135,7 +136,7 @@ async function getOwnUsername() {
135
136
  export async function leaveGroup(args) {
136
137
  const slug = args.find((a) => !a.startsWith("--"));
137
138
  if (!slug)
138
- throw new Error(LEAVE_USAGE);
139
+ throw new UsageError(LEAVE_USAGE);
139
140
  const username = await getOwnUsername();
140
141
  await apiJson(`/api/v1/groups/${encodeURIComponent(slug)}/members/${encodeURIComponent(username)}`, { method: "DELETE" });
141
142
  console.log(`Left ${slug}.`);
@@ -147,7 +148,7 @@ export async function deleteGroup(args) {
147
148
  const yes = args.includes("--yes");
148
149
  const slug = args.find((a) => !a.startsWith("--"));
149
150
  if (!slug)
150
- throw new Error(DELETE_USAGE);
151
+ throw new UsageError(DELETE_USAGE);
151
152
  const confirmed = yes
152
153
  ? true
153
154
  : await confirm(`This will permanently delete the group "${slug}" for all members. Type "yes" to confirm: `);
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
2
  import { basename, join, resolve, sep } from "node:path";
3
- const USAGE = "Usage: ahood init [name]";
3
+ import { UsageError } from "../usage-error.js";
4
+ const USAGE = "Usage: ahood skill init [name]";
4
5
  // Mirrors the server's slug convention referenced by ahood-cli#60 (and the
5
6
  // same shape as spec.ts's SEGMENT_RE, minus "." and "_" -- skill slugs are
6
7
  // strictly lowercase-alnum-and-hyphen). A name that already matches this is
@@ -33,7 +34,7 @@ function buildSkillMd(name) {
33
34
  return `---
34
35
  # name: a short, unique identifier for this skill (kebab-case is
35
36
  # conventional, e.g. "pdf-tools"). Not the same as the <skill> slug you
36
- # publish under -- ahood publish reads that from the command line -- but
37
+ # publish under -- ahood skill publish reads that from the command line -- but
37
38
  # keeping them in sync avoids confusion.
38
39
  name: ${name}
39
40
  # description: one or two sentences describing what this skill does and
@@ -49,7 +50,7 @@ TODO: describe, step by step, what Claude should do when this skill is invoked.
49
50
  }
50
51
  // Falls back to "my-skill" when the directory name itself isn't usable as a
51
52
  // bare YAML scalar (empty, or starting with a character like "-" or "@" that
52
- // would need quoting) -- e.g. running `ahood init` with no name directly in
53
+ // would need quoting) -- e.g. running `ahood skill init` with no name directly in
53
54
  // "/" (basename "") or in a directory whose name starts with punctuation.
54
55
  function skillNameFor(dirPath) {
55
56
  const base = basename(dirPath);
@@ -58,7 +59,7 @@ function skillNameFor(dirPath) {
58
59
  export async function init(args) {
59
60
  let name = args[0];
60
61
  if (name !== undefined && name.startsWith("-"))
61
- throw new Error(USAGE);
62
+ throw new UsageError(USAGE);
62
63
  if (name) {
63
64
  // Path containment first (ahood-cli#61), mirroring spec.ts's
64
65
  // validateSegment containment discipline used by add/remove: a resolved
@@ -69,7 +70,7 @@ export async function init(args) {
69
70
  const cwd = resolve(process.cwd());
70
71
  const resolvedName = resolve(cwd, name);
71
72
  if (resolvedName !== cwd && !resolvedName.startsWith(cwd + sep)) {
72
- throw new Error(`Invalid name "${name}" -- resolves to "${resolvedName}", which is outside the current directory (${cwd}). Refusing to create files outside the project directory.`);
73
+ throw new UsageError(`Invalid name "${name}" -- resolves to "${resolvedName}", which is outside the current directory (${cwd}). Refusing to create files outside the project directory.`);
73
74
  }
74
75
  // Then slug normalization (ahood-cli#60): a name that stays within cwd
75
76
  // but isn't already a valid slug gets normalized with a note, rather
@@ -77,7 +78,7 @@ export async function init(args) {
77
78
  if (!SLUG_RE.test(name)) {
78
79
  const normalized = normalizeToSlug(name);
79
80
  if (!normalized) {
80
- throw new Error(`Invalid name "${name}" -- could not derive a valid name from it (must contain at least one letter or digit).`);
81
+ throw new UsageError(`Invalid name "${name}" -- could not derive a valid name from it (must contain at least one letter or digit).`);
81
82
  }
82
83
  console.log(`Note: normalized "${name}" to "${normalized}".`);
83
84
  name = normalized;
@@ -92,5 +93,5 @@ export async function init(args) {
92
93
  writeFileSync(skillMdPath, buildSkillMd(name ?? skillNameFor(targetDir)));
93
94
  console.log(`Created ${skillMdPath}`);
94
95
  console.log("Fill in the description, then flesh out the ## Instructions section.");
95
- console.log(`Run \`ahood publish <owner>/<skill>@<version>${name ? ` --path ${name}` : ""}\` when ready.`);
96
+ console.log(`Run \`ahood skill publish <owner>/<skill>@<version>${name ? ` --path ${name}` : ""}\` when ready.`);
96
97
  }
@@ -1,7 +1,11 @@
1
1
  import { apiJson } from "../http.js";
2
+ export async function listOwnSkills() {
3
+ const { skills } = await apiJson("/api/v1/skills?mine=true");
4
+ return skills ?? [];
5
+ }
2
6
  export async function listSkills(args = []) {
3
7
  const jsonOutput = args.includes("--json");
4
- const { skills } = await apiJson("/api/v1/skills?mine=true");
8
+ const skills = await listOwnSkills();
5
9
  if (jsonOutput) {
6
10
  console.log(JSON.stringify(skills));
7
11
  return;
@@ -0,0 +1,7 @@
1
+ import { startMcpServer } from "../mcp/server.js";
2
+ // `ahood mcp` takes no subcommands/flags in v1. Unlike every other
3
+ // account-scoped command it never returns until the client disconnects --
4
+ // see startMcpServer's comment.
5
+ export async function mcp(_args) {
6
+ await startMcpServer();
7
+ }