@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/commands/edit.js
CHANGED
|
@@ -2,13 +2,14 @@ import { apiJson } from "../http.js";
|
|
|
2
2
|
import { flagValue } from "../flags.js";
|
|
3
3
|
import { parseOwnerSkill, validateExternalUrl } from "../spec.js";
|
|
4
4
|
import { UsageError } from "../usage-error.js";
|
|
5
|
+
import { ensureRemoteKind } from "../kinds.js";
|
|
5
6
|
const USAGE = "Usage: ahood skill edit <owner>/<skill> [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--visibility public|private] [--homepage <url>] [--repository <url>]";
|
|
6
7
|
// Mirrors PATCH /api/v1/skills/{owner}/{skill}'s allow-list exactly
|
|
7
8
|
// (lib/skills/mutations.ts's UpdateSkillInput) -- only a field the caller
|
|
8
9
|
// explicitly passed a flag for is included in the body, so an omitted flag
|
|
9
10
|
// never clobbers an existing value (the route only validates/writes keys
|
|
10
11
|
// that are present on the body at all).
|
|
11
|
-
export async function edit(args) {
|
|
12
|
+
export async function edit(args, scope) {
|
|
12
13
|
const spec = args[0];
|
|
13
14
|
if (!spec || spec.startsWith("--"))
|
|
14
15
|
throw new UsageError(USAGE);
|
|
@@ -43,6 +44,7 @@ export async function edit(args) {
|
|
|
43
44
|
if (Object.keys(body).length === 0) {
|
|
44
45
|
throw new UsageError("Nothing to update -- pass at least one of --tagline, --tags, --license, --visibility, --homepage, --repository.");
|
|
45
46
|
}
|
|
47
|
+
await ensureRemoteKind(scope, owner, skill, "edit", "edit");
|
|
46
48
|
const updated = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`, {
|
|
47
49
|
method: "PATCH",
|
|
48
50
|
headers: { "Content-Type": "application/json" },
|
package/dist/commands/init.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { basename, join, resolve, sep } from "node:path";
|
|
3
3
|
import { UsageError } from "../usage-error.js";
|
|
4
|
+
import { CLI_KINDS, KINDS, strictKind } from "../kinds.js";
|
|
4
5
|
const USAGE = "Usage: ahood skill init [name]";
|
|
5
6
|
// Mirrors the server's slug convention referenced by ahood-cli#60 (and the
|
|
6
7
|
// same shape as spec.ts's SEGMENT_RE, minus "." and "_" -- skill slugs are
|
|
@@ -56,15 +57,76 @@ description: TODO -- describe what this skill does and when Claude should use it
|
|
|
56
57
|
TODO: describe, step by step, what Claude should do when this skill is invoked.
|
|
57
58
|
`;
|
|
58
59
|
}
|
|
60
|
+
// AGENT.md, for `ahood agent init` (ahood-cli#172). The backend's publish
|
|
61
|
+
// validator (lib/publish/parse-agent-frontmatter.ts in the ahood repo) is a
|
|
62
|
+
// hand-rolled flat `key: value` reader, not YAML: it requires the file to
|
|
63
|
+
// START with a `---` line, a closing `---` line followed by a newline, and
|
|
64
|
+
// non-empty `name` and `description`; an optional `skills: [owner/slug, ...]`
|
|
65
|
+
// flow list must hold owner/slug entries. Claude Code's own subagent loader
|
|
66
|
+
// reads the same frontmatter (name, description, and optionally tools and
|
|
67
|
+
// model). So: no leading comment above the opening `---` (that alone fails
|
|
68
|
+
// publish), and the optional keys are left as commented-out examples, with
|
|
69
|
+
// no colon in any prose comment, so neither reader can mistake a comment for
|
|
70
|
+
// a key.
|
|
71
|
+
function buildAgentMd(name) {
|
|
72
|
+
return `---
|
|
73
|
+
name: ${name}
|
|
74
|
+
description: TODO -- describe what this agent does and when Claude should delegate to it.
|
|
75
|
+
# Optional. Uncomment to restrict the tools this agent may use (omit to inherit all).
|
|
76
|
+
# tools: Read, Grep, Glob
|
|
77
|
+
# Optional. Uncomment to pin a model for this agent.
|
|
78
|
+
# model: sonnet
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
TODO -- write the system prompt for this agent. Say what it is responsible for,
|
|
82
|
+
the steps it should take, and what it should hand back when it is done.
|
|
83
|
+
`;
|
|
84
|
+
}
|
|
85
|
+
// server.json, for `ahood mcp init` (ahood-cli#172). Mirrors exactly what the
|
|
86
|
+
// backend's publish validator (lib/publish/parse-server-manifest.ts in the
|
|
87
|
+
// ahood repo) accepts: a JSON object with non-empty `name` and `description`
|
|
88
|
+
// and exactly one of `packages` (one npm/npx entry) or `remotes` (one http(s)
|
|
89
|
+
// url). The starter uses `remotes`, pointed at a reserved example.com host
|
|
90
|
+
// (RFC 2606), deliberately: a `packages` placeholder would name an npm
|
|
91
|
+
// package that `ahood mcp add` turns into `npx -y <identifier>@<version>` --
|
|
92
|
+
// an install of whatever someone later publishes under that name if the
|
|
93
|
+
// starter were ever published unedited. A reserved host can never resolve to
|
|
94
|
+
// anyone's server. JSON has no comments, so the editing guidance is printed
|
|
95
|
+
// by init instead.
|
|
96
|
+
function buildServerJson(name) {
|
|
97
|
+
const manifest = {
|
|
98
|
+
name,
|
|
99
|
+
description: "TODO -- describe what this MCP server does and when an agent should use it.",
|
|
100
|
+
remotes: [{ url: "https://mcp.example.com/mcp" }],
|
|
101
|
+
};
|
|
102
|
+
return `${JSON.stringify(manifest, null, 2)}
|
|
103
|
+
`;
|
|
104
|
+
}
|
|
105
|
+
const TEMPLATES = {
|
|
106
|
+
skill: buildSkillMd,
|
|
107
|
+
agent: buildAgentMd,
|
|
108
|
+
mcp: buildServerJson,
|
|
109
|
+
};
|
|
110
|
+
const NEXT_STEPS = {
|
|
111
|
+
skill: ["Fill in the description, then flesh out the ## Instructions section."],
|
|
112
|
+
agent: ["Fill in the description, then write the agent's system prompt below the frontmatter."],
|
|
113
|
+
mcp: [
|
|
114
|
+
"Fill in the description and point remotes[0].url at your server's https endpoint, or replace",
|
|
115
|
+
'"remotes" with one npm package: "packages": [{"registry_type": "npm", "runtime_hint": "npx",',
|
|
116
|
+
'"identifier": "<npm-package>", "version": "<x.y.z>"}] (plus optional "environment_variables").',
|
|
117
|
+
],
|
|
118
|
+
};
|
|
59
119
|
// Falls back to "my-skill" when the directory name itself isn't usable as a
|
|
60
120
|
// bare YAML scalar (empty, or starting with a character like "-" or "@" that
|
|
61
121
|
// would need quoting) -- e.g. running `ahood skill init` with no name directly in
|
|
62
122
|
// "/" (basename "") or in a directory whose name starts with punctuation.
|
|
63
|
-
function skillNameFor(dirPath) {
|
|
123
|
+
function skillNameFor(dirPath, fallback = "my-skill") {
|
|
64
124
|
const base = basename(dirPath);
|
|
65
|
-
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(base) ? base :
|
|
125
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(base) ? base : fallback;
|
|
66
126
|
}
|
|
67
|
-
export async function init(args) {
|
|
127
|
+
export async function init(args, scope) {
|
|
128
|
+
const strict = strictKind(scope);
|
|
129
|
+
const kind = strict ?? "skill";
|
|
68
130
|
let name = args[0];
|
|
69
131
|
if (name !== undefined && name.startsWith("-"))
|
|
70
132
|
throw new UsageError(USAGE);
|
|
@@ -93,13 +155,31 @@ export async function init(args) {
|
|
|
93
155
|
}
|
|
94
156
|
}
|
|
95
157
|
const targetDir = name ? resolve(name) : process.cwd();
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
158
|
+
const rootDoc = KINDS[kind].rootDoc;
|
|
159
|
+
const docPath = join(targetDir, rootDoc);
|
|
160
|
+
if (existsSync(docPath)) {
|
|
161
|
+
throw new Error(`${rootDoc} already exists at ${docPath} -- refusing to overwrite it.`);
|
|
162
|
+
}
|
|
163
|
+
// A kind-scoped init also refuses a folder that already holds ANOTHER
|
|
164
|
+
// kind's root document: one folder is one artifact, and a second root doc
|
|
165
|
+
// makes a plain `ahood skill publish` of that folder ambiguous. The legacy
|
|
166
|
+
// `ahood skill init` keeps its exact pre-#172 behavior.
|
|
167
|
+
if (strict !== undefined) {
|
|
168
|
+
for (const other of CLI_KINDS) {
|
|
169
|
+
if (other === kind)
|
|
170
|
+
continue;
|
|
171
|
+
const otherPath = join(targetDir, KINDS[other].rootDoc);
|
|
172
|
+
if (existsSync(otherPath)) {
|
|
173
|
+
throw new Error(`${otherPath} already exists -- that folder is already ${KINDS[other].article} ${KINDS[other].label}. ` +
|
|
174
|
+
`Refusing to add ${rootDoc} next to it; pick another name.`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
99
177
|
}
|
|
100
178
|
mkdirSync(targetDir, { recursive: true });
|
|
101
|
-
writeFileSync(
|
|
102
|
-
console.log(`Created ${
|
|
103
|
-
|
|
104
|
-
|
|
179
|
+
writeFileSync(docPath, TEMPLATES[kind](name ?? skillNameFor(targetDir, `my-${kind}`)));
|
|
180
|
+
console.log(`Created ${docPath}`);
|
|
181
|
+
for (const line of NEXT_STEPS[kind])
|
|
182
|
+
console.log(line);
|
|
183
|
+
const placeholder = kind === "skill" ? "<skill>" : kind === "agent" ? "<agent>" : "<server>";
|
|
184
|
+
console.log(`Run \`ahood ${strict ?? "skill"} publish <owner>/${placeholder}@<version>${name ? ` --path ${name}` : ""}\` when ready.`);
|
|
105
185
|
}
|
package/dist/commands/list.js
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import { apiJson } from "../http.js";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { KINDS, strictKind } from "../kinds.js";
|
|
3
|
+
// A strict kind is filtered by the registry: its `mine=true` branch honours
|
|
4
|
+
// ?kind= (ahood#657) and, with no page/per_page, still returns every owned
|
|
5
|
+
// row unpaginated -- the same shape as before.
|
|
6
|
+
export async function listOwnSkills(kind) {
|
|
7
|
+
const query = kind ? `mine=true&kind=${encodeURIComponent(kind)}` : "mine=true";
|
|
8
|
+
const { skills } = await apiJson(`/api/v1/skills?${query}`);
|
|
4
9
|
return skills ?? [];
|
|
5
10
|
}
|
|
6
|
-
export async function listSkills(args = []) {
|
|
11
|
+
export async function listSkills(args = [], scope) {
|
|
7
12
|
const jsonOutput = args.includes("--json");
|
|
8
|
-
const
|
|
13
|
+
const kind = strictKind(scope);
|
|
14
|
+
const skills = await listOwnSkills(kind);
|
|
9
15
|
if (jsonOutput) {
|
|
10
16
|
console.log(JSON.stringify(skills));
|
|
11
17
|
return;
|
|
12
18
|
}
|
|
13
19
|
if (skills.length === 0) {
|
|
14
|
-
console.log("You haven't published any skills yet.");
|
|
20
|
+
console.log(kind ? `You haven't published any ${KINDS[kind].plural} yet.` : "You haven't published any skills yet.");
|
|
15
21
|
return;
|
|
16
22
|
}
|
|
17
23
|
for (const skill of skills) {
|
package/dist/commands/login.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import { apiJson } from "../http.js";
|
|
1
|
+
import { apiJson, parseRetryAfterMs } from "../http.js";
|
|
2
2
|
import { getApiUrl } from "../config.js";
|
|
3
3
|
import { writeCredentials } from "../credentials.js";
|
|
4
4
|
const DEFAULT_EXPIRES_IN_SECONDS = 600; // 10 minutes, matching the previous timeout loop's real-world duration
|
|
5
5
|
const POLL_TIMEOUT_MS = 10_000;
|
|
6
|
+
const POLL_INTERVAL_MS = 2_000;
|
|
7
|
+
// ahood-cli#159: cap on a single 429 backoff hop, same policy as apiJson's
|
|
8
|
+
// rate-limit retry -- however large the server's Retry-After, one wait stays
|
|
9
|
+
// bounded, and the deadline check below keeps the loop inside expires_in.
|
|
10
|
+
const RATE_LIMIT_MAX_BACKOFF_MS = 30_000;
|
|
6
11
|
function sleep(ms) {
|
|
7
12
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
13
|
}
|
|
@@ -10,6 +15,11 @@ export async function login() {
|
|
|
10
15
|
const { code, verification_url, expires_in } = await apiJson("/api/v1/auth/cli/device", {
|
|
11
16
|
method: "POST",
|
|
12
17
|
});
|
|
18
|
+
// Printed VERBATIM, deliberately not through sanitizeForTerminal the way
|
|
19
|
+
// `add` treats server-supplied text: this line exists so the user can
|
|
20
|
+
// compare it against the code on the /cli-auth page, and a tidied-up
|
|
21
|
+
// rendering would make that comparison meaningless. Pinned by
|
|
22
|
+
// tests/login-device-code-verbatim.test.ts (ahood#357).
|
|
13
23
|
console.log(`First, confirm this code matches what you see in your browser: ${code}`);
|
|
14
24
|
console.log(`Open ${verification_url} to approve.`);
|
|
15
25
|
// expires_in is server-supplied; a missing/non-finite value previously
|
|
@@ -18,8 +28,29 @@ export async function login() {
|
|
|
18
28
|
// failed instantly with "timed out" instead of ever actually polling.
|
|
19
29
|
const seconds = Number.isFinite(expires_in) && expires_in > 0 ? expires_in : DEFAULT_EXPIRES_IN_SECONDS;
|
|
20
30
|
const deadline = Date.now() + seconds * 1000;
|
|
31
|
+
// ahood-cli#159: when the registry rate-limits the poll, `backoffMs` carries
|
|
32
|
+
// the wait for the NEXT poll -- 429 must not `continue` on the normal
|
|
33
|
+
// cadence, which is the retry-storm shape: the client answers "you are
|
|
34
|
+
// asking too often" by asking again on the same schedule, against an
|
|
35
|
+
// endpoint that can least afford it.
|
|
36
|
+
let backoffMs;
|
|
37
|
+
let consecutive429s = 0;
|
|
38
|
+
// Whether the loop ever saw a 429 -- the timeout message below can then say
|
|
39
|
+
// why the window ran out instead of blaming the user's pace.
|
|
40
|
+
let wasRateLimited = false;
|
|
21
41
|
while (Date.now() < deadline) {
|
|
22
|
-
|
|
42
|
+
let waitMs = POLL_INTERVAL_MS;
|
|
43
|
+
if (backoffMs !== undefined) {
|
|
44
|
+
waitMs = backoffMs;
|
|
45
|
+
backoffMs = undefined;
|
|
46
|
+
// The 429 backoff counts against the login deadline like every other
|
|
47
|
+
// wait: a server that keeps answering "wait 60s" must not stretch the
|
|
48
|
+
// loop past expires_in -- and past expiry the device code is dead, so
|
|
49
|
+
// polling after the wait would be pointless anyway.
|
|
50
|
+
if (Date.now() + waitMs >= deadline)
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
await sleep(waitMs);
|
|
23
54
|
let res;
|
|
24
55
|
try {
|
|
25
56
|
// Poll against the CONFIGURED API host (getApiUrl()), not a URL derived
|
|
@@ -27,6 +58,17 @@ export async function login() {
|
|
|
27
58
|
// shown to the human, never used to build a request, so a compromised
|
|
28
59
|
// or redirected registry response can't point this at a host that
|
|
29
60
|
// then harvests the device code and hands back an attacker's token.
|
|
61
|
+
//
|
|
62
|
+
// `code` is sent VERBATIM and must stay that way: do not trim it,
|
|
63
|
+
// change its case, strip its separator or otherwise tidy it. The format
|
|
64
|
+
// is defined once, in ahood's lib/auth/device-code.ts, and the server
|
|
65
|
+
// canonicalizes whatever arrives -- so this CLI stays format-agnostic
|
|
66
|
+
// and a server-side format fix needs no release of this package. In
|
|
67
|
+
// ahood#349 that property was all that stood between a bad ten-minute
|
|
68
|
+
// "Login timed out" and a bad error: the hyphen happens to be
|
|
69
|
+
// unreserved, so encodeURIComponent left it alone. Pinned by
|
|
70
|
+
// tests/login-device-code-verbatim.test.ts; contract in ahood's
|
|
71
|
+
// docs/adr/backend/0005-device-code-cross-repo-contract.md (ahood#357).
|
|
30
72
|
res = await apiFetchWithTimeout(`/api/v1/auth/cli/device/${encodeURIComponent(code)}`);
|
|
31
73
|
}
|
|
32
74
|
catch (error) {
|
|
@@ -46,6 +88,22 @@ export async function login() {
|
|
|
46
88
|
if (res.status === 410 || res.status === 404) {
|
|
47
89
|
throw new Error("This login was cancelled or expired. Run `ahood login` again.");
|
|
48
90
|
}
|
|
91
|
+
if (res.status === 429) {
|
|
92
|
+
// ahood-cli#159: the registry has said stop. Back off by Retry-After
|
|
93
|
+
// (exponentially, 1s doubling, when the header is absent/unparseable)
|
|
94
|
+
// instead of polling on -- and let the deadline check at the top of the
|
|
95
|
+
// loop bound the total time, so rate limiting cannot extend the window.
|
|
96
|
+
wasRateLimited = true;
|
|
97
|
+
consecutive429s++;
|
|
98
|
+
// Drain the rejected body so the socket is released while we wait.
|
|
99
|
+
await res.text().catch(() => "");
|
|
100
|
+
const retryAfterMs = parseRetryAfterMs(res.headers.get("Retry-After"));
|
|
101
|
+
const wait = Math.min(retryAfterMs ?? 1_000 * 2 ** (consecutive429s - 1), RATE_LIMIT_MAX_BACKOFF_MS);
|
|
102
|
+
backoffMs = wait;
|
|
103
|
+
console.error(`Rate limited while polling; waiting ${Math.ceil(wait / 1000)}s before trying again...`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
consecutive429s = 0;
|
|
49
107
|
let body;
|
|
50
108
|
try {
|
|
51
109
|
body = (await res.json());
|
|
@@ -65,7 +123,9 @@ export async function login() {
|
|
|
65
123
|
}
|
|
66
124
|
// status === "pending" -- keep polling.
|
|
67
125
|
}
|
|
68
|
-
throw new Error(
|
|
126
|
+
throw new Error(wasRateLimited
|
|
127
|
+
? "Login timed out -- the registry kept rate limiting the poll. Wait a bit and run `ahood login` again."
|
|
128
|
+
: "Login timed out. Run `ahood login` again.");
|
|
69
129
|
}
|
|
70
130
|
// Not apiJson: a non-2xx poll response ("pending", still-provisioning, etc.)
|
|
71
131
|
// is expected and must not throw -- the status code is inspected by the
|
package/dist/commands/mcp.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { startMcpServer } from "../mcp/server.js";
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// Starts the local stdio MCP server -- reached as bare `ahood mcp` (the
|
|
3
|
+
// original v1 contract, kept byte-for-byte for existing host configs) or
|
|
4
|
+
// `ahood mcp serve`; index.ts's dispatchMcp() decides which first words mean
|
|
5
|
+
// "serve" and which are registry verbs (ahood-cli#172). Unlike every other
|
|
6
|
+
// command it never returns until the client disconnects -- see
|
|
7
|
+
// startMcpServer's comment.
|
|
5
8
|
export async function mcp(_args) {
|
|
6
9
|
await startMcpServer();
|
|
7
10
|
}
|
package/dist/commands/publish.js
CHANGED
|
@@ -6,6 +6,26 @@ import { apiJson, ApiError, sanitizeErrorMessage } from "../http.js";
|
|
|
6
6
|
import { flagValue } from "../flags.js";
|
|
7
7
|
import { parseOwnerSkill, SEMVER_RE, validateExternalUrl } from "../spec.js";
|
|
8
8
|
import { UsageError } from "../usage-error.js";
|
|
9
|
+
import { assertKind, strictKind } from "../kinds.js";
|
|
10
|
+
// `ahood agent publish` / `ahood mcp publish` (ahood-cli#172): an existing
|
|
11
|
+
// entry of another kind must be refused before anything is packed, created,
|
|
12
|
+
// or uploaded. The upload would fail server-side anyway -- the processing
|
|
13
|
+
// workflow validates the archive against the entry's stored kind -- but only
|
|
14
|
+
// after a version row was created and a full upload spent. A 404 is the
|
|
15
|
+
// "doesn't exist yet" case publish already handles by creating the entry,
|
|
16
|
+
// now with the scope's kind.
|
|
17
|
+
async function assertPublishTargetKind(scope, owner, skill) {
|
|
18
|
+
let detail;
|
|
19
|
+
try {
|
|
20
|
+
detail = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error instanceof ApiError && error.status === 404)
|
|
24
|
+
return;
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
assertKind(scope, `${owner}/${skill}`, detail.kind, "publish", "publish a new version of");
|
|
28
|
+
}
|
|
9
29
|
// Processing (decompress/validate/checksum/scan/upload) now happens in a
|
|
10
30
|
// background workflow, not inline in versions/complete's response (server
|
|
11
31
|
// issue #120) -- complete returns 202 immediately, so this polls
|
|
@@ -342,10 +362,18 @@ async function initVersion(owner, skill, version, packageSizeBytes, changelog, c
|
|
|
342
362
|
// validation, network, or a rejected publish -- still yields exactly one
|
|
343
363
|
// JSON object (`{error}`) rather than a human message, while still
|
|
344
364
|
// rethrowing so index.ts's exit-code mapping (exitCodeFor) is unaffected.
|
|
345
|
-
export async function publish(args) {
|
|
365
|
+
export async function publish(args, scope) {
|
|
346
366
|
const jsonOutput = args.includes("--json");
|
|
347
367
|
try {
|
|
348
|
-
const
|
|
368
|
+
const parsed = parsePublishArgs(args);
|
|
369
|
+
const { owner, skill, version, path, name, tagline, tags, license, homepage, repository, changelog } = parsed;
|
|
370
|
+
// A kind-scoped publish implies its kind. The dispatcher has already
|
|
371
|
+
// refused a contradictory --kind before this runs, so the flag (when the
|
|
372
|
+
// legacy `ahood skill publish` passes one through) and the scope can't
|
|
373
|
+
// disagree here.
|
|
374
|
+
const scopedKind = strictKind(scope);
|
|
375
|
+
const kind = scopedKind ?? parsed.kind;
|
|
376
|
+
const via = scopedKind ? `ahood ${scope.noun} publish` : `publish --kind ${kind}`;
|
|
349
377
|
const skillMdPath = join(path, "SKILL.md");
|
|
350
378
|
const agentMdPath = join(path, "AGENT.md");
|
|
351
379
|
const serverJsonPath = join(path, "server.json");
|
|
@@ -355,15 +383,15 @@ export async function publish(args) {
|
|
|
355
383
|
let resolvedKind = kind;
|
|
356
384
|
if (kind === "agent") {
|
|
357
385
|
if (!hasAgentMd)
|
|
358
|
-
throw new Error(`No AGENT.md found at ${agentMdPath} --
|
|
386
|
+
throw new Error(`No AGENT.md found at ${agentMdPath} -- ${via} must point at a folder containing AGENT.md.`);
|
|
359
387
|
}
|
|
360
388
|
else if (kind === "skill") {
|
|
361
389
|
if (!hasSkillMd)
|
|
362
|
-
throw new Error(`No SKILL.md found at ${skillMdPath} --
|
|
390
|
+
throw new Error(`No SKILL.md found at ${skillMdPath} -- ${via} must point at a folder containing SKILL.md.`);
|
|
363
391
|
}
|
|
364
392
|
else if (kind === "mcp") {
|
|
365
393
|
if (!hasServerJson)
|
|
366
|
-
throw new Error(`No server.json found at ${serverJsonPath} --
|
|
394
|
+
throw new Error(`No server.json found at ${serverJsonPath} -- ${via} must point at a folder containing server.json.`);
|
|
367
395
|
}
|
|
368
396
|
else {
|
|
369
397
|
// kind === undefined -- infer from whichever root file(s) are actually present
|
|
@@ -390,6 +418,8 @@ export async function publish(args) {
|
|
|
390
418
|
if (resolvedKind === "skill" || (resolvedKind === undefined && hasSkillMd)) {
|
|
391
419
|
warnAboutSkillDescription(skillMdPath);
|
|
392
420
|
}
|
|
421
|
+
if (scopedKind !== undefined)
|
|
422
|
+
await assertPublishTargetKind(scope, owner, skill);
|
|
393
423
|
const archive = await tarGzDirectory(path);
|
|
394
424
|
const { init, created } = await initVersion(owner, skill, version, archive.length, changelog, { name, tagline, tags, license, homepage, repository, kind: resolvedKind }, jsonOutput);
|
|
395
425
|
// TS's lib.dom BodyInit (in scope here since tsconfig has no explicit
|
package/dist/commands/read.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { apiJson } from "../http.js";
|
|
2
2
|
import { parseOwnerSkill } from "../spec.js";
|
|
3
|
+
import { sanitizeDocumentForTerminal, sanitizeForTerminal } from "../terminal-safe.js";
|
|
3
4
|
import { UsageError } from "../usage-error.js";
|
|
5
|
+
import { ensureRemoteKind } from "../kinds.js";
|
|
4
6
|
const USAGE = "Usage: ahood skill read <owner>/<skill> [--json]";
|
|
5
7
|
export async function readSkillMd(owner, skill) {
|
|
6
8
|
const detail = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`);
|
|
@@ -12,8 +14,16 @@ export async function readSkillMd(owner, skill) {
|
|
|
12
14
|
// be silent about handing back a yanked version's content. Written to
|
|
13
15
|
// stderr, not stdout -- safe for MCP callers too (only stdout is reserved
|
|
14
16
|
// for the JSON-RPC stream).
|
|
17
|
+
//
|
|
18
|
+
// yanked_reason is publisher-controlled free text interpolated into a
|
|
19
|
+
// CLI-authored line, which is exactly sanitizeForTerminal's case (and
|
|
20
|
+
// exactly what add.ts does with manifest fields) -- not the document case
|
|
21
|
+
// below, so it keeps the flatten-newlines/200-char defaults rather than
|
|
22
|
+
// sanitizeDocumentForTerminal. Unconditional, not TTY-gated: nothing
|
|
23
|
+
// round-trips a warning, so there is no byte-exactness to preserve here
|
|
24
|
+
// (ahood-cli#133).
|
|
15
25
|
if (detail.skill_versions.yanked_at) {
|
|
16
|
-
console.warn(`WARNING: ${detail.owner}/${detail.slug}@${detail.skill_versions.version} has been yanked${detail.skill_versions.yanked_reason ? `: ${detail.skill_versions.yanked_reason}` : "."}`);
|
|
26
|
+
console.warn(`WARNING: ${detail.owner}/${detail.slug}@${detail.skill_versions.version} has been yanked${detail.skill_versions.yanked_reason ? `: ${sanitizeForTerminal(detail.skill_versions.yanked_reason)}` : "."}`);
|
|
17
27
|
}
|
|
18
28
|
const content = detail.skill_versions.skill_md_content;
|
|
19
29
|
if (!content) {
|
|
@@ -32,12 +42,13 @@ export async function readSkillMd(owner, skill) {
|
|
|
32
42
|
yanked_reason: detail.skill_versions.yanked_reason ?? null,
|
|
33
43
|
};
|
|
34
44
|
}
|
|
35
|
-
export async function read(args) {
|
|
45
|
+
export async function read(args, scope) {
|
|
36
46
|
const jsonOutput = args.includes("--json");
|
|
37
47
|
const spec = args.find((a) => !a.startsWith("--"));
|
|
38
48
|
if (!spec)
|
|
39
49
|
throw new UsageError(USAGE);
|
|
40
50
|
const { owner, skill } = parseOwnerSkill(spec, USAGE);
|
|
51
|
+
await ensureRemoteKind(scope, owner, skill, "read", "read");
|
|
41
52
|
const { version, content } = await readSkillMd(owner, skill);
|
|
42
53
|
if (jsonOutput) {
|
|
43
54
|
console.log(JSON.stringify({ version, content }));
|
|
@@ -52,5 +63,22 @@ export async function read(args) {
|
|
|
52
63
|
// a real, reported round-trip mismatch between "ahood skill read" and the
|
|
53
64
|
// source file, even though the registry itself stores/serves the content
|
|
54
65
|
// byte-exact (ahood-cli#90).
|
|
66
|
+
//
|
|
67
|
+
// That byte-exactness is a promise made to a *pipe*, though, and #90 is the
|
|
68
|
+
// only reason the raw write exists. A human looking at a terminal gets no
|
|
69
|
+
// value from it and pays for it with every control sequence the publisher
|
|
70
|
+
// chose to embed, because skill_md_content is publisher-controlled free text
|
|
71
|
+
// and `skill read` is the command the CLI advertises for inspecting a skill
|
|
72
|
+
// *before* installing it -- the cautious path. An embedded OSC 52
|
|
73
|
+
// ("\x1b]52;c;<base64>\x07") silently overwrites the reader's system
|
|
74
|
+
// clipboard on xterm/kitty/wezterm/foot; cheaper variants retitle the window
|
|
75
|
+
// (OSC 0), clear the screen and forge "verified safe" output (ESC[2J ESC[H),
|
|
76
|
+
// or switch charsets (ESC(0) and corrupt every later line in the session. So
|
|
77
|
+
// strip on the TTY path only, leaving the redirected path the exact bytes
|
|
78
|
+
// #90 guaranteed (ahood-cli#133).
|
|
79
|
+
if (process.stdout.isTTY) {
|
|
80
|
+
process.stdout.write(sanitizeDocumentForTerminal(content));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
55
83
|
process.stdout.write(content);
|
|
56
84
|
}
|
package/dist/commands/remove.js
CHANGED
|
@@ -1,11 +1,37 @@
|
|
|
1
|
-
import { existsSync, rmSync
|
|
1
|
+
import { existsSync, rmSync } from "node:fs";
|
|
2
2
|
import { confirm } from "../confirm.js";
|
|
3
3
|
import { readLockfile, removeLockfileEntry, withLock, writeJsonFileAtomic } from "../lockfile.js";
|
|
4
4
|
import { LOCKFILE_PATH, parseOwnerSkill, skillDir, agentPath, MCP_CONFIG_PATH } from "../spec.js";
|
|
5
5
|
import { readMcpConfig, matchesMcpConfigHash } from "./add.js";
|
|
6
6
|
import { UsageError } from "../usage-error.js";
|
|
7
|
+
import { assertKind, ensureRemoteKind, KindMismatchError, describeKind, localInstalledKind, strictKind, } from "../kinds.js";
|
|
8
|
+
// `ahood agent remove` / `ahood mcp remove` (ahood-cli#172): decide whether
|
|
9
|
+
// this install really is the scope's kind BEFORE the prompt and every delete
|
|
10
|
+
// below. Local evidence first -- remove has always worked offline, and the
|
|
11
|
+
// footprint answers it for every install made by a current CLI. Only a pin
|
|
12
|
+
// whose footprint is ambiguous (a pre-fingerprint mcp pin, or a skill whose
|
|
13
|
+
// directory was deleted by hand) asks the registry, and if that can't answer
|
|
14
|
+
// either, nothing is removed.
|
|
15
|
+
async function assertInstalledKind(scope, owner, skill, lockEntry) {
|
|
16
|
+
const key = `${owner}/${skill}`;
|
|
17
|
+
const local = localInstalledKind(owner, skill, lockEntry);
|
|
18
|
+
if (local !== undefined) {
|
|
19
|
+
assertKind(scope, key, local, "remove", "remove");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
await ensureRemoteKind(scope, owner, skill, "remove", "remove");
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error instanceof KindMismatchError)
|
|
27
|
+
throw error;
|
|
28
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
29
|
+
throw new Error(`Could not confirm that ${key} is installed as ${describeKind(strictKind(scope))}: its local files don't say, and the registry lookup failed (${reason}). ` +
|
|
30
|
+
`Nothing was removed. Use \`ahood skill remove ${key}\` (legacy, any kind) to remove it anyway.`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
7
33
|
const USAGE = "Usage: ahood skill remove <owner>/<skill> [--yes]";
|
|
8
|
-
export async function remove(args) {
|
|
34
|
+
export async function remove(args, scope) {
|
|
9
35
|
const spec = args[0];
|
|
10
36
|
if (!spec)
|
|
11
37
|
throw new UsageError(USAGE);
|
|
@@ -22,11 +48,21 @@ export async function remove(args) {
|
|
|
22
48
|
const agentExisted = existsSync(agentFile);
|
|
23
49
|
const lockEntry = readLockfile(LOCKFILE_PATH)[key];
|
|
24
50
|
const hadLockfileEntry = lockEntry !== undefined;
|
|
51
|
+
// A pin whose install left nothing on disk is the defining shape of an
|
|
52
|
+
// mcp-kind install: its only footprint is this pin plus a live .mcp.json
|
|
53
|
+
// entry. Kept as evidence for the unreadable-.mcp.json warning at the
|
|
54
|
+
// bottom because, unlike mcp_config_hash, it exists for entries installed
|
|
55
|
+
// before that field did (lockfile.ts: "absent for any mcp entry installed
|
|
56
|
+
// before this field existed") and it is computed HERE, above the .mcp.json
|
|
57
|
+
// read -- so it survives the case where that read throws (ahood-cli#143).
|
|
58
|
+
const hadNoOnDiskFootprint = !dirExisted && !agentExisted && hadLockfileEntry;
|
|
25
59
|
if (!dirExisted && !agentExisted && !hadLockfileEntry) {
|
|
26
60
|
console.error(`${key} was not installed -- nothing to remove.`);
|
|
27
61
|
process.exitCode = 1;
|
|
28
62
|
return;
|
|
29
63
|
}
|
|
64
|
+
if (scope && strictKind(scope) !== undefined)
|
|
65
|
+
await assertInstalledKind(scope, owner, skill, lockEntry);
|
|
30
66
|
// Matches unpublish.ts/group.ts's confirm-before-destroy pattern (CLAUDE.md:
|
|
31
67
|
// "Destructive commands ... prompt for confirmation unless --yes is passed").
|
|
32
68
|
// Checked only once something is actually installed, so a no-op remove of an
|
|
@@ -36,16 +72,32 @@ export async function remove(args) {
|
|
|
36
72
|
console.log("Aborted.");
|
|
37
73
|
return;
|
|
38
74
|
}
|
|
75
|
+
// Every artifact the pin points at is deleted BEFORE the pin itself, which
|
|
76
|
+
// is cleared exactly once at the end of this function. The two interruption
|
|
77
|
+
// states are not symmetric, which is the whole reason for the ordering: a
|
|
78
|
+
// pin left behind after its artifact is gone is a dangling reference that a
|
|
79
|
+
// re-run of this same command finds (hadLockfileEntry) and clears, while an
|
|
80
|
+
// artifact left behind after its pin is gone is invisible to list/update
|
|
81
|
+
// forever -- and for an mcp-kind install that artifact is a live .mcp.json
|
|
82
|
+
// entry holding a resolved secret (ahood-cli#142). The rule is applied
|
|
83
|
+
// uniformly rather than per-kind: the directory and agent file are lower
|
|
84
|
+
// stakes (no secret, and a re-run's own existsSync rediscovers them), but
|
|
85
|
+
// ordering them by stakes would just make the invariant harder to keep.
|
|
86
|
+
//
|
|
87
|
+
// `force: true` on both, rather than trusting the existsSync taken at the
|
|
88
|
+
// top: an unbounded confirm() prompt sits between that check and here, so
|
|
89
|
+
// the path may legitimately be gone by now. Without it, unlinkSync threw a
|
|
90
|
+
// raw ENOENT that propagated out and skipped the pin clear below, leaving
|
|
91
|
+
// the pin behind for a file that no longer existed (ahood-cli#142).
|
|
39
92
|
if (dirExisted)
|
|
40
93
|
rmSync(dir, { recursive: true, force: true });
|
|
41
94
|
if (agentExisted)
|
|
42
|
-
|
|
43
|
-
removeLockfileEntry(LOCKFILE_PATH, key);
|
|
95
|
+
rmSync(agentFile, { force: true });
|
|
44
96
|
// An mcp-kind install has no directory or agent file on disk -- its only
|
|
45
|
-
// footprint
|
|
46
|
-
// entry in .mcp.json (which add.ts's installMcpEntry merged in,
|
|
47
|
-
// holding a resolved secret in its `env`). Real removal (delete
|
|
48
|
-
// key) only happens when the on-disk entry's fingerprint
|
|
97
|
+
// footprint is the lockfile entry (cleared below, once this block has run)
|
|
98
|
+
// and a live entry in .mcp.json (which add.ts's installMcpEntry merged in,
|
|
99
|
+
// possibly holding a resolved secret in its `env`). Real removal (delete
|
|
100
|
+
// that one key) only happens when the on-disk entry's fingerprint matches
|
|
49
101
|
// what was recorded at install/update time -- same posture as add.ts's
|
|
50
102
|
// own assertNoCollision: never blind-write/delete something this process
|
|
51
103
|
// didn't verify it still owns. Without SOME check here, "Removed" would
|
|
@@ -77,9 +129,9 @@ export async function remove(args) {
|
|
|
77
129
|
// path. But leaving every flag false here meant the unreadable case
|
|
78
130
|
// printed a bare "Removed" with the entry (and its secret) still live,
|
|
79
131
|
// the very false-assurance bug the block above exists to prevent
|
|
80
|
-
// (ahood-cli#115).
|
|
81
|
-
//
|
|
82
|
-
// ahood DID install an entry into this file, so record why the read
|
|
132
|
+
// (ahood-cli#115). Both the lockfile's mcp_config_hash and
|
|
133
|
+
// hadNoOnDiskFootprint, read at the top of this function, are evidence
|
|
134
|
+
// that ahood DID install an entry into this file, so record why the read
|
|
83
135
|
// failed and warn off that instead of off a shape we can't inspect.
|
|
84
136
|
mcpConfigUnreadable = error instanceof Error ? error.message : String(error);
|
|
85
137
|
}
|
|
@@ -114,7 +166,26 @@ export async function remove(args) {
|
|
|
114
166
|
// removedMcpEntry stays false -- falls through to the warning below.
|
|
115
167
|
}
|
|
116
168
|
}
|
|
117
|
-
|
|
169
|
+
// Last, per the ordering note above: everything this pin could point at has
|
|
170
|
+
// now either been deleted or been diagnosed and warned about below, so the
|
|
171
|
+
// pin has no surviving work left to surface (ahood-cli#142).
|
|
172
|
+
removeLockfileEntry(LOCKFILE_PATH, key);
|
|
173
|
+
if (mcpConfigUnreadable !== undefined && (lockEntry?.mcp_config_hash !== undefined || hadNoOnDiskFootprint)) {
|
|
174
|
+
// Two independent pieces of evidence, because mcp_config_hash alone is
|
|
175
|
+
// not sufficient: it is absent for any mcp entry installed before that
|
|
176
|
+
// field existed, and for such a legacy pin the unreadable read also
|
|
177
|
+
// leaves mcpEntryPresent false -- so the `else if` below could not fire
|
|
178
|
+
// either and #115's fix silently did not apply, printing the bare
|
|
179
|
+
// "Removed" with the entry and its credential still live (ahood-cli#143).
|
|
180
|
+
// hadNoOnDiskFootprint closes that hole without needing the missing
|
|
181
|
+
// field. It cannot be narrowed further from the lockfile alone (LockEntry
|
|
182
|
+
// records no `kind`), so a skill whose directory was deleted by hand
|
|
183
|
+
// before this ran looks identical to a legacy mcp pin and warns too --
|
|
184
|
+
// deliberately, since the only thing that could tell them apart is the
|
|
185
|
+
// .mcp.json read that just failed, and a hedged sentence is the cheaper
|
|
186
|
+
// error of the two. A plain skill install still has its directory, so
|
|
187
|
+
// that far more common shape stays silent as ahood-cli#115 requires.
|
|
188
|
+
//
|
|
118
189
|
// Its own wording rather than the branch below's: "remove it manually"
|
|
119
190
|
// isn't actionable while the file doesn't parse, and we can't say the
|
|
120
191
|
// entry is definitely still there -- only that one was installed and
|
package/dist/commands/search.js
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
import { apiJson } from "../http.js";
|
|
2
2
|
import { flagValue, parseSearchQuery } from "../flags.js";
|
|
3
3
|
import { UsageError } from "../usage-error.js";
|
|
4
|
+
import { KINDS, strictKind } from "../kinds.js";
|
|
4
5
|
const USAGE = "Usage: ahood skill search <query> [--json] [--limit <n>]";
|
|
5
|
-
|
|
6
|
+
// `kind` narrows the search server-side (GET /api/v1/skills?kind=, which the
|
|
7
|
+
// backend validates against its ALLOWED_KINDS). Omitted -- the legacy
|
|
8
|
+
// `ahood skill search` and the MCP skill_search tool -- the request is
|
|
9
|
+
// byte-for-byte what it always was: every kind.
|
|
10
|
+
export async function searchSkills(query, limit, kind) {
|
|
6
11
|
const qs = new URLSearchParams({ q: query });
|
|
7
12
|
if (limit !== undefined)
|
|
8
13
|
qs.set("per_page", String(limit));
|
|
14
|
+
if (kind !== undefined)
|
|
15
|
+
qs.set("kind", kind);
|
|
9
16
|
const { skills } = await apiJson(`/api/v1/skills?${qs}`);
|
|
10
17
|
return skills ?? [];
|
|
11
18
|
}
|
|
12
|
-
export async function search(args) {
|
|
19
|
+
export async function search(args, scope) {
|
|
20
|
+
const kind = strictKind(scope);
|
|
13
21
|
const jsonOutput = args.includes("--json");
|
|
14
22
|
const limitStr = flagValue(args, "--limit");
|
|
15
23
|
const query = parseSearchQuery(args, USAGE);
|
|
@@ -17,13 +25,13 @@ export async function search(args) {
|
|
|
17
25
|
throw new UsageError(`--limit must be a positive integer (got "${limitStr}").\n${USAGE}`);
|
|
18
26
|
}
|
|
19
27
|
const limit = limitStr !== undefined ? Number(limitStr) : undefined;
|
|
20
|
-
const skills = await searchSkills(query, limit);
|
|
28
|
+
const skills = await searchSkills(query, limit, kind);
|
|
21
29
|
if (jsonOutput) {
|
|
22
30
|
console.log(JSON.stringify(skills));
|
|
23
31
|
return;
|
|
24
32
|
}
|
|
25
33
|
if (skills.length === 0) {
|
|
26
|
-
console.log("No skills found.");
|
|
34
|
+
console.log(kind ? `No ${KINDS[kind].plural} found.` : "No skills found.");
|
|
27
35
|
return;
|
|
28
36
|
}
|
|
29
37
|
for (const skill of skills) {
|