@ahood/cli 0.8.8 → 0.10.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/dist/http.js CHANGED
@@ -1,11 +1,20 @@
1
1
  import { getApiUrl } from "./config.js";
2
2
  import { resolveToken } from "./credentials.js";
3
+ import { sanitizeForTerminal } from "./terminal-safe.js";
3
4
  import { CLI_NAME, CLI_VERSION } from "./version.js";
4
5
  export class ApiError extends Error {
5
6
  status;
6
- 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) {
7
15
  super(message);
8
16
  this.status = status;
17
+ this.body = body;
9
18
  }
10
19
  }
11
20
  // Distinguished from ApiError so index.ts's top-level handler can map it to
@@ -14,6 +23,36 @@ export class ApiError extends Error {
14
23
  export class NetworkError extends Error {
15
24
  }
16
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
+ }
17
56
  // A hung/black-holed server would otherwise stall the process indefinitely --
18
57
  // relevant since this CLI is meant to run unattended in CI and be driven by
19
58
  // agents. Callers (e.g. publish's upload) can still pass their own `signal`.
@@ -31,8 +70,12 @@ export async function apiFetch(path, init = {}) {
31
70
  });
32
71
  }
33
72
  catch (error) {
34
- const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
35
- const message = error instanceof Error ? error.message : String(error);
73
+ // The `instanceof Error` guard on the cause is load-bearing, not defensive
74
+ // noise: `cause` is `unknown`, and optional-chaining into `.message`
75
+ // instead would print a bare "undefined" as the reason a request failed
76
+ // (ahood-cli#129). Dropping the reason entirely is the better failure mode.
77
+ const cause = error instanceof Error && error.cause instanceof Error ? `: ${sanitizeForTerminal(error.cause.message)}` : "";
78
+ const message = sanitizeForTerminal(error instanceof Error ? error.message : error);
36
79
  throw new NetworkError(`Request to ${getApiUrl()}${path} failed (${message}${cause})`);
37
80
  }
38
81
  }
@@ -44,23 +87,8 @@ const MAX_ERROR_MESSAGE_LENGTH = 500;
44
87
  // sits on every apiJson call in the CLI -- including `add`, which prints
45
88
  // server-sourced text immediately before a masked secret prompt, the exact
46
89
  // moment a cursor-movement/line-clear sequence can repaint what the user sees.
90
+ // See src/terminal-safe.ts for what that substitution does and why.
47
91
  //
48
- // Control characters are replaced with a space rather than deleted so that
49
- // stripping can't silently glue two tokens into one misleading word.
50
- //
51
- // Newlines (\x0a) and carriage returns (\x0d) are inside this range and are
52
- // deliberately flattened too: a forged prompt line ("...\n\nEnter your token:")
53
- // needs no escape sequence at all, just a newline, so preserving them would
54
- // leave the spoofing half of the threat model open while closing the escape
55
- // half. The readability cost is small because anything reaching this branch is
56
- // already capped at MAX_ERROR_MESSAGE_LENGTH -- a few lines' worth of text, not
57
- // a stack trace.
58
- //
59
- // Known duplication: this is the same substitution as `sanitizeForTerminal` in
60
- // src/commands/add.ts (ahood-cli#122). It is copied rather than shared on
61
- // purpose -- promoting a shared helper means editing add.ts's call sites, which
62
- // is follow-up refactor work kept out of this security fix; see ahood-cli#127.
63
- const CONTROL_CHARACTERS = /[\x00-\x1f\x7f-\x9f]/g;
64
92
  // Defense in depth (ahood-cli#31): the API is expected to never forward a raw
65
93
  // upstream error body (an HTML block page from a proxy/WAF, a giant stack
66
94
  // trace, etc.) into `error`, but this CLI shouldn't trust that unconditionally
@@ -69,39 +97,103 @@ const CONTROL_CHARACTERS = /[\x00-\x1f\x7f-\x9f]/g;
69
97
  // a normal error string is replaced with a short, safe summary instead.
70
98
  export function sanitizeErrorMessage(message) {
71
99
  const looksLikeHtml = /<!DOCTYPE|<html[\s>]/i.test(message);
100
+ // The length argument is belt-and-braces, not behavior: this branch already
101
+ // guarantees `message.length <= MAX_ERROR_MESSAGE_LENGTH`, and an oversized
102
+ // body takes the generic-summary path below instead.
72
103
  if (!looksLikeHtml && message.length <= MAX_ERROR_MESSAGE_LENGTH)
73
- return message.replace(CONTROL_CHARACTERS, " ");
104
+ return sanitizeForTerminal(message, MAX_ERROR_MESSAGE_LENGTH);
74
105
  // No preview of the raw content: an HTML-shaped body's first bytes are
75
106
  // exactly where infra details (e.g. a Cloudflare block page's title, Ray
76
107
  // ID) live, so a "helpful" excerpt would leak the same details this
77
108
  // exists to suppress. Length alone is safe to report.
78
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.`;
79
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]);
80
129
  export async function apiJson(path, init = {}) {
81
- const res = await apiFetch(path, init);
82
- if (!res.ok) {
83
- // The error body may not be valid/object JSON (proxy error pages, an
84
- // empty body, a literal `null`) -- fall back to the status-only message
85
- // rather than crashing on `body.error` of something that isn't an object.
86
- const body = await res.json().catch(() => undefined);
87
- const message = body && typeof body === "object" && typeof body.error === "string"
88
- ? sanitizeErrorMessage(body.error)
89
- : `Request failed with status ${res.status}`;
90
- throw new ApiError(res.status, message);
91
- }
92
- // A 204 No Content (or any 2xx with an empty body) has nothing to parse --
93
- // res.json() throws on empty input, which previously surfaced as a
94
- // generic "Malformed response" error even though the request succeeded
95
- // (ahood-cli#103). Callers that don't need response data (e.g. token
96
- // revoke, which awaits this without using the result) get `undefined`
97
- // back instead of a spurious failure.
98
- const text = await res.text();
99
- if (text === "")
100
- return undefined;
101
- try {
102
- return JSON.parse(text);
103
- }
104
- catch {
105
- 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
+ }
106
198
  }
107
199
  }
package/dist/index.js CHANGED
@@ -20,15 +20,16 @@ import { versions } from "./commands/versions.js";
20
20
  import { diff } from "./commands/diff.js";
21
21
  import { completion } from "./commands/completion.js";
22
22
  import { mcp } from "./commands/mcp.js";
23
+ import { extractKindFlag, resolveScope } from "./kinds.js";
24
+ import { readUseme } from "./useme.js";
25
+ import { UsageError } from "./usage-error.js";
23
26
  import { init } from "./commands/init.js";
24
27
  import { createGroup, listGroups, groupMembers, inviteLink, joinGroup, removeMember, leaveGroup, deleteGroup, } from "./commands/group.js";
25
28
  import { createSnap, listSnaps, searchSnaps, showSnap, removeSnap, shareSnap, unshareSnap, tagsSnap, } from "./commands/snap.js";
26
- import { formatHelp, formatSkillHelp, formatGroupHelp, formatSnapHelp, formatCommandHelp, findCommandHelp } from "./help.js";
29
+ import { formatHelp, formatKindHelp, formatGroupHelp, formatSnapHelp, formatCommandHelp, findCommandHelp } from "./help.js";
27
30
  import { ApiError } from "./http.js";
28
31
  import { exitCodeFor } from "./exit-code.js";
29
32
  import { CLI_NAME, CLI_VERSION } from "./version.js";
30
- // Every skill-entity verb, reached only as `ahood skill <verb>` -- see
31
- // dispatchSkill() below. "show" is an alias for "view" (issue #30).
32
33
  const SKILL_COMMANDS = {
33
34
  search,
34
35
  view,
@@ -44,7 +45,7 @@ const SKILL_COMMANDS = {
44
45
  // can never accidentally move a pin forward. Reuses update()'s existing
45
46
  // "no targets = every installed skill, explicit targets = just those"
46
47
  // behavior as-is; no new logic.
47
- outdated: (args) => update([...args, "--dry-run"]),
48
+ outdated: (args, scope) => (scope ? update([...args, "--dry-run"], scope) : update([...args, "--dry-run"])),
48
49
  remove,
49
50
  edit,
50
51
  unpublish,
@@ -92,8 +93,9 @@ const COMMANDS = {
92
93
  whoami,
93
94
  token,
94
95
  completion,
95
- mcp,
96
+ mcp: dispatchMcp,
96
97
  skill: dispatchSkill,
98
+ agent: dispatchAgent,
97
99
  group: dispatchGroup,
98
100
  snap: dispatchSnap,
99
101
  };
@@ -105,7 +107,8 @@ const COMMANDS = {
105
107
  // here just means "a command that owns its own sub-verb dispatch", the same
106
108
  // sense "skill" already used before "group" (the entity) existed. "snap"
107
109
  // joins this set for the exact same reason (ahood-cli#107).
108
- const GROUP_COMMANDS = new Set(["skill", "group", "snap"]);
110
+ // "agent" and "mcp" join it as registry nouns (ahood-cli#172).
111
+ const GROUP_COMMANDS = new Set(["skill", "agent", "mcp", "group", "snap"]);
109
112
  function levenshtein(a, b) {
110
113
  const dp = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
111
114
  for (let i = 0; i <= a.length; i++)
@@ -143,31 +146,96 @@ function closestGroupVerb(input) {
143
146
  function closestSnapVerb(input) {
144
147
  return closestOf(input, Object.keys(SNAP_VERBS));
145
148
  }
146
- // `ahood skill <verb> [...]` -- owns its own help handling at both the
147
- // group level (`ahood skill` / `ahood skill --help`) and the per-verb level
148
- // (`ahood skill <verb> --help`), since neither should fall through to
149
- // main()'s generic top-level --help interception (see GROUP_COMMANDS).
150
- async function dispatchSkill(args) {
149
+ // `ahood <noun> <verb> [...]` for every registry noun (skill, agent, mcp) --
150
+ // owns its own help handling at both the noun level (`ahood agent` /
151
+ // `ahood agent --help`) and the per-verb level (`ahood agent <verb> --help`),
152
+ // since neither should fall through to main()'s generic top-level --help
153
+ // interception (see GROUP_COMMANDS).
154
+ //
155
+ // Scope (ahood-cli#172): `--kind <k|all>` is consumed here, never by a
156
+ // handler, and resolved by resolveScope(): agent/mcp are strict for their own
157
+ // kind (a contradictory --kind is a usage error), while skill stays legacy
158
+ // cross-kind unless --kind narrows it. `ahood skill publish` is the one
159
+ // exception: its --kind has always been publish's own "what kind of artifact
160
+ // is this folder" flag, so it is passed through untouched.
161
+ async function dispatchRegistry(noun, args) {
151
162
  const [sub, ...rest] = args;
152
163
  if (!sub || sub === "--help" || sub === "-h") {
153
- console.log(formatSkillHelp());
164
+ console.log(formatKindHelp(noun));
154
165
  return;
155
166
  }
156
167
  const handler = SKILL_COMMANDS[sub];
157
168
  if (!handler) {
158
- console.error(`Unknown skill command: ${sub}`);
159
- const suggestion = closestSkillCommand(sub);
169
+ console.error(`Unknown ${noun} command: ${sub}`);
170
+ const suggestion = closestOf(sub, noun === "mcp" ? ["serve", ...Object.keys(SKILL_COMMANDS)] : Object.keys(SKILL_COMMANDS));
160
171
  if (suggestion)
161
172
  console.error(`Did you mean '${suggestion}'?`);
162
- console.error("Run `ahood skill --help` for a list of commands.");
173
+ console.error(`Run \`ahood ${noun} --help\` for a list of commands.`);
174
+ if (noun === "mcp") {
175
+ console.error("To start the local MCP server, run `ahood mcp serve` (or `ahood mcp` with no arguments).");
176
+ }
163
177
  process.exit(2);
164
178
  }
165
179
  if (rest.includes("--help") || rest.includes("-h")) {
166
- const entry = findCommandHelp("skill", sub);
167
- console.log(entry ? formatCommandHelp(entry) : formatSkillHelp());
180
+ const entry = findCommandHelp(noun, sub);
181
+ console.log(entry ? formatCommandHelp(entry) : formatKindHelp(noun));
168
182
  return;
169
183
  }
170
- await handler(rest);
184
+ if (noun === "skill" && sub === "publish") {
185
+ await handler(rest);
186
+ return;
187
+ }
188
+ const { kind, rest: handlerArgs } = extractKindFlag(rest);
189
+ const scope = resolveScope(noun, kind);
190
+ if (scope.kind === "all") {
191
+ // Legacy: the exact pre-#172 call, minus an explicit `--kind all`.
192
+ await handler(kind === undefined ? rest : handlerArgs);
193
+ return;
194
+ }
195
+ try {
196
+ await handler(handlerArgs, scope);
197
+ }
198
+ catch (error) {
199
+ // Handlers phrase their usage lines as `ahood skill <verb>`; under another
200
+ // noun, say the command the user actually typed.
201
+ if (error instanceof UsageError && noun !== "skill") {
202
+ error.message = error.message.replace(/(Usage: |or: )ahood skill /g, `$1ahood ${noun} `);
203
+ }
204
+ throw error;
205
+ }
206
+ }
207
+ async function dispatchSkill(args) {
208
+ await dispatchRegistry("skill", args);
209
+ }
210
+ async function dispatchAgent(args) {
211
+ await dispatchRegistry("agent", args);
212
+ }
213
+ // `ahood mcp` keeps its original contract byte-for-byte: with NO arguments it
214
+ // is the local stdio MCP server that existing host configs launch -- nothing
215
+ // is printed and nothing else runs first. `ahood mcp serve` is the same
216
+ // server under an explicit, documented name. Every other first word is a
217
+ // registry verb for mcp-kind entries, help, or an error: an unrecognized one
218
+ // fails with usage (dispatchRegistry) rather than silently starting a
219
+ // long-lived server that a typo would otherwise leave hanging.
220
+ async function dispatchMcp(args) {
221
+ if (args.length === 0) {
222
+ await mcp(args);
223
+ return;
224
+ }
225
+ const [sub, ...rest] = args;
226
+ if (sub === "serve") {
227
+ if (rest.includes("--help") || rest.includes("-h")) {
228
+ const entry = findCommandHelp("mcp", "serve");
229
+ console.log(entry ? formatCommandHelp(entry) : formatKindHelp("mcp"));
230
+ return;
231
+ }
232
+ if (rest.length > 0) {
233
+ throw new UsageError(`\`ahood mcp serve\` takes no arguments (got: ${rest.join(" ")}).\nUsage: ahood mcp serve`);
234
+ }
235
+ await mcp([]);
236
+ return;
237
+ }
238
+ await dispatchRegistry("mcp", args);
171
239
  }
172
240
  // `ahood group <verb> [...]` -- owns its own help handling at both the
173
241
  // group level (`ahood group` / `ahood group --help`) and the per-verb level
@@ -228,10 +296,26 @@ async function main() {
228
296
  }
229
297
  if (command === "help") {
230
298
  const sub = args[0];
231
- if (sub === "skill") {
299
+ // `ahood help useme` (ahood-cli#172): the bundled self-skill, as raw
300
+ // bytes on stdout and nothing else -- no banner, no trailing newline of
301
+ // our own -- so the output IS the SKILL.md. No network, no credentials,
302
+ // no project writes.
303
+ if (sub === "useme") {
304
+ let content;
305
+ try {
306
+ content = readUseme();
307
+ }
308
+ catch (error) {
309
+ console.error(`Could not read the bundled ahood SKILL.md: ${error instanceof Error ? error.message : String(error)}`);
310
+ process.exit(1);
311
+ }
312
+ process.stdout.write(content);
313
+ return;
314
+ }
315
+ if (sub === "skill" || sub === "agent" || sub === "mcp") {
232
316
  const verb = args[1];
233
- const entry = verb ? findCommandHelp("skill", verb) : undefined;
234
- console.log(entry ? formatCommandHelp(entry) : formatSkillHelp());
317
+ const entry = verb ? findCommandHelp(sub, verb) : undefined;
318
+ console.log(entry ? formatCommandHelp(entry) : formatKindHelp(sub));
235
319
  return;
236
320
  }
237
321
  if (sub === "group") {
@@ -310,4 +394,4 @@ if (isEntrypoint) {
310
394
  main();
311
395
  }
312
396
  // Exported for tests only -- the CLI itself only ever calls main() above.
313
- export { main, dispatchSkill, dispatchGroup, dispatchSnap, COMMANDS, SKILL_COMMANDS, GROUP_VERBS, SNAP_VERBS };
397
+ export { main, dispatchSkill, dispatchAgent, dispatchMcp, dispatchRegistry, dispatchGroup, dispatchSnap, COMMANDS, SKILL_COMMANDS, GROUP_VERBS, SNAP_VERBS };
@@ -0,0 +1,40 @@
1
+ // Pure kind metadata (no imports, no I/O) so help.ts -- which
2
+ // scripts/sync-readme.mjs imports straight from dist/ -- can use it without
3
+ // pulling in the HTTP/filesystem layer. Behavior lives in kinds.ts.
4
+ //
5
+ // Kind-aware registry commands (ahood-cli#172).
6
+ //
7
+ // The registry stores four artifact kinds (the backend's
8
+ // lib/skills/validation.ts ALLOWED_KINDS: skill, agent, mcp, doc), and a
9
+ // kind is fixed when an entry is created -- PATCH's allow-list has no `kind`
10
+ // -- so the kind an entry reports now is the kind it has always had. This
11
+ // CLI has a noun group for three of them. `doc` is a real registry kind left
12
+ // out of this CLI's scope on purpose; adding it means one entry in KINDS
13
+ // below plus its help/init template, not a new dispatcher.
14
+ export const KINDS = {
15
+ skill: {
16
+ label: "skill",
17
+ article: "a",
18
+ plural: "skills",
19
+ rootDoc: "SKILL.md",
20
+ installsTo: ".claude/skills/<owner>@<skill>/",
21
+ },
22
+ agent: {
23
+ label: "agent",
24
+ article: "an",
25
+ plural: "agents",
26
+ rootDoc: "AGENT.md",
27
+ installsTo: ".claude/agents/<owner>@<agent>.md",
28
+ },
29
+ mcp: {
30
+ label: "MCP server manifest",
31
+ article: "an",
32
+ plural: "MCP server manifests",
33
+ rootDoc: "server.json",
34
+ installsTo: "an entry in .mcp.json",
35
+ },
36
+ };
37
+ export const CLI_KINDS = Object.keys(KINDS);
38
+ export function isCliKind(value) {
39
+ return typeof value === "string" && CLI_KINDS.includes(value);
40
+ }
package/dist/kinds.js ADDED
@@ -0,0 +1,122 @@
1
+ // Kind-aware registry commands (ahood-cli#172): scope resolution and the
2
+ // wrong-kind guards every kind-scoped verb runs before its first side effect.
3
+ // The kind table itself lives in kind-info.ts.
4
+ import { existsSync } from "node:fs";
5
+ import { apiJson } from "./http.js";
6
+ import { agentPath, skillDir } from "./spec.js";
7
+ import { UsageError } from "./usage-error.js";
8
+ import { KINDS, CLI_KINDS, isCliKind } from "./kind-info.js";
9
+ export { KINDS, CLI_KINDS, isCliKind };
10
+ export const LEGACY_SCOPE = { noun: "skill", kind: "all" };
11
+ export function strictKind(scope) {
12
+ return scope && scope.kind !== "all" ? scope.kind : undefined;
13
+ }
14
+ // A wrong-kind target: the user pointed a noun at the wrong kind of entry, so
15
+ // it maps to exit 2 ("fix your input") through UsageError, the same code a
16
+ // server-side validation refusal gets.
17
+ export class KindMismatchError extends UsageError {
18
+ }
19
+ export function describeKind(kind) {
20
+ return `${KINDS[kind].article} ${KINDS[kind].label}`;
21
+ }
22
+ // Throws unless `actual` is the scope's kind. A no-op for the legacy scope.
23
+ //
24
+ // Missing or unrecognized kind metadata is refused, never guessed: the legacy
25
+ // code treated a missing `kind` as a skill, and a strict verb that did the
26
+ // same would silently misclassify exactly the entry it exists to protect.
27
+ // That refusal is a plain Error (exit 1) rather than a KindMismatchError,
28
+ // since nothing about the user's input is wrong -- the registry's answer is.
29
+ export function assertKind(scope, key, actual, verb, action) {
30
+ const expected = strictKind(scope);
31
+ if (expected === undefined || actual === expected)
32
+ return;
33
+ const legacyHint = `\`ahood skill ${verb} ${key}\` (legacy, any kind)`;
34
+ if (isCliKind(actual)) {
35
+ throw new KindMismatchError(`${key} is ${describeKind(actual)}, not ${describeKind(expected)} -- refusing to ${action} it. ` +
36
+ `Use \`ahood ${actual} ${verb} ${key}\` instead.`);
37
+ }
38
+ if (actual === undefined || actual === null || actual === "") {
39
+ throw new Error(`The registry did not report a kind for ${key}, so ahood cannot confirm it is ${describeKind(expected)} -- ` +
40
+ `refusing to ${action} it. If you are sure, use ${legacyHint}.`);
41
+ }
42
+ throw new Error(`${key} has kind "${String(actual).slice(0, 40)}", which \`ahood ${scope.noun}\` does not handle -- ` +
43
+ `refusing to ${action} it. Use ${legacyHint} if you really mean it.`);
44
+ }
45
+ // Resolves an existing registry entry's kind and asserts it, for verbs whose
46
+ // own request carries no kind (star, share, edit, versions, ...). A no-op --
47
+ // and, importantly, no extra request -- for the legacy scope, so
48
+ // `ahood skill <verb>` sends exactly what it always has.
49
+ export async function ensureRemoteKind(scope, owner, skill, verb, action) {
50
+ if (strictKind(scope) === undefined)
51
+ return;
52
+ const detail = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`);
53
+ assertKind(scope, `${owner}/${skill}`, detail.kind, verb, action);
54
+ }
55
+ // What this project's own files say an installed pin is, without asking the
56
+ // registry. The lockfile records no `kind` (and this change does not migrate
57
+ // it), so the footprint each kind leaves is the evidence:
58
+ // skill -> a .claude/skills/<owner>@<skill>/ directory
59
+ // agent -> a .claude/agents/<owner>@<skill>.md file
60
+ // mcp -> neither, plus the mcp_config_hash only an mcp install records
61
+ // Anything else (both, or a bare pin with no hash -- a legacy mcp pin or a
62
+ // skill whose directory was deleted by hand) is undefined: unknown, and the
63
+ // caller must ask the registry rather than guess.
64
+ export function localInstalledKind(owner, skill, entry) {
65
+ const hasDir = existsSync(skillDir(owner, skill));
66
+ const hasAgentFile = existsSync(agentPath(owner, skill));
67
+ if (hasDir && !hasAgentFile)
68
+ return "skill";
69
+ if (hasAgentFile && !hasDir)
70
+ return "agent";
71
+ if (!hasDir && !hasAgentFile && entry?.mcp_config_hash !== undefined)
72
+ return "mcp";
73
+ return undefined;
74
+ }
75
+ // Pulls `--kind <value>` / `--kind=<value>` out of a registry verb's args.
76
+ // Returns the remaining args untouched otherwise, so a handler never sees a
77
+ // flag it does not declare.
78
+ export function extractKindFlag(args) {
79
+ let kind;
80
+ const rest = [];
81
+ for (let i = 0; i < args.length; i++) {
82
+ const arg = args[i];
83
+ if (arg === "--kind" || arg.startsWith("--kind=")) {
84
+ if (kind !== undefined)
85
+ throw new UsageError("--kind given more than once. Pass it once.");
86
+ if (arg === "--kind") {
87
+ const value = args[i + 1];
88
+ if (value === undefined || value.startsWith("--"))
89
+ throw new UsageError("--kind requires a value.");
90
+ kind = value;
91
+ i++;
92
+ }
93
+ else {
94
+ kind = arg.slice("--kind=".length);
95
+ }
96
+ continue;
97
+ }
98
+ rest.push(arg);
99
+ }
100
+ return { kind, rest };
101
+ }
102
+ // Resolves the scope for `ahood <noun> <verb> [--kind <k>]`.
103
+ // skill noun, no --kind or --kind all -> legacy (cross-kind)
104
+ // skill noun, --kind <k> -> strict <k>
105
+ // agent/mcp noun, no --kind or --kind <same> -> strict <noun>
106
+ // agent/mcp noun, any other --kind -> usage error (contradiction)
107
+ export function resolveScope(noun, kindFlag) {
108
+ const valid = [...CLI_KINDS, "all"].join(", ");
109
+ if (kindFlag !== undefined && kindFlag !== "all" && !isCliKind(kindFlag)) {
110
+ throw new UsageError(`--kind must be one of: ${valid} (got "${kindFlag}").`);
111
+ }
112
+ if (noun === "skill") {
113
+ return kindFlag === undefined || kindFlag === "all" ? LEGACY_SCOPE : { noun, kind: kindFlag };
114
+ }
115
+ if (kindFlag !== undefined && kindFlag !== noun) {
116
+ throw new UsageError(`\`ahood ${noun}\` only operates on ${KINDS[noun].plural} -- --kind ${kindFlag} contradicts it. ` +
117
+ (kindFlag === "all"
118
+ ? "Use `ahood skill <verb>` (legacy, any kind) for a cross-kind operation."
119
+ : `Use \`ahood ${kindFlag} <verb>\` instead.`));
120
+ }
121
+ return { noun, kind: noun };
122
+ }