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