@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/README.md +92 -6
- package/dist/commands/add.js +147 -54
- package/dist/commands/completion.js +61 -37
- package/dist/commands/diff.js +4 -1
- package/dist/commands/edit.js +3 -1
- package/dist/commands/init.js +90 -10
- package/dist/commands/list.js +11 -5
- package/dist/commands/login.js +63 -3
- package/dist/commands/mcp.js +6 -3
- package/dist/commands/publish.js +35 -5
- package/dist/commands/read.js +30 -2
- package/dist/commands/remove.js +83 -12
- package/dist/commands/search.js +12 -4
- package/dist/commands/share.js +5 -2
- package/dist/commands/snap.js +140 -35
- package/dist/commands/star.js +5 -2
- package/dist/commands/unpublish.js +5 -1
- package/dist/commands/update.js +109 -11
- package/dist/commands/versions.js +3 -1
- package/dist/commands/view.js +4 -1
- package/dist/commands/whoami.js +50 -9
- package/dist/flags.js +100 -8
- package/dist/help.js +292 -15
- package/dist/http.js +137 -45
- package/dist/index.js +106 -22
- package/dist/kind-info.js +40 -0
- package/dist/kinds.js +122 -0
- package/dist/lockfile.js +397 -38
- package/dist/mcp/safe-tool.js +14 -2
- package/dist/spec.js +8 -5
- package/dist/terminal-safe.js +62 -0
- package/dist/useme/SKILL.md +170 -0
- package/dist/useme.js +14 -0
- package/package.json +3 -2
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
147
|
-
//
|
|
148
|
-
// (`ahood
|
|
149
|
-
// main()'s generic top-level --help
|
|
150
|
-
|
|
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(
|
|
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
|
|
159
|
-
const suggestion =
|
|
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(
|
|
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(
|
|
167
|
-
console.log(entry ? formatCommandHelp(entry) :
|
|
180
|
+
const entry = findCommandHelp(noun, sub);
|
|
181
|
+
console.log(entry ? formatCommandHelp(entry) : formatKindHelp(noun));
|
|
168
182
|
return;
|
|
169
183
|
}
|
|
170
|
-
|
|
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
|
-
|
|
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(
|
|
234
|
-
console.log(entry ? formatCommandHelp(entry) :
|
|
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
|
+
}
|