@ahood/cli 0.9.0 → 0.10.1

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/dist/help.js CHANGED
@@ -1,3 +1,27 @@
1
+ // Canonical command list for terminal --help output. Kept in sync by hand
2
+ // with cli/README.md's table and app/docs/page.tsx's COMMANDS array (three
3
+ // copies of the same list, none of which can literally import from this
4
+ // file -- README.md is plain markdown and the docs page is a separate
5
+ // Next.js app) -- a comment in each of those two places points back here so
6
+ // a future command addition doesn't update only one.
7
+ //
8
+ // The command surface is split into two tiers, gh-style:
9
+ // - TOP_LEVEL_COMMANDS_HELP: account/auth-scoped commands that stay flat
10
+ // (login, logout, whoami, token, completion) -- same reasoning
11
+ // `gh auth login` isn't `gh account login`.
12
+ // - SKILL_COMMANDS_HELP: every registry verb, reached as `ahood skill
13
+ // <verb>` -- legacy and cross-kind in this release (ahood-cli#172).
14
+ // - AGENT_COMMANDS_HELP / MCP_COMMANDS_HELP: the same verbs, kind-scoped,
15
+ // generated from one template (registryCommandsHelp); MCP's list also
16
+ // carries `ahood mcp` / `ahood mcp serve`, the local stdio server.
17
+ // - GROUP_COMMANDS_HELP / SNAP_COMMANDS_HELP: the other entity nouns.
18
+ //
19
+ // `summary` is a genuinely one-sentence blurb used only by the top-level
20
+ // `ahood --help` / `ahood skill --help` listings. `desc` is the full
21
+ // (possibly multi-sentence) description rendered in full by `ahood help
22
+ // <command>` / `ahood skill <verb> --help`. Keep `summary` self-contained --
23
+ // it must not depend on the reader having also seen `desc`.
24
+ import { KINDS } from "./kind-info.js";
1
25
  export const TOP_LEVEL_COMMANDS_HELP = [
2
26
  {
3
27
  usage: "ahood login",
@@ -27,12 +51,15 @@ export const TOP_LEVEL_COMMANDS_HELP = [
27
51
  examples: ["ahood completion bash >> ~/.bashrc"],
28
52
  },
29
53
  {
30
- usage: "ahood mcp",
31
- summary: "Start an MCP server exposing read-only skill commands as tools over stdio.",
32
- desc: "Start a Model Context Protocol server over stdio, exposing skill_search, skill_view, skill_read, " +
33
- "skill_versions, skill_list, skill_outdated, and whoami as MCP tools -- the same data ahood's --json " +
34
- "commands already return, reachable as typed tool calls instead of parsed CLI output. Meant to be " +
35
- "launched by an MCP-aware agent host (e.g. Claude Code's MCP server configuration), not run interactively.",
54
+ usage: "ahood help useme",
55
+ summary: "Print the bundled ahood SKILL.md for an AI agent -- raw, offline, no login needed.",
56
+ desc: "Print the ahood self-skill bundled with this exact CLI version: a complete SKILL.md teaching an AI agent " +
57
+ "(or a human) when and how to use ahood. Stdout is the raw file and nothing else -- no banner, no colors -- " +
58
+ "so it can be pasted into an agent's context, redirected into a file, or read directly by an agent with " +
59
+ "shell access. Works offline and without credentials, and never writes to your project. It is not the " +
60
+ "same as `ahood skill add alexkay/ahood`, which installs the registry-published copy as a separate, " +
61
+ "pinned project skill.",
62
+ examples: ["ahood help useme", "ahood help useme > ahood-SKILL.md"],
36
63
  },
37
64
  ];
38
65
  export const SKILL_COMMANDS_HELP = [
@@ -206,6 +233,182 @@ export const SKILL_COMMANDS_HELP = [
206
233
  ],
207
234
  },
208
235
  ];
236
+ // Kind-scoped registry verbs (ahood-cli#172): `ahood agent <verb>` and
237
+ // `ahood mcp <verb>` take the same verbs as `ahood skill <verb>`, run by the
238
+ // same handlers, but strictly for their own kind -- a target of another kind
239
+ // is refused before any side effect. Generated from one template per verb
240
+ // rather than hand-copied twice, so the two lists can't drift from each other.
241
+ // SKILL_COMMANDS_HELP above stays hand-written: it documents the legacy,
242
+ // cross-kind behavior `ahood skill` keeps in this release.
243
+ function registryCommandsHelp(kind) {
244
+ const info = KINDS[kind];
245
+ const n = kind === "agent" ? "<agent>" : "<server>";
246
+ const p = `ahood ${kind}`;
247
+ const label = info.label;
248
+ const a = info.article;
249
+ const plural = info.plural;
250
+ const doc = info.rootDoc;
251
+ const ex = kind === "agent" ? "alice/code-reviewer" : "alice/github-server";
252
+ const kindRefusal = `Refused, before anything else happens, when the target is not ${a} ${label}.`;
253
+ return [
254
+ {
255
+ usage: `${p} search <query> [--json] [--limit <n>]`,
256
+ summary: `Search published ${plural}.`,
257
+ desc: `Search published ${plural} only (the registry's ?kind=${kind} filter). Same output and --json shape as \`ahood skill search\`.`,
258
+ flags: ["--json Emit the raw result objects instead of formatted lines.", "--limit <n> Cap the number of results."],
259
+ examples: [`${p} search github`],
260
+ },
261
+ {
262
+ usage: `${p} view <owner>/${n} [--json] [--web]`,
263
+ summary: `Show a single ${label}'s details without installing it (alias: ${p} show).`,
264
+ desc: `Show a single ${label}'s details without installing it. Alias: ${p} show. ${kindRefusal}`,
265
+ flags: ["--json Emit the raw object instead of formatted lines.", "--web Open its page in your browser instead of printing."],
266
+ },
267
+ {
268
+ usage: `${p} read <owner>/${n} [--json]`,
269
+ summary: `Print a published ${label}'s ${doc}, without installing it.`,
270
+ desc: `Print the ${doc} of a published ${label} verbatim, without installing it -- read it before you add it. ${kindRefusal}`,
271
+ flags: ["--json Emit {version, content} as a single line instead of the raw content."],
272
+ examples: [`${p} read ${ex}`],
273
+ },
274
+ {
275
+ usage: `${p} versions <owner>/${n} [--json]`,
276
+ summary: `List a ${label}'s published-version history.`,
277
+ desc: `List a ${label}'s published-version history: version, changelog, size, and publish date. ${kindRefusal}`,
278
+ flags: ["--json Emit the raw version objects instead of formatted text."],
279
+ },
280
+ {
281
+ usage: `${p} diff <owner>/${n} <versionA> <versionB> [--json]`,
282
+ summary: `Show what changed between two published versions of ${a} ${label}.`,
283
+ desc: `Show what changed between two published versions, exactly like \`ahood skill diff\`. Both versions must be explicit semver. ${kindRefusal}`,
284
+ flags: ["--json Emit {skillmd_diff, manifest: {added, removed, changed}} instead of formatted text."],
285
+ },
286
+ {
287
+ usage: `${p} list [--json]`,
288
+ summary: `List your own ${plural}, public and private.`,
289
+ desc: `List your own ${plural}, public and private. The registry returns every entry you own; this keeps only the ${kind} ones ` +
290
+ "(an entry with no kind in the response is left out, with a warning on stderr). --json keeps `ahood skill list --json`'s shape.",
291
+ flags: ["--json Emit the raw objects instead of formatted lines."],
292
+ },
293
+ {
294
+ usage: `${p} add <owner>/${n}[@version]`,
295
+ summary: `Install ${a} ${label} into ${info.installsTo}, pinned in the lockfile.`,
296
+ desc: `Install ${a} ${label} into ${info.installsTo}, pinned in .claude/skills.lock.json. ` +
297
+ (kind === "mcp" ? "Prompts for any secret environment variables its server.json declares that aren't already set. " : "") +
298
+ `Refused before anything is downloaded or written when the target is not ${a} ${label}.`,
299
+ examples: [`${p} add ${ex}`, `${p} add ${ex}@1.2.0`],
300
+ },
301
+ {
302
+ usage: `${p} update [<owner>/${n} ...] [--dry-run] [--json]`,
303
+ summary: `Move ${label} pins forward to the latest version.`,
304
+ desc: `Move lockfile pins forward to the latest version. With no argument, considers only the installed ${plural} ` +
305
+ `in this project -- other kinds' pins are left alone. A named target that is not ${a} ${label} is refused and counted as a failure.`,
306
+ flags: [
307
+ "--dry-run Preview current vs. latest version (and the changelog) without installing anything.",
308
+ "--json With --dry-run, emit structured preview objects instead of a formatted table.",
309
+ ],
310
+ },
311
+ {
312
+ usage: `${p} outdated [<owner>/${n} ...] [--json]`,
313
+ summary: `Read-only staleness check for installed ${plural}.`,
314
+ desc: `Read-only staleness check -- equivalent to \`${p} update --dry-run\`. With no argument, checks only the installed ${plural}.`,
315
+ flags: ["--json Emit structured preview objects instead of a formatted table."],
316
+ },
317
+ {
318
+ usage: `${p} remove <owner>/${n} [--yes]`,
319
+ summary: `Uninstall and unpin ${a} ${label} (local only, prompts unless --yes is passed).`,
320
+ desc: `Uninstall and unpin ${a} ${label} from this project (local only). Refused, before the prompt and before anything is deleted, ` +
321
+ `when the install is another kind; the kind is read from the project's own files, and from the registry only when they can't tell.`,
322
+ flags: ["--yes Skip the interactive confirmation, for scripts/CI."],
323
+ },
324
+ {
325
+ usage: `${p} edit <owner>/${n} [--tagline] [--tags] [--license] [--visibility] [--homepage] [--repository]`,
326
+ summary: `Update ${a} ${label} you own, changing only the flags you pass.`,
327
+ desc: `Update ${a} ${label} you own; same flags as \`ahood skill edit\`. ${kindRefusal}`,
328
+ flags: [
329
+ "--tagline <text> Short one-line description.",
330
+ "--tags <comma,separated> Replaces the tag list.",
331
+ "--license <id> An SPDX license identifier, e.g. MIT.",
332
+ "--visibility public|private Who can see and install it.",
333
+ "--homepage <url> Homepage URL.",
334
+ "--repository <url> Source repository URL.",
335
+ ],
336
+ },
337
+ {
338
+ usage: `${p} unpublish <owner>/${n}[@version] [--yes]`,
339
+ summary: `Delete ${a} ${label} from the registry for every consumer, or yank one version (prompts unless --yes is passed).`,
340
+ desc: `Without @version: delete ${a} ${label} from the registry for every consumer. With @version: yank that version. ` +
341
+ `Refused before the prompt when the target is not ${a} ${label}.`,
342
+ flags: ["--yes Skip the interactive confirmation, for scripts/CI."],
343
+ },
344
+ { usage: `${p} star <owner>/${n}`, summary: `Star ${a} ${label}.`, desc: `Star ${a} ${label}. ${kindRefusal}` },
345
+ { usage: `${p} unstar <owner>/${n}`, summary: `Remove your star from ${a} ${label}.`, desc: `Remove your star from ${a} ${label}. ${kindRefusal}` },
346
+ {
347
+ usage: `${p} share <owner>/${n} --group <group>`,
348
+ summary: `Share ${a} ${label} you own with a group, without changing its visibility.`,
349
+ desc: `Share ${a} ${label} you own with a group you belong to. ${kindRefusal}`,
350
+ flags: ["--group <group> The group's slug (from `ahood group list`)."],
351
+ },
352
+ {
353
+ usage: `${p} unshare <owner>/${n} --group <group>`,
354
+ summary: `Stop sharing ${a} ${label} you own with a group.`,
355
+ desc: `Stop sharing ${a} ${label} you own with a group. ${kindRefusal}`,
356
+ flags: ["--group <group> The group's slug."],
357
+ },
358
+ {
359
+ usage: `${p} init [name]`,
360
+ summary: `Scaffold a new ${label} folder with a minimal, valid ${doc}.`,
361
+ desc: `Scaffold a minimal ${doc} that passes the registry's publish validation: ./<name>/${doc} with a name, or ./${doc} without. ` +
362
+ (kind === "mcp"
363
+ ? "The starter points at a reserved example.com URL, so it can never install anyone's real server until you edit it. "
364
+ : "") +
365
+ `Refuses to overwrite an existing ${doc}, or to add one to a folder that already holds another kind's root document. ` +
366
+ "Names are slug-normalized and must stay inside the current directory.",
367
+ examples: [`${p} init ${ex.split("/")[1]}`, `${p} init`],
368
+ },
369
+ {
370
+ usage: `${p} publish <owner>/${n}@<version> [--path <dir>] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--changelog <text>] [--json]`,
371
+ summary: `Publish a new version of ${a} ${label} from a folder containing ${doc}, creating it first if it doesn't exist yet.`,
372
+ desc: `Publish a new version from a folder containing ${doc}; the kind is implied, and a contradictory --kind is refused. ` +
373
+ `If the entry already exists as another kind, this stops before anything is packed or uploaded. ` +
374
+ "If it doesn't exist yet, it is created first -- pass --name for that. Otherwise the same flags and --json output as `ahood skill publish`.",
375
+ flags: [
376
+ "--path <dir> Folder to publish (default: the current directory).",
377
+ "--name <text> Required only when creating the entry on this publish.",
378
+ "--changelog <text> What changed in this version.",
379
+ "--json Print one {version,status,...} JSON object on completion (or {error} on failure).",
380
+ ],
381
+ examples: [`${p} publish ${ex}@1.0.0 --name "${kind === "agent" ? "Code Reviewer" : "GitHub Server"}"`],
382
+ },
383
+ ];
384
+ }
385
+ export const AGENT_COMMANDS_HELP = registryCommandsHelp("agent");
386
+ // `ahood mcp` is two things (ADR 0007 in the ahood repo separates the hosted
387
+ // registry MCP endpoint from this CLI's local one): with no verb, or with
388
+ // `serve`, it is the local, read-only stdio MCP server that MCP hosts spawn;
389
+ // with a registry verb it manages mcp-kind registry entries (server.json
390
+ // manifests). Both are listed here so `ahood mcp --help` explains both.
391
+ export const MCP_SERVE_HELP = [
392
+ {
393
+ usage: "ahood mcp serve",
394
+ summary: "Start the local, read-only ahood MCP server over stdio (preferred spelling).",
395
+ desc: "Start a Model Context Protocol server over stdio, exposing skill_search, skill_view, skill_read, " +
396
+ "skill_versions, skill_list, skill_outdated, and whoami as MCP tools -- the same data ahood's --json " +
397
+ "commands already return, reachable as typed tool calls instead of parsed CLI output. Meant to be " +
398
+ "launched by an MCP-aware agent host (e.g. Claude Code's MCP server configuration), not run interactively. " +
399
+ "Read-only by design. This is not the hosted registry MCP endpoint (https://www.ahood.dev/api/mcp), which " +
400
+ "is a separate server with write tools.",
401
+ examples: ['claude mcp add ahood -- ahood mcp serve', "npx @ahood/cli@latest mcp serve"],
402
+ },
403
+ {
404
+ usage: "ahood mcp",
405
+ summary: "Same as `ahood mcp serve`, kept byte-for-byte so existing MCP host configs keep working.",
406
+ desc: "With no arguments at all, `ahood mcp` starts the same stdio server as `ahood mcp serve` -- nothing is printed " +
407
+ "to stdout before the protocol starts, and no login or network is needed to start it. Any other first word must " +
408
+ "be `serve`, `--help`, or a registry verb below; anything unrecognized fails with usage instead of starting a server.",
409
+ },
410
+ ];
411
+ export const MCP_COMMANDS_HELP = [...MCP_SERVE_HELP, ...registryCommandsHelp("mcp")];
209
412
  // Every group-entity verb, all reached as `ahood group <verb>` -- mirrors
210
413
  // SKILL_COMMANDS_HELP above, just for the "Groups" feature: private groups,
211
414
  // shareable invite links, and sharing your own skills with a group without
@@ -242,10 +445,10 @@ export const GROUP_COMMANDS_HELP = [
242
445
  {
243
446
  usage: "ahood group join <invite-url-or-token>",
244
447
  summary: "Join a group using an invite link or its raw token.",
245
- desc: "Join a group using an invite link (e.g. https://ahood.vercel.app/groups/join?token=...) or its bare " +
448
+ desc: "Join a group using an invite link (e.g. https://www.ahood.dev/groups/join?token=...) or its bare " +
246
449
  "raw token -- either form works.",
247
450
  examples: [
248
- "ahood group join https://ahood.vercel.app/groups/join?token=abc123",
451
+ "ahood group join https://www.ahood.dev/groups/join?token=abc123",
249
452
  "ahood group join abc123",
250
453
  ],
251
454
  },
@@ -380,6 +583,8 @@ export const COMMAND_ALIASES = { show: "view" };
380
583
  // only has to be added here once, not re-special-cased in both places.
381
584
  const ENTITY_COMMANDS_HELP = {
382
585
  skill: SKILL_COMMANDS_HELP,
586
+ agent: AGENT_COMMANDS_HELP,
587
+ mcp: MCP_COMMANDS_HELP,
383
588
  group: GROUP_COMMANDS_HELP,
384
589
  snap: SNAP_COMMANDS_HELP,
385
590
  };
@@ -501,12 +706,58 @@ export function formatSkillHelp() {
501
706
  return [
502
707
  "ahood skill -- manage skills in the ahood registry",
503
708
  "",
709
+ "Legacy, cross-kind: in this release `ahood skill <verb>` still acts on any registry kind",
710
+ "(skills, agents, MCP server manifests) -- `skill list`/`skill search` include every kind, and",
711
+ "`skill add`/`skill update` install whatever kind they resolve. Scripts relying on that keep",
712
+ "working. For one kind only, use `ahood agent <verb>` / `ahood mcp <verb>`, or add",
713
+ "`--kind skill|agent|mcp` to any verb below (`--kind all` spells out the legacy behavior).",
714
+ "A future major version will make `ahood skill` skills-only.",
715
+ "",
504
716
  "Commands:",
505
717
  ...lines,
506
718
  "",
507
719
  "Run `ahood skill <command> --help` for a single command's flags and examples.",
508
720
  ].join("\n");
509
721
  }
722
+ // `ahood agent --help` / `ahood mcp --help` -- kind-scoped registry verbs.
723
+ function formatRegistryKindHelp(header, intro, entries, noun) {
724
+ const lines = formatCommandTable(entries.map((c) => ({ usage: usageWithAliases(c), summary: c.summary })));
725
+ return [
726
+ header,
727
+ "",
728
+ ...intro,
729
+ "",
730
+ "Commands:",
731
+ ...lines,
732
+ "",
733
+ `Run \`ahood ${noun} <command> --help\` for a single command's flags and examples.`,
734
+ ].join("\n");
735
+ }
736
+ export function formatAgentHelp() {
737
+ return formatRegistryKindHelp("ahood agent -- manage agent definitions (AGENT.md) in the ahood registry", [
738
+ "Every verb here acts on agents only: a target of another kind is refused before anything",
739
+ "is downloaded, prompted for, written, or sent. Installs land in .claude/agents/<owner>@<agent>.md.",
740
+ ], AGENT_COMMANDS_HELP, "agent");
741
+ }
742
+ export function formatMcpHelp() {
743
+ return formatRegistryKindHelp("ahood mcp -- the local MCP server, and MCP server manifests (server.json) in the ahood registry", [
744
+ "Two meanings, told apart by the first word:",
745
+ " ahood mcp / ahood mcp serve start this CLI's local, read-only MCP server over stdio",
746
+ " (what MCP host configs launch; `serve` is the preferred spelling)",
747
+ " ahood mcp <verb> manage mcp-kind registry entries (installs merge into .mcp.json)",
748
+ "Neither is the hosted registry MCP endpoint, https://www.ahood.dev/api/mcp.",
749
+ "Registry verbs act on MCP server manifests only; another kind is refused before any side effect.",
750
+ ], MCP_COMMANDS_HELP, "mcp");
751
+ }
752
+ // One entry point for every registry noun's group help, so dispatch and
753
+ // `ahood help <noun>` can't disagree about which listing a noun gets.
754
+ export function formatKindHelp(noun) {
755
+ if (noun === "agent")
756
+ return formatAgentHelp();
757
+ if (noun === "mcp")
758
+ return formatMcpHelp();
759
+ return formatSkillHelp();
760
+ }
510
761
  // `ahood group --help` -- the group-level listing for every group verb.
511
762
  export function formatGroupHelp() {
512
763
  const lines = formatCommandTable(GROUP_COMMANDS_HELP.map((c) => ({ usage: usageWithAliases(c), summary: c.summary })));
@@ -537,7 +788,11 @@ export function formatSnapHelp() {
537
788
  // entity command lists.
538
789
  export function formatHelp() {
539
790
  const skillGroupUsage = "ahood skill <command>";
540
- const skillGroupSummary = "Search, install, and publish skills -- run `ahood skill --help` for the full list.";
791
+ const skillGroupSummary = "Search, install, and publish skills (legacy: also any other kind) -- run `ahood skill --help` for the full list.";
792
+ const agentGroupUsage = "ahood agent <command>";
793
+ const agentGroupSummary = "The same verbs, for agent definitions only -- run `ahood agent --help` for the full list.";
794
+ const mcpGroupUsage = "ahood mcp <command>";
795
+ const mcpGroupSummary = "The same verbs, for MCP server manifests only; bare `ahood mcp` or `ahood mcp serve` starts the local MCP server.";
541
796
  const groupGroupUsage = "ahood group <command>";
542
797
  const groupGroupSummary = "Create private groups and share skills with them -- run `ahood group --help` for the full list.";
543
798
  const snapGroupUsage = "ahood snap <command>";
@@ -545,22 +800,30 @@ export function formatHelp() {
545
800
  const lines = formatCommandTable([
546
801
  ...TOP_LEVEL_COMMANDS_HELP.map((c) => ({ usage: usageWithAliases(c), summary: c.summary })),
547
802
  { usage: skillGroupUsage, summary: skillGroupSummary },
803
+ { usage: agentGroupUsage, summary: agentGroupSummary },
804
+ { usage: mcpGroupUsage, summary: mcpGroupSummary },
548
805
  { usage: groupGroupUsage, summary: groupGroupSummary },
549
806
  { usage: snapGroupUsage, summary: snapGroupSummary },
550
807
  ]);
551
808
  return [
552
- "ahood -- CLI for the ahood skills registry (https://ahood.vercel.app)",
809
+ "ahood -- CLI for the ahood skills registry (https://www.ahood.dev)",
553
810
  "",
554
811
  "Quick start:",
555
812
  " ahood login",
556
813
  " ahood skill search <something>",
557
814
  " ahood skill add <owner>/<skill>",
558
815
  "",
816
+ "Use ahood with an AI agent:",
817
+ " ahood help useme Print a complete SKILL.md teaching an agent how to use ahood.",
818
+ " Raw stdout, offline, no login -- paste it to an agent, or let an",
819
+ " agent with a shell run it.",
820
+ "",
559
821
  "Commands:",
560
822
  ...lines,
561
823
  "",
562
824
  "Run `ahood <command> --help` (or `ahood help <command>`) for a single command's flags and examples.",
563
825
  "Run `ahood skill --help` for the full list of skill commands.",
826
+ "Run `ahood agent --help` / `ahood mcp --help` for the kind-scoped agent and MCP commands.",
564
827
  "Run `ahood group --help` for the full list of group commands.",
565
828
  "Run `ahood snap --help` for the full list of snap commands.",
566
829
  "Run `ahood --version` to print the installed CLI version.",
@@ -569,6 +832,6 @@ export function formatHelp() {
569
832
  " 4 authentication required or rejected, 5 not found,",
570
833
  " 6 network/transport error or upstream server (5xx) error.",
571
834
  "",
572
- "Full reference: https://ahood.vercel.app/docs",
835
+ "Full reference: https://www.ahood.dev/docs",
573
836
  ].join("\n");
574
837
  }
package/dist/http.js CHANGED
@@ -4,9 +4,17 @@ import { sanitizeForTerminal } from "./terminal-safe.js";
4
4
  import { CLI_NAME, CLI_VERSION } from "./version.js";
5
5
  export class ApiError extends Error {
6
6
  status;
7
- constructor(status, message) {
7
+ body;
8
+ // The parsed JSON error body, when there was one and it parsed as an
9
+ // object -- not just the `.error` string already folded into `message`.
10
+ // whoami.ts needs this to read the `provisioning` flag GET /api/v1/profile
11
+ // sets on a 404 for "row not provisioned yet" and tell it apart from a 404
12
+ // that means something else, which `message` alone can't do once it's been
13
+ // through sanitizeErrorMessage.
14
+ constructor(status, message, body) {
8
15
  super(message);
9
16
  this.status = status;
17
+ this.body = body;
10
18
  }
11
19
  }
12
20
  // Distinguished from ApiError so index.ts's top-level handler can map it to
@@ -15,6 +23,36 @@ export class ApiError extends Error {
15
23
  export class NetworkError extends Error {
16
24
  }
17
25
  const DEFAULT_TIMEOUT_MS = 30_000;
26
+ // ahood-cli#159: 429 policy. The registry rate-limits many endpoints the CLI
27
+ // calls directly, and every limited endpoint answers with a Retry-After. Two
28
+ // bounded retries, then a failure that says what actually happened ("rate
29
+ // limited", with the server's own wait) instead of a generic status message
30
+ // that reads like a registry bug.
31
+ const RATE_LIMIT_MAX_RETRIES = 2;
32
+ const RATE_LIMIT_FIRST_WAIT_MS = 1_000;
33
+ // Cap on a single backoff hop, however large the server's Retry-After is: a
34
+ // CLI must not silently hang for minutes on one header, and the final error
35
+ // still reports the server's uncapped ask.
36
+ const RATE_LIMIT_MAX_WAIT_MS = 30_000;
37
+ function sleep(ms) {
38
+ return new Promise((resolve) => setTimeout(resolve, ms));
39
+ }
40
+ // Retry-After is seconds per RFC 7231 ("120"), but the same RFC also allows an
41
+ // HTTP-date and every rate limiter is one refactor away from switching shape --
42
+ // so parse both, defensively. Anything unparseable comes back as undefined so
43
+ // callers fall back to their own backoff rather than trusting garbage.
44
+ export function parseRetryAfterMs(header, now = Date.now()) {
45
+ const value = header?.trim();
46
+ if (!value)
47
+ return undefined;
48
+ const seconds = Number(value);
49
+ if (Number.isFinite(seconds) && seconds >= 0)
50
+ return seconds * 1000;
51
+ const when = Date.parse(value);
52
+ if (!Number.isNaN(when))
53
+ return Math.max(0, when - now);
54
+ return undefined;
55
+ }
18
56
  // A hung/black-holed server would otherwise stall the process indefinitely --
19
57
  // relevant since this CLI is meant to run unattended in CI and be driven by
20
58
  // agents. Callers (e.g. publish's upload) can still pass their own `signal`.
@@ -70,31 +108,92 @@ export function sanitizeErrorMessage(message) {
70
108
  // exists to suppress. Length alone is safe to report.
71
109
  return `Request failed with an unexpected, oversized, or HTML-shaped error response (${message.length} bytes) -- this usually means an upstream proxy/WAF failure, not a problem with your request.`;
72
110
  }
111
+ // ahood-cli#159 (finding 5): during an outage the registry answered a request
112
+ // with 402 and an HTML body -- a client expecting JSON got a parse-shaped
113
+ // failure ("Request failed with status 402") that named none of what actually
114
+ // happened. These are the statuses that plausibly mean "the whole registry is
115
+ // down or blocked" rather than "your specific request was rejected": the 5xx
116
+ // family, plus 402 (this incident's status -- a billing/quota gate in front of
117
+ // the registry is exactly the kind of thing that fails open with an infra page
118
+ // rather than a JSON error).
119
+ //
120
+ // Deliberately NOT extended to 401/403/404/410/429: those already have their
121
+ // own well-understood meanings elsewhere in this CLI (see exit-code.ts,
122
+ // login.ts's device-code polling, whoami.ts's provisioning check) and, unlike
123
+ // a 5xx or a paywall, a body-less/non-JSON response on one of them is far more
124
+ // likely to be "this particular endpoint just doesn't send a body on this
125
+ // status" than "the registry is down". Treating those as an outage too would
126
+ // overfit this one incident's status code into a much broader claim than the
127
+ // evidence supports.
128
+ const LIKELY_OUTAGE_STATUSES = new Set([402, 500, 502, 503, 504]);
73
129
  export async function apiJson(path, init = {}) {
74
- const res = await apiFetch(path, init);
75
- if (!res.ok) {
76
- // The error body may not be valid/object JSON (proxy error pages, an
77
- // empty body, a literal `null`) -- fall back to the status-only message
78
- // rather than crashing on `body.error` of something that isn't an object.
79
- const body = await res.json().catch(() => undefined);
80
- const message = body && typeof body === "object" && typeof body.error === "string"
81
- ? sanitizeErrorMessage(body.error)
82
- : `Request failed with status ${res.status}`;
83
- throw new ApiError(res.status, message);
84
- }
85
- // A 204 No Content (or any 2xx with an empty body) has nothing to parse --
86
- // res.json() throws on empty input, which previously surfaced as a
87
- // generic "Malformed response" error even though the request succeeded
88
- // (ahood-cli#103). Callers that don't need response data (e.g. token
89
- // revoke, which awaits this without using the result) get `undefined`
90
- // back instead of a spurious failure.
91
- const text = await res.text();
92
- if (text === "")
93
- return undefined;
94
- try {
95
- return JSON.parse(text);
96
- }
97
- catch {
98
- throw new Error(`Malformed response from ${getApiUrl()}${path}: expected JSON.`);
130
+ // ahood-cli#159: 429 is its own case, before the generic !res.ok handling.
131
+ // It means "you are asking too fast", and the server tells us exactly when
132
+ // to come back -- surface neither as a generic error, and retry a bounded
133
+ // number of times first. Every other status keeps its exact previous
134
+ // handling below.
135
+ for (let attempt = 0;; attempt++) {
136
+ const res = await apiFetch(path, init);
137
+ if (res.status === 429) {
138
+ // Drain the rejected body so undici releases the socket instead of
139
+ // keeping it checked out while we wait.
140
+ await res.text().catch(() => "");
141
+ const retryAfterMs = parseRetryAfterMs(res.headers.get("Retry-After"));
142
+ if (attempt < RATE_LIMIT_MAX_RETRIES) {
143
+ // No usable Retry-After -> exponential (1s, 2s), the same fallback
144
+ // shape the login poll uses. One wait never exceeds the cap.
145
+ const waitMs = Math.min(retryAfterMs ?? RATE_LIMIT_FIRST_WAIT_MS * 2 ** attempt, RATE_LIMIT_MAX_WAIT_MS);
146
+ await sleep(waitMs);
147
+ continue;
148
+ }
149
+ // Retries exhausted. Report the server's own ask (not our capped wait):
150
+ // that is the number the user needs to act on.
151
+ const seconds = Math.ceil((retryAfterMs ?? RATE_LIMIT_FIRST_WAIT_MS * 2 ** RATE_LIMIT_MAX_RETRIES) / 1000);
152
+ // ahood-cli#159 (finding 3): an IP-keyed limit is shared by every
153
+ // unauthenticated caller behind that address -- a CI runner, a
154
+ // corporate NAT, a university network. This CLI has no say over how
155
+ // the registry buckets requests (that's server policy, out of this
156
+ // repo's reach), but it DOES know, right here, that the caller making
157
+ // this specific request has no token attached -- the one fact that
158
+ // makes the hint worth showing now rather than always. Logged-in
159
+ // callers get nothing extra: they already did the one thing this
160
+ // message would suggest, and repeating it at them would just be noise
161
+ // on top of a real rate limit. Worded as "may" -- this CLI cannot
162
+ // confirm the registry actually keys authenticated requests
163
+ // differently, only that it's a plausible reason this specific 429
164
+ // landed on an anonymous caller.
165
+ const loginHint = resolveToken()
166
+ ? ""
167
+ : " You're not logged in, so this request shares a rate limit with every other anonymous caller on your network -- `ahood login` may get you your own.";
168
+ throw new ApiError(429, `Rate limited -- try again in ${seconds} seconds.${loginHint}`);
169
+ }
170
+ if (!res.ok) {
171
+ // The error body may not be valid/object JSON (proxy error pages, an
172
+ // empty body, a literal `null`) -- fall back to the status-only message
173
+ // rather than crashing on `body.error` of something that isn't an object.
174
+ const body = await res.json().catch(() => undefined);
175
+ const hasStructuredError = body && typeof body === "object" && typeof body.error === "string";
176
+ const message = hasStructuredError
177
+ ? sanitizeErrorMessage(body.error)
178
+ : LIKELY_OUTAGE_STATUSES.has(res.status)
179
+ ? `The registry appears to be unavailable (HTTP ${res.status}).`
180
+ : `Request failed with status ${res.status}`;
181
+ throw new ApiError(res.status, message, body);
182
+ }
183
+ // A 204 No Content (or any 2xx with an empty body) has nothing to parse --
184
+ // res.json() throws on empty input, which previously surfaced as a
185
+ // generic "Malformed response" error even though the request succeeded
186
+ // (ahood-cli#103). Callers that don't need response data (e.g. token
187
+ // revoke, which awaits this without using the result) get `undefined`
188
+ // back instead of a spurious failure.
189
+ const text = await res.text();
190
+ if (text === "")
191
+ return undefined;
192
+ try {
193
+ return JSON.parse(text);
194
+ }
195
+ catch {
196
+ throw new Error(`Malformed response from ${getApiUrl()}${path}: expected JSON.`);
197
+ }
99
198
  }
100
199
  }