@ahood/cli 0.2.2 → 0.3.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 +2 -1
- package/dist/commands/add.js +35 -9
- package/dist/commands/completion.js +6 -2
- package/dist/commands/edit.js +7 -3
- package/dist/commands/init.js +56 -0
- package/dist/commands/publish.js +94 -46
- package/dist/commands/update.js +83 -10
- package/dist/commands/versions.js +40 -0
- package/dist/commands/whoami.js +22 -4
- package/dist/flags.js +23 -7
- package/dist/help.js +70 -11
- package/dist/http.js +1 -1
- package/dist/index.js +4 -0
- package/dist/spec.js +38 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,12 +42,13 @@ For CI or any non-interactive environment, set `AHOOD_TOKEN` instead of running
|
|
|
42
42
|
| `ahood view <owner>/<skill> [--json] [--web]` (alias: `show`) | Show a single skill's details -- tags, license, homepage, repository, dates, etc. -- without installing it, or open its page in a browser. |
|
|
43
43
|
| `ahood list-mine [--json]` | List your own skills, public and private, with their visibility and download/star counts. |
|
|
44
44
|
| `ahood add <owner>/<skill>[@version]` | Install into `.claude/skills/<owner>/<skill>/`, pinned in `.claude/skills.lock.json`. |
|
|
45
|
-
| `ahood update [<owner>/<skill> ...]` | Move the lockfile pin(s) forward to the latest version. One skill failing to update doesn't stop the rest. |
|
|
45
|
+
| `ahood update [<owner>/<skill> ...] [--dry-run] [--json]` | Move the lockfile pin(s) forward to the latest version. One skill failing to update doesn't stop the rest. `--dry-run` previews current vs. latest version (plus the changelog for anything behind) without installing anything; add `--json` for structured output. |
|
|
46
46
|
| `ahood remove <owner>/<skill>` | Uninstall and unpin (local only -- does not affect the published skill). |
|
|
47
47
|
| `ahood edit <owner>/<skill> [--tagline] [--tags] [--license] [--visibility] [--homepage] [--repository]` | Update a skill you own. Only the flags you pass are changed; requires the `publish` scope. |
|
|
48
48
|
| `ahood unpublish <owner>/<skill> [--yes]` | Deletes the skill from the registry for every consumer, not just your local install -- prompts for a typed "yes" confirmation, or pass `--yes` for scripts/CI. |
|
|
49
49
|
| `ahood star <owner>/<skill>` | Star a skill. |
|
|
50
50
|
| `ahood unstar <owner>/<skill>` | Remove your star from a skill. |
|
|
51
|
+
| `ahood init [name]` | Scaffold a new skill folder with a minimal, valid `SKILL.md` -- `./<name>/SKILL.md` if a name is given, `./SKILL.md` otherwise. Refuses to overwrite an existing `SKILL.md`. |
|
|
51
52
|
| `ahood publish <owner>/<skill>@<version> [--path <dir>] [--name] [--tagline] [--tags] [--license] [--homepage] [--repository]` | Publish a new version of a skill from a folder containing `SKILL.md` (defaults to the current directory). If the skill doesn't exist yet, creates it first -- `--name` is required in that case; `--tagline`/`--tags`/`--license`/`--homepage`/`--repository` are optional and only used at creation. Processing (validation, checksumming, secret scanning) happens server-side after upload; this command polls and reports the final published/failed status. The legacy form `ahood publish <path> --owner <owner> --slug <skill> --version <x.y.z>` is still accepted. |
|
|
52
53
|
| `ahood token create <name>\|list [--json]\|revoke <id>` | Manage personal API tokens. Creating a token requires an existing logged-in session -- see the docs. |
|
|
53
54
|
| `ahood completion <bash\|zsh\|fish>` | Print a shell completion script for the command names. |
|
package/dist/commands/add.js
CHANGED
|
@@ -22,7 +22,7 @@ const MAX_ENTRY_COUNT = 10_000;
|
|
|
22
22
|
// version in this codebase), so the yanked-skill warning below only fires
|
|
23
23
|
// for an explicit @version -- confirmed by reading both route.ts files
|
|
24
24
|
// rather than assumed.
|
|
25
|
-
async function fetchVersionMeta(owner, skill, version) {
|
|
25
|
+
export async function fetchVersionMeta(owner, skill, version) {
|
|
26
26
|
if (version === "latest") {
|
|
27
27
|
const { skill_versions } = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`);
|
|
28
28
|
if (!skill_versions)
|
|
@@ -31,6 +31,39 @@ async function fetchVersionMeta(owner, skill, version) {
|
|
|
31
31
|
}
|
|
32
32
|
return apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions/${encodeURIComponent(version)}`);
|
|
33
33
|
}
|
|
34
|
+
// Reads a fetch Response body while enforcing MAX_DOWNLOAD_BYTES AS bytes
|
|
35
|
+
// arrive, instead of buffering the whole thing via arrayBuffer() and checking
|
|
36
|
+
// afterwards (ahood-cli#37). A content-length header, when present and
|
|
37
|
+
// already over cap, lets us bail before reading a single byte; but the header
|
|
38
|
+
// is attacker/server-controlled and sometimes just absent (chunked transfer,
|
|
39
|
+
// a mutated/redirected presigned URL), so it's a fast-path optimization only
|
|
40
|
+
// -- the streaming check below is what actually bounds memory in every case.
|
|
41
|
+
async function readBoundedBody(res, maxBytes) {
|
|
42
|
+
const contentLength = Number(res.headers.get("content-length") ?? NaN);
|
|
43
|
+
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
|
44
|
+
await res.body?.cancel().catch(() => { });
|
|
45
|
+
throw new Error(`Download is ${contentLength} bytes, over the ${maxBytes / (1024 * 1024)} MB limit -- refusing to install.`);
|
|
46
|
+
}
|
|
47
|
+
if (!res.body)
|
|
48
|
+
return Buffer.alloc(0);
|
|
49
|
+
const chunks = [];
|
|
50
|
+
let total = 0;
|
|
51
|
+
// Throwing out of a `for await` loop over a ReadableStream runs the async
|
|
52
|
+
// iterator's implicit `return()`, which cancels the underlying stream and
|
|
53
|
+
// releases its reader lock -- no separate res.body.cancel() call needed
|
|
54
|
+
// (and calling one here would fail anyway: the stream is locked to this
|
|
55
|
+
// loop's internal reader). This is what actually stops reading mid-download
|
|
56
|
+
// rather than draining the rest of a huge/malicious body first.
|
|
57
|
+
for await (const chunk of res.body) {
|
|
58
|
+
const buf = Buffer.from(chunk);
|
|
59
|
+
total += buf.length;
|
|
60
|
+
if (total > maxBytes) {
|
|
61
|
+
throw new Error(`Downloaded archive is over the ${maxBytes / (1024 * 1024)} MB limit -- refusing to install.`);
|
|
62
|
+
}
|
|
63
|
+
chunks.push(buf);
|
|
64
|
+
}
|
|
65
|
+
return Buffer.concat(chunks);
|
|
66
|
+
}
|
|
34
67
|
export async function extractTarGz(buffer, destDir) {
|
|
35
68
|
let decompressed;
|
|
36
69
|
try {
|
|
@@ -160,14 +193,7 @@ export async function add(args) {
|
|
|
160
193
|
const body = await downloadRes.text().catch(() => "");
|
|
161
194
|
throw new Error(`Download failed with status ${downloadRes.status}${body ? `: ${body}` : ""}`);
|
|
162
195
|
}
|
|
163
|
-
const
|
|
164
|
-
if (Number.isFinite(contentLength) && contentLength > MAX_DOWNLOAD_BYTES) {
|
|
165
|
-
throw new Error(`Download is ${contentLength} bytes, over the ${MAX_DOWNLOAD_BYTES / (1024 * 1024)} MB limit -- refusing to install.`);
|
|
166
|
-
}
|
|
167
|
-
const buffer = Buffer.from(await downloadRes.arrayBuffer());
|
|
168
|
-
if (buffer.length > MAX_DOWNLOAD_BYTES) {
|
|
169
|
-
throw new Error(`Downloaded archive is ${buffer.length} bytes, over the ${MAX_DOWNLOAD_BYTES / (1024 * 1024)} MB limit -- refusing to install.`);
|
|
170
|
-
}
|
|
196
|
+
const buffer = await readBoundedBody(downloadRes, MAX_DOWNLOAD_BYTES);
|
|
171
197
|
const actualChecksum = createHash("sha256").update(buffer).digest("hex");
|
|
172
198
|
if (actualChecksum !== meta.checksum_sha256) {
|
|
173
199
|
throw new Error(`Checksum mismatch for ${key}@${meta.version}: expected ${meta.checksum_sha256}, got ${actualChecksum}. Refusing to install.`);
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { COMMANDS_HELP } from "../help.js";
|
|
1
|
+
import { COMMANDS_HELP, COMMAND_ALIASES } from "../help.js";
|
|
2
2
|
const USAGE = "Usage: ahood completion <bash|zsh|fish>";
|
|
3
|
+
// Includes alias names (e.g. "show" for "view") alongside the primary
|
|
4
|
+
// command names, so shell completion offers every name a user might type,
|
|
5
|
+
// not just the ones with their own COMMANDS_HELP entry.
|
|
3
6
|
function commandNames() {
|
|
4
|
-
|
|
7
|
+
const primary = COMMANDS_HELP.map((c) => c.usage.split(" ")[1]);
|
|
8
|
+
return [...primary, ...Object.keys(COMMAND_ALIASES)];
|
|
5
9
|
}
|
|
6
10
|
function bashCompletion() {
|
|
7
11
|
const names = commandNames().join(" ");
|
package/dist/commands/edit.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { apiJson } from "../http.js";
|
|
2
2
|
import { flagValue } from "../flags.js";
|
|
3
|
-
import { parseOwnerSkill } from "../spec.js";
|
|
3
|
+
import { parseOwnerSkill, validateExternalUrl } from "../spec.js";
|
|
4
4
|
const USAGE = "Usage: ahood edit <owner>/<skill> [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--visibility public|private] [--homepage <url>] [--repository <url>]";
|
|
5
5
|
// Mirrors PATCH /api/v1/skills/{owner}/{skill}'s allow-list exactly
|
|
6
6
|
// (lib/skills/mutations.ts's UpdateSkillInput) -- only a field the caller
|
|
@@ -30,11 +30,15 @@ export async function edit(args) {
|
|
|
30
30
|
body.visibility = visibility;
|
|
31
31
|
}
|
|
32
32
|
const homepage = flagValue(args, "--homepage");
|
|
33
|
-
if (homepage !== undefined)
|
|
33
|
+
if (homepage !== undefined) {
|
|
34
|
+
validateExternalUrl(homepage, "--homepage");
|
|
34
35
|
body.homepage = homepage;
|
|
36
|
+
}
|
|
35
37
|
const repository = flagValue(args, "--repository");
|
|
36
|
-
if (repository !== undefined)
|
|
38
|
+
if (repository !== undefined) {
|
|
39
|
+
validateExternalUrl(repository, "--repository");
|
|
37
40
|
body.repository = repository;
|
|
41
|
+
}
|
|
38
42
|
if (Object.keys(body).length === 0) {
|
|
39
43
|
throw new Error("Nothing to update -- pass at least one of --tagline, --tags, --license, --visibility, --homepage, --repository.");
|
|
40
44
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, join, resolve } from "node:path";
|
|
3
|
+
const USAGE = "Usage: ahood init [name]";
|
|
4
|
+
// publish.ts's only client-side check on SKILL.md is that the file exists
|
|
5
|
+
// (see publish.ts's `existsSync(skillMdPath)`) -- actual frontmatter
|
|
6
|
+
// validation happens server-side, after upload, as part of the processing
|
|
7
|
+
// workflow described in publish.ts's POLL_INTERVAL_MS comment. There's no
|
|
8
|
+
// parser to match locally, so this template instead follows the documented
|
|
9
|
+
// Claude Code skill format: YAML frontmatter delimited by `---` lines, with
|
|
10
|
+
// a `name` and a `description` field -- the same shape every real SKILL.md
|
|
11
|
+
// in this ecosystem uses (see e.g. any installed skill under
|
|
12
|
+
// .claude/skills/<owner>/<skill>/SKILL.md). Keeping to exactly those two
|
|
13
|
+
// fields, rather than inventing extra ones, means nothing here needs
|
|
14
|
+
// updating just because publish.ts's own validation happens to be looser
|
|
15
|
+
// today.
|
|
16
|
+
function buildSkillMd(name) {
|
|
17
|
+
return `---
|
|
18
|
+
# name: a short, unique identifier for this skill (kebab-case is
|
|
19
|
+
# conventional, e.g. "pdf-tools"). Not the same as the <skill> slug you
|
|
20
|
+
# publish under -- ahood publish reads that from the command line -- but
|
|
21
|
+
# keeping them in sync avoids confusion.
|
|
22
|
+
name: ${name}
|
|
23
|
+
# description: one or two sentences describing what this skill does and
|
|
24
|
+
# when Claude should use it. This is the primary text Claude reads to
|
|
25
|
+
# decide whether to invoke the skill, so be specific rather than generic.
|
|
26
|
+
description: TODO -- describe what this skill does and when Claude should use it.
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Instructions
|
|
30
|
+
|
|
31
|
+
TODO: describe, step by step, what Claude should do when this skill is invoked.
|
|
32
|
+
`;
|
|
33
|
+
}
|
|
34
|
+
// Falls back to "my-skill" when the directory name itself isn't usable as a
|
|
35
|
+
// bare YAML scalar (empty, or starting with a character like "-" or "@" that
|
|
36
|
+
// would need quoting) -- e.g. running `ahood init` with no name directly in
|
|
37
|
+
// "/" (basename "") or in a directory whose name starts with punctuation.
|
|
38
|
+
function skillNameFor(dirPath) {
|
|
39
|
+
const base = basename(dirPath);
|
|
40
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(base) ? base : "my-skill";
|
|
41
|
+
}
|
|
42
|
+
export async function init(args) {
|
|
43
|
+
const name = args[0];
|
|
44
|
+
if (name !== undefined && name.startsWith("-"))
|
|
45
|
+
throw new Error(USAGE);
|
|
46
|
+
const targetDir = name ? resolve(name) : process.cwd();
|
|
47
|
+
const skillMdPath = join(targetDir, "SKILL.md");
|
|
48
|
+
if (existsSync(skillMdPath)) {
|
|
49
|
+
throw new Error(`SKILL.md already exists at ${skillMdPath} -- refusing to overwrite it.`);
|
|
50
|
+
}
|
|
51
|
+
mkdirSync(targetDir, { recursive: true });
|
|
52
|
+
writeFileSync(skillMdPath, buildSkillMd(name ?? skillNameFor(targetDir)));
|
|
53
|
+
console.log(`Created ${skillMdPath}`);
|
|
54
|
+
console.log("Fill in the description, then flesh out the ## Instructions section.");
|
|
55
|
+
console.log(`Run \`ahood publish <owner>/<skill>@<version>${name ? ` --path ${name}` : ""}\` when ready.`);
|
|
56
|
+
}
|
package/dist/commands/publish.js
CHANGED
|
@@ -2,9 +2,9 @@ import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { pack } from "tar-stream";
|
|
4
4
|
import { createGzip } from "node:zlib";
|
|
5
|
-
import { apiJson, ApiError } from "../http.js";
|
|
5
|
+
import { apiJson, ApiError, sanitizeErrorMessage } from "../http.js";
|
|
6
6
|
import { flagValue } from "../flags.js";
|
|
7
|
-
import { parseOwnerSkill, SEMVER_RE } from "../spec.js";
|
|
7
|
+
import { parseOwnerSkill, SEMVER_RE, validateExternalUrl } from "../spec.js";
|
|
8
8
|
// Processing (decompress/validate/checksum/scan/upload) now happens in a
|
|
9
9
|
// background workflow, not inline in versions/complete's response (server
|
|
10
10
|
// issue #120) -- complete returns 202 immediately, so this polls
|
|
@@ -26,8 +26,8 @@ async function pollVersionStatus(owner, skill, version) {
|
|
|
26
26
|
}
|
|
27
27
|
throw new Error(`Still processing after ${POLL_TIMEOUT_MS / 1000}s -- check \`ahood view ${owner}/${skill}\` later for the result.`);
|
|
28
28
|
}
|
|
29
|
-
const USAGE = "Usage: ahood publish <owner>/<skill>@<version> [--path <dir>] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>]\n" +
|
|
30
|
-
" or: ahood publish <path> --owner <owner> --slug <skill> --version <x.y.z>";
|
|
29
|
+
const USAGE = "Usage: ahood publish <owner>/<skill>@<version> [--path <dir>] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--json]\n" +
|
|
30
|
+
" or: ahood publish <path> --owner <owner> --slug <skill> --version <x.y.z> [--json]";
|
|
31
31
|
// Matched by ENTRY NAME at every depth, not by path prefix, so a nested
|
|
32
32
|
// `vendor/thing/.git` is skipped the same as a top-level one. Not a
|
|
33
33
|
// .gitignore parser (out of scope) -- just the fixed set of names that must
|
|
@@ -129,6 +129,13 @@ function parsePublishArgs(args) {
|
|
|
129
129
|
if (!SEMVER_RE.test(version)) {
|
|
130
130
|
throw new Error(`--version must be a semver like 1.2.3 (got "${version}").\n${USAGE}`);
|
|
131
131
|
}
|
|
132
|
+
// Validated here, before any network call (including the tar/gzip of the
|
|
133
|
+
// skill directory) -- these only ever reach the server via
|
|
134
|
+
// createSkillForPublish's create-skill body (ahood-cli#34).
|
|
135
|
+
if (homepage !== undefined)
|
|
136
|
+
validateExternalUrl(homepage, "--homepage");
|
|
137
|
+
if (repository !== undefined)
|
|
138
|
+
validateExternalUrl(repository, "--repository");
|
|
132
139
|
return { owner, skill, version, path: pathFlag ?? legacyPath ?? ".", name, tagline, tags, license, homepage, repository };
|
|
133
140
|
}
|
|
134
141
|
// Creates the skill under the caller's own account when versions/init 404s
|
|
@@ -137,7 +144,7 @@ function parsePublishArgs(args) {
|
|
|
137
144
|
// createSkill requires a name; --tagline/--tags/--license/--homepage/
|
|
138
145
|
// --repository are optional and only used here, never applied to an
|
|
139
146
|
// already-existing skill.
|
|
140
|
-
async function createSkillForPublish(owner, skill, opts) {
|
|
147
|
+
async function createSkillForPublish(owner, skill, opts, jsonOutput) {
|
|
141
148
|
if (!opts.name) {
|
|
142
149
|
throw new Error(`${owner}/${skill} doesn't exist yet -- pass --name to create it as part of this publish (e.g. --name "My Skill").`);
|
|
143
150
|
}
|
|
@@ -166,60 +173,101 @@ async function createSkillForPublish(owner, skill, opts) {
|
|
|
166
173
|
`are always created under your own account (${created.owner}). Retry with: ` +
|
|
167
174
|
`ahood publish ${created.owner}/${skill}@<version>.`);
|
|
168
175
|
}
|
|
169
|
-
|
|
176
|
+
if (!jsonOutput) {
|
|
177
|
+
console.log(`Created ${created.owner}/${created.slug} -- publishing its first version now.`);
|
|
178
|
+
}
|
|
179
|
+
return created;
|
|
170
180
|
}
|
|
171
|
-
async function initVersion(owner, skill, version, packageSizeBytes, createOpts) {
|
|
181
|
+
async function initVersion(owner, skill, version, packageSizeBytes, createOpts, jsonOutput) {
|
|
172
182
|
const path = `/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions/init`;
|
|
173
183
|
const body = JSON.stringify({ version, package_size_bytes: packageSizeBytes });
|
|
174
184
|
try {
|
|
175
|
-
|
|
185
|
+
const init = await apiJson(path, { method: "POST", headers: { "Content-Type": "application/json" }, body });
|
|
186
|
+
return { init };
|
|
176
187
|
}
|
|
177
188
|
catch (error) {
|
|
178
189
|
if (!(error instanceof ApiError) || error.status !== 404)
|
|
179
190
|
throw error;
|
|
180
|
-
await createSkillForPublish(owner, skill, createOpts);
|
|
191
|
+
const created = await createSkillForPublish(owner, skill, createOpts, jsonOutput);
|
|
181
192
|
// Retry exactly once. If this ALSO 404s (e.g. a race, or the create
|
|
182
193
|
// silently didn't take), let that error propagate rather than looping.
|
|
183
|
-
|
|
194
|
+
const init = await apiJson(path, { method: "POST", headers: { "Content-Type": "application/json" }, body });
|
|
195
|
+
return { init, created };
|
|
184
196
|
}
|
|
185
197
|
}
|
|
198
|
+
// --json mode (ahood-cli#57): a CI/script caller needs one machine-readable
|
|
199
|
+
// object on stdout, not the human progress lines below scraped with a
|
|
200
|
+
// regex. jsonOutput therefore gates every intermediate console.log in this
|
|
201
|
+
// flow, and the whole body is wrapped in a try/catch so ANY failure --
|
|
202
|
+
// validation, network, or a rejected publish -- still yields exactly one
|
|
203
|
+
// JSON object (`{error}`) rather than a human message, while still
|
|
204
|
+
// rethrowing so index.ts's exit-code mapping (exitCodeFor) is unaffected.
|
|
186
205
|
export async function publish(args) {
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
206
|
+
const jsonOutput = args.includes("--json");
|
|
207
|
+
try {
|
|
208
|
+
const { owner, skill, version, path, name, tagline, tags, license, homepage, repository } = parsePublishArgs(args);
|
|
209
|
+
const skillMdPath = join(path, "SKILL.md");
|
|
210
|
+
if (!existsSync(skillMdPath)) {
|
|
211
|
+
throw new Error(`No SKILL.md found at ${skillMdPath} -- publish must point at a skill folder's root.`);
|
|
212
|
+
}
|
|
213
|
+
const archive = await tarGzDirectory(path);
|
|
214
|
+
const { init, created } = await initVersion(owner, skill, version, archive.length, { name, tagline, tags, license, homepage, repository }, jsonOutput);
|
|
215
|
+
// TS's lib.dom BodyInit (in scope here since tsconfig has no explicit
|
|
216
|
+
// "lib" override, so DOM is included alongside the Node types) type-checks
|
|
217
|
+
// Buffer/Uint8Array against `Uint8Array<ArrayBuffer>` specifically as of
|
|
218
|
+
// TS 5.7+'s ArrayBufferLike generics, which a Node Buffer's
|
|
219
|
+
// `Uint8Array<ArrayBufferLike>` doesn't structurally satisfy even though
|
|
220
|
+
// it is a valid BufferSource at runtime (this is exactly what Node's own
|
|
221
|
+
// fetch/undici accepts) -- see undici-types' BodyInit, which includes
|
|
222
|
+
// `NodeJS.ArrayBufferView` (i.e. Buffer) directly with no such
|
|
223
|
+
// restriction. Asserting through BodyInit here, rather than reshaping
|
|
224
|
+
// tsconfig's "lib" for the whole package, keeps this fix local to the one
|
|
225
|
+
// call site.
|
|
226
|
+
const putRes = await fetch(init.upload_url, {
|
|
227
|
+
method: "PUT",
|
|
228
|
+
body: archive,
|
|
229
|
+
signal: AbortSignal.timeout(120_000),
|
|
230
|
+
});
|
|
231
|
+
if (!putRes.ok) {
|
|
232
|
+
// The signed URL points at Supabase Storage directly, a different host
|
|
233
|
+
// than the API, so this never goes through apiJson -- sanitize by hand
|
|
234
|
+
// here for the same reason apiJson does (ahood-cli#31/#50): a raw
|
|
235
|
+
// proxy/WAF error page must not be spliced verbatim into a thrown Error.
|
|
236
|
+
const rawBody = await putRes.text().catch(() => "");
|
|
237
|
+
const body = rawBody ? sanitizeErrorMessage(rawBody) : "";
|
|
238
|
+
throw new Error(`Upload failed with status ${putRes.status}${body ? `: ${body}` : ""} (version_id: ${init.version_id}, retry with the same command once the underlying issue is fixed).`);
|
|
239
|
+
}
|
|
240
|
+
await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions/complete`, {
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: { "Content-Type": "application/json" },
|
|
243
|
+
body: JSON.stringify({ version_id: init.version_id }),
|
|
244
|
+
});
|
|
245
|
+
if (!jsonOutput)
|
|
246
|
+
console.log(`Uploaded ${owner}/${skill}@${version} -- processing...`);
|
|
247
|
+
const result = await pollVersionStatus(owner, skill, version);
|
|
248
|
+
if (result.status === "failed") {
|
|
249
|
+
throw new Error(`Publish failed: ${result.failure_reason ?? "unknown reason"}`);
|
|
250
|
+
}
|
|
251
|
+
if (jsonOutput) {
|
|
252
|
+
const output = {
|
|
253
|
+
version: result.version,
|
|
254
|
+
status: result.status,
|
|
255
|
+
};
|
|
256
|
+
if (result.failure_reason)
|
|
257
|
+
output.failure_reason = result.failure_reason;
|
|
258
|
+
if (created)
|
|
259
|
+
output.created = created;
|
|
260
|
+
console.log(JSON.stringify(output));
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
console.log(`Published ${owner}/${skill}@${result.version} (${result.status})`);
|
|
264
|
+
}
|
|
213
265
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const result = await pollVersionStatus(owner, skill, version);
|
|
221
|
-
if (result.status === "failed") {
|
|
222
|
-
throw new Error(`Publish failed: ${result.failure_reason ?? "unknown reason"}`);
|
|
266
|
+
catch (error) {
|
|
267
|
+
if (jsonOutput) {
|
|
268
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
269
|
+
console.log(JSON.stringify({ error: message }));
|
|
270
|
+
}
|
|
271
|
+
throw error;
|
|
223
272
|
}
|
|
224
|
-
console.log(`Published ${owner}/${skill}@${result.version} (${result.status})`);
|
|
225
273
|
}
|
package/dist/commands/update.js
CHANGED
|
@@ -1,19 +1,92 @@
|
|
|
1
|
-
import { LOCKFILE_PATH } from "../spec.js";
|
|
1
|
+
import { LOCKFILE_PATH, parseOwnerSkill } from "../spec.js";
|
|
2
2
|
import { readLockfile } from "../lockfile.js";
|
|
3
|
-
import { add } from "./add.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
import { add, fetchVersionMeta } from "./add.js";
|
|
4
|
+
const USAGE = "Usage: ahood update [<owner>/<skill> ...] [--dry-run] [--json]";
|
|
5
|
+
async function previewSkill(ownerSlashSkill, currentVersion) {
|
|
6
|
+
const { owner, skill } = parseOwnerSkill(ownerSlashSkill, USAGE);
|
|
7
|
+
const meta = await fetchVersionMeta(owner, skill, "latest");
|
|
8
|
+
const upToDate = currentVersion === meta.version;
|
|
9
|
+
return {
|
|
10
|
+
skill: ownerSlashSkill,
|
|
11
|
+
current_version: currentVersion,
|
|
12
|
+
latest_version: meta.version,
|
|
13
|
+
up_to_date: upToDate,
|
|
14
|
+
changelog_md: upToDate ? null : meta.changelog_md ?? null,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function padRow(cells, widths) {
|
|
18
|
+
return cells.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd();
|
|
19
|
+
}
|
|
20
|
+
// Prints a side-by-side current/latest table for every previewed skill, then
|
|
21
|
+
// -- for only the skills that actually have an update available -- the
|
|
22
|
+
// changelog for the version they'd move to. Nothing here touches disk or the
|
|
23
|
+
// network beyond the read-only resolution already done in previewSkill().
|
|
24
|
+
function printDryRunTable(previews) {
|
|
25
|
+
const header = ["SKILL", "CURRENT", "LATEST", "STATUS"];
|
|
26
|
+
const rows = previews.map((p) => [
|
|
27
|
+
p.skill,
|
|
28
|
+
p.current_version ?? "(not installed)",
|
|
29
|
+
p.latest_version,
|
|
30
|
+
p.up_to_date ? "up to date" : "update available",
|
|
31
|
+
]);
|
|
32
|
+
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
|
|
33
|
+
console.log(padRow(header, widths));
|
|
34
|
+
for (const row of rows)
|
|
35
|
+
console.log(padRow(row, widths));
|
|
36
|
+
const changed = previews.filter((p) => !p.up_to_date);
|
|
37
|
+
if (changed.length === 0) {
|
|
38
|
+
console.log("\nAll skills are already up to date.");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
for (const p of changed) {
|
|
42
|
+
console.log(`\n${p.skill}: ${p.current_version ?? "(not installed)"} -> ${p.latest_version}`);
|
|
43
|
+
console.log(p.changelog_md ? p.changelog_md : "(no changelog available)");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
7
46
|
export async function update(args) {
|
|
47
|
+
const dryRun = args.includes("--dry-run");
|
|
48
|
+
const jsonOutput = args.includes("--json");
|
|
49
|
+
if (jsonOutput && !dryRun) {
|
|
50
|
+
throw new Error(`--json is only supported together with --dry-run.\n${USAGE}`);
|
|
51
|
+
}
|
|
52
|
+
const targets = args.filter((a) => a !== "--dry-run" && a !== "--json");
|
|
8
53
|
const lockfile = readLockfile(LOCKFILE_PATH);
|
|
9
|
-
const
|
|
10
|
-
if (
|
|
54
|
+
const skillKeys = targets.length > 0 ? targets : Object.keys(lockfile);
|
|
55
|
+
if (skillKeys.length === 0) {
|
|
11
56
|
console.log("No installed skills to update.");
|
|
12
57
|
return;
|
|
13
58
|
}
|
|
59
|
+
if (dryRun) {
|
|
60
|
+
// Read-only preview: resolves "latest" for each skill via the same
|
|
61
|
+
// fetchVersionMeta() add() itself uses, but never calls add() -- so
|
|
62
|
+
// nothing is downloaded, extracted, or written to the lockfile.
|
|
63
|
+
const previews = [];
|
|
64
|
+
const failures = [];
|
|
65
|
+
for (const key of skillKeys) {
|
|
66
|
+
try {
|
|
67
|
+
previews.push(await previewSkill(key, lockfile[key]?.version ?? null));
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
71
|
+
console.error(`Failed to resolve latest version for ${key}: ${message}`);
|
|
72
|
+
failures.push(key);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (jsonOutput) {
|
|
76
|
+
console.log(JSON.stringify(previews));
|
|
77
|
+
}
|
|
78
|
+
else if (previews.length > 0) {
|
|
79
|
+
printDryRunTable(previews);
|
|
80
|
+
}
|
|
81
|
+
if (failures.length > 0) {
|
|
82
|
+
console.error(`${failures.length} of ${skillKeys.length} skill(s) could not be resolved: ${failures.join(", ")}`);
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
14
87
|
const failures = [];
|
|
15
|
-
for (const ownerSlashSkill of
|
|
16
|
-
if (!lockfile[ownerSlashSkill] &&
|
|
88
|
+
for (const ownerSlashSkill of skillKeys) {
|
|
89
|
+
if (!lockfile[ownerSlashSkill] && targets.length === 0) {
|
|
17
90
|
// Only reachable when targets came from the lockfile itself, so this
|
|
18
91
|
// would mean the lockfile changed under us mid-loop -- skip, don't abort.
|
|
19
92
|
console.warn(`Skipping ${ownerSlashSkill}: not currently installed.`);
|
|
@@ -31,7 +104,7 @@ export async function update(args) {
|
|
|
31
104
|
}
|
|
32
105
|
}
|
|
33
106
|
if (failures.length > 0) {
|
|
34
|
-
console.error(`${failures.length} of ${
|
|
107
|
+
console.error(`${failures.length} of ${skillKeys.length} skill(s) failed to update: ${failures.join(", ")}`);
|
|
35
108
|
process.exitCode = 1;
|
|
36
109
|
}
|
|
37
110
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { apiJson } from "../http.js";
|
|
2
|
+
import { parseOwnerSkill } from "../spec.js";
|
|
3
|
+
const USAGE = "Usage: ahood versions <owner>/<skill> [--json]";
|
|
4
|
+
function formatSize(bytes) {
|
|
5
|
+
if (bytes < 1024)
|
|
6
|
+
return `${bytes} B`;
|
|
7
|
+
const units = ["KB", "MB", "GB", "TB"];
|
|
8
|
+
let value = bytes / 1024;
|
|
9
|
+
let unitIndex = 0;
|
|
10
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
11
|
+
value /= 1024;
|
|
12
|
+
unitIndex++;
|
|
13
|
+
}
|
|
14
|
+
return `${value.toFixed(1)} ${units[unitIndex]}`;
|
|
15
|
+
}
|
|
16
|
+
export async function versions(args) {
|
|
17
|
+
const jsonOutput = args.includes("--json");
|
|
18
|
+
const spec = args.find((a) => !a.startsWith("--"));
|
|
19
|
+
if (!spec)
|
|
20
|
+
throw new Error(USAGE);
|
|
21
|
+
const { owner, skill } = parseOwnerSkill(spec, USAGE);
|
|
22
|
+
const { versions: list } = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions`);
|
|
23
|
+
if (jsonOutput) {
|
|
24
|
+
console.log(JSON.stringify(list));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (list.length === 0) {
|
|
28
|
+
console.log(`${owner}/${skill} has no published versions.`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
console.log(`${owner}/${skill} -- ${list.length} published version${list.length === 1 ? "" : "s"}`);
|
|
32
|
+
const field = (label, value) => console.log(` ${label.padEnd(13)}${value}`);
|
|
33
|
+
for (const v of list) {
|
|
34
|
+
console.log("");
|
|
35
|
+
console.log(v.version);
|
|
36
|
+
field("published:", v.created_at);
|
|
37
|
+
field("size:", formatSize(v.package_size_bytes));
|
|
38
|
+
field("changelog:", v.changelog_md ? v.changelog_md : "-");
|
|
39
|
+
}
|
|
40
|
+
}
|
package/dist/commands/whoami.js
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import { ApiError, apiJson } from "../http.js";
|
|
2
2
|
import { resolveToken } from "../credentials.js";
|
|
3
|
+
// Best-effort enrichment: whoami's real job is answering "does this token
|
|
4
|
+
// still authenticate?", which is already settled by the time this runs.
|
|
5
|
+
// A failure here (network blip, an otherwise-valid token hitting a 500 on
|
|
6
|
+
// this specific route, etc.) must never turn a successful auth check into a
|
|
7
|
+
// command failure -- so every error is swallowed and callers fall back to
|
|
8
|
+
// the plain "Authenticated..." message instead.
|
|
9
|
+
async function fetchProfile() {
|
|
10
|
+
try {
|
|
11
|
+
return await apiJson("/api/v1/profile");
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
3
17
|
export async function whoami(args = []) {
|
|
4
18
|
const wantsJson = args.includes("--json");
|
|
5
19
|
const token = resolveToken();
|
|
@@ -30,17 +44,21 @@ export async function whoami(args = []) {
|
|
|
30
44
|
await apiJson("/api/v1/auth/tokens");
|
|
31
45
|
// A session-backed caller (only reachable if this ever runs against a
|
|
32
46
|
// cookie-bearing client) -- the token list came back.
|
|
47
|
+
const profile = await fetchProfile();
|
|
33
48
|
if (wantsJson)
|
|
34
|
-
console.log(JSON.stringify({ authenticated: true, mode: "session" }));
|
|
49
|
+
console.log(JSON.stringify({ authenticated: true, mode: "session", ...profile }));
|
|
35
50
|
else
|
|
36
|
-
console.log("Authenticated.");
|
|
51
|
+
console.log(profile ? `Authenticated as ${profile.username}.` : "Authenticated.");
|
|
37
52
|
}
|
|
38
53
|
catch (error) {
|
|
39
54
|
if (error instanceof ApiError && error.status === 403) {
|
|
55
|
+
const profile = await fetchProfile();
|
|
40
56
|
if (wantsJson)
|
|
41
|
-
console.log(JSON.stringify({ authenticated: true, mode: "token" }));
|
|
57
|
+
console.log(JSON.stringify({ authenticated: true, mode: "token", ...profile }));
|
|
42
58
|
else
|
|
43
|
-
console.log(
|
|
59
|
+
console.log(profile
|
|
60
|
+
? `Authenticated as ${profile.username} (personal API token).`
|
|
61
|
+
: "Authenticated with a personal API token.");
|
|
44
62
|
return;
|
|
45
63
|
}
|
|
46
64
|
if (error instanceof ApiError && error.status === 401) {
|
package/dist/flags.js
CHANGED
|
@@ -1,13 +1,29 @@
|
|
|
1
1
|
// Shared flag-value parsing. Throws instead of silently treating the next flag
|
|
2
2
|
// as this flag's value (e.g. `--tagline --visibility public` used to send
|
|
3
3
|
// "--visibility" as the tagline and drop --visibility entirely).
|
|
4
|
+
//
|
|
5
|
+
// Two forms are accepted:
|
|
6
|
+
// --flag value Space-separated. If `value` itself looks like a flag
|
|
7
|
+
// (starts with "--"), that's treated as "no value given"
|
|
8
|
+
// per the swallow-protection above, and this throws.
|
|
9
|
+
// --flag=value Explicit-equals. Everything after the first "=" is the
|
|
10
|
+
// value, verbatim, even if it starts with "--" -- this is
|
|
11
|
+
// the unambiguous escape hatch for values that genuinely
|
|
12
|
+
// start with "--" (e.g. a tagline like "--fast and cheap").
|
|
4
13
|
export function flagValue(args, flag) {
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
14
|
+
const prefix = `${flag}=`;
|
|
15
|
+
for (let i = 0; i < args.length; i++) {
|
|
16
|
+
const arg = args[i];
|
|
17
|
+
if (arg.startsWith(prefix)) {
|
|
18
|
+
return arg.slice(prefix.length);
|
|
19
|
+
}
|
|
20
|
+
if (arg === flag) {
|
|
21
|
+
const value = args[i + 1];
|
|
22
|
+
if (value === undefined || value.startsWith("--")) {
|
|
23
|
+
throw new Error(`${flag} requires a value.`);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
11
27
|
}
|
|
12
|
-
return
|
|
28
|
+
return undefined;
|
|
13
29
|
}
|
package/dist/help.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
export const COMMANDS_HELP = [
|
|
2
|
-
{
|
|
3
|
-
|
|
2
|
+
{
|
|
3
|
+
usage: "ahood login",
|
|
4
|
+
summary: "Device-code browser login, stores a token locally.",
|
|
5
|
+
desc: "Device-code browser login, stores a token locally.",
|
|
6
|
+
},
|
|
7
|
+
{ usage: "ahood logout", summary: "Removes the stored token.", desc: "Removes the stored token." },
|
|
4
8
|
{
|
|
5
9
|
usage: "ahood whoami [--json]",
|
|
10
|
+
summary: "Reports whether your stored token still authenticates.",
|
|
6
11
|
desc: "Reports whether your stored token still authenticates.",
|
|
7
12
|
flags: ["--json Emit a machine-readable result instead of prose."],
|
|
8
13
|
},
|
|
9
14
|
{
|
|
10
15
|
usage: "ahood search <query> [--json] [--limit <n>]",
|
|
16
|
+
summary: "Search published skills.",
|
|
11
17
|
desc: "Search published skills.",
|
|
12
18
|
flags: [
|
|
13
19
|
"--json Emit the raw skill objects instead of formatted lines.",
|
|
@@ -17,30 +23,51 @@ export const COMMANDS_HELP = [
|
|
|
17
23
|
},
|
|
18
24
|
{
|
|
19
25
|
usage: "ahood view <owner>/<skill> [--json] [--web]",
|
|
26
|
+
summary: "Show a single skill's details -- tags, license, homepage, repository, dates, and more -- without installing it (alias: ahood show).",
|
|
20
27
|
desc: "Show a single skill's details (tags, license, homepage, repository, dates, etc.) without installing it. Alias: ahood show.",
|
|
21
28
|
flags: [
|
|
22
29
|
"--json Emit the raw skill object instead of formatted lines.",
|
|
23
30
|
"--web Open the skill's page in your browser instead of printing.",
|
|
24
31
|
],
|
|
25
32
|
},
|
|
33
|
+
{
|
|
34
|
+
usage: "ahood versions <owner>/<skill> [--json]",
|
|
35
|
+
summary: "List a skill's published-version history -- version, changelog, size, and publish date.",
|
|
36
|
+
desc: "List a skill's published-version history: version, changelog, size, and publish date. Most-recent first.",
|
|
37
|
+
flags: ["--json Emit the raw version objects instead of formatted text."],
|
|
38
|
+
},
|
|
26
39
|
{
|
|
27
40
|
usage: "ahood list-mine [--json]",
|
|
41
|
+
summary: "List your own skills, public and private.",
|
|
28
42
|
desc: "List your own skills, public and private.",
|
|
29
43
|
flags: ["--json Emit the raw skill objects instead of formatted lines."],
|
|
30
44
|
},
|
|
31
45
|
{
|
|
32
46
|
usage: "ahood add <owner>/<skill>[@version]",
|
|
47
|
+
summary: "Install a skill into .claude/skills/, pinned in the lockfile.",
|
|
33
48
|
desc: "Install a skill into .claude/skills/, pinned in the lockfile.",
|
|
34
49
|
examples: ["ahood add alice/pdf-tools", "ahood add alice/pdf-tools@1.2.0"],
|
|
35
50
|
},
|
|
36
51
|
{
|
|
37
|
-
usage: "ahood update [<owner>/<skill> ...]",
|
|
52
|
+
usage: "ahood update [<owner>/<skill> ...] [--dry-run] [--json]",
|
|
53
|
+
summary: "Move the lockfile pin(s) forward to the latest version, for one skill or all installed skills at once.",
|
|
38
54
|
desc: "Move the lockfile pin(s) forward to the latest version. With no argument, updates every installed skill; one failure doesn't stop the rest.",
|
|
55
|
+
flags: [
|
|
56
|
+
"--dry-run Preview current vs. latest version (and the changelog for anything behind) without installing anything.",
|
|
57
|
+
"--json With --dry-run, emit structured preview objects instead of a formatted table.",
|
|
58
|
+
],
|
|
59
|
+
examples: ["ahood update --dry-run", "ahood update alice/pdf-tools --dry-run --json"],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
usage: "ahood remove <owner>/<skill>",
|
|
63
|
+
summary: "Uninstall and unpin a skill (local only).",
|
|
64
|
+
desc: "Uninstall and unpin (local only).",
|
|
39
65
|
},
|
|
40
|
-
{ usage: "ahood remove <owner>/<skill>", desc: "Uninstall and unpin (local only)." },
|
|
41
66
|
{
|
|
42
67
|
usage: "ahood edit <owner>/<skill> [--tagline] [--tags] [--license] [--visibility] [--homepage] [--repository]",
|
|
43
|
-
|
|
68
|
+
summary: "Update a skill you own, changing only the flags you pass.",
|
|
69
|
+
desc: "Update a skill you own. Only the flags you pass are changed. Every flag also accepts " +
|
|
70
|
+
"the --flag=value form (e.g. --tagline=--fast and cheap), needed when a value itself starts with --.",
|
|
44
71
|
flags: [
|
|
45
72
|
"--tagline <text> Short one-line description.",
|
|
46
73
|
"--tags <comma,separated> Replaces the skill's tag list.",
|
|
@@ -53,17 +80,31 @@ export const COMMANDS_HELP = [
|
|
|
53
80
|
},
|
|
54
81
|
{
|
|
55
82
|
usage: "ahood unpublish <owner>/<skill> [--yes]",
|
|
83
|
+
summary: "Delete a skill from the registry for every consumer, not just your local install (prompts for confirmation unless --yes is passed).",
|
|
56
84
|
desc: "Delete a skill from the registry for every consumer (not just your local install). Prompts for a typed \"yes\" unless --yes is passed.",
|
|
57
85
|
flags: ["--yes Skip the interactive confirmation, for scripts/CI."],
|
|
58
86
|
},
|
|
59
|
-
{ usage: "ahood star <owner>/<skill>", desc: "Star a skill." },
|
|
60
|
-
{
|
|
87
|
+
{ usage: "ahood star <owner>/<skill>", summary: "Star a skill.", desc: "Star a skill." },
|
|
88
|
+
{
|
|
89
|
+
usage: "ahood unstar <owner>/<skill>",
|
|
90
|
+
summary: "Remove your star from a skill.",
|
|
91
|
+
desc: "Remove your star from a skill.",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
usage: "ahood init [name]",
|
|
95
|
+
summary: "Scaffold a new skill folder with a minimal, valid SKILL.md.",
|
|
96
|
+
desc: "Scaffold a new skill folder with a minimal, valid SKILL.md. Creates ./<name>/SKILL.md if a name is given, " +
|
|
97
|
+
"or ./SKILL.md in the current directory otherwise. Refuses to overwrite an existing SKILL.md at the target path.",
|
|
98
|
+
examples: ["ahood init pdf-tools", "ahood init"],
|
|
99
|
+
},
|
|
61
100
|
{
|
|
62
101
|
usage: "ahood publish <owner>/<skill>@<version> [--path <dir>] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>]",
|
|
102
|
+
summary: "Publish a new version of a skill from a folder containing SKILL.md, creating the skill first if it doesn't already exist.",
|
|
63
103
|
desc: "Publish a new version of a skill from a folder containing SKILL.md. If the skill doesn't exist yet, this " +
|
|
64
104
|
"creates it first -- pass --name (required in that case) and optionally --tagline/--tags/--license/--homepage/--repository. " +
|
|
65
105
|
"Processing happens server-side after upload; this command polls and reports the final published/failed status. " +
|
|
66
|
-
"The legacy form `ahood publish <path> --owner <owner> --slug <skill> --version <x.y.z>` is still accepted."
|
|
106
|
+
"The legacy form `ahood publish <path> --owner <owner> --slug <skill> --version <x.y.z>` is still accepted. " +
|
|
107
|
+
"Every flag also accepts the --flag=value form, needed when a value itself starts with --.",
|
|
67
108
|
flags: [
|
|
68
109
|
"--name <text> Required only when creating the skill on this publish.",
|
|
69
110
|
"--tagline <text> Short one-line description, used only when creating.",
|
|
@@ -80,20 +121,38 @@ export const COMMANDS_HELP = [
|
|
|
80
121
|
},
|
|
81
122
|
{
|
|
82
123
|
usage: "ahood token create <name>|list [--json]|revoke <id>",
|
|
124
|
+
summary: "Manage personal API tokens.",
|
|
83
125
|
desc: "Manage personal API tokens. `create` requires an existing browser-backed session.",
|
|
84
126
|
examples: ["ahood token create ci-runner", "ahood token list --json", "ahood token revoke <id>"],
|
|
85
127
|
},
|
|
86
128
|
{
|
|
87
129
|
usage: "ahood completion <bash|zsh|fish>",
|
|
130
|
+
summary: "Print a shell completion script for the command names.",
|
|
88
131
|
desc: "Print a shell completion script for the command names.",
|
|
89
132
|
examples: ["ahood completion bash >> ~/.bashrc"],
|
|
90
133
|
},
|
|
91
134
|
];
|
|
92
|
-
|
|
135
|
+
// Exported so other consumers of the command list (e.g. shell completion) can
|
|
136
|
+
// surface aliases without needing their own copy of this map.
|
|
137
|
+
export const COMMAND_ALIASES = { show: "view" };
|
|
93
138
|
export function findCommandHelp(command) {
|
|
94
139
|
const resolved = COMMAND_ALIASES[command] ?? command;
|
|
95
140
|
return COMMANDS_HELP.find((c) => c.usage.startsWith(`ahood ${resolved} `) || c.usage === `ahood ${resolved}`);
|
|
96
141
|
}
|
|
142
|
+
// Renders a COMMANDS_HELP entry's usage line with any known aliases folded in
|
|
143
|
+
// after the primary command name, e.g. "ahood view <owner>/<skill> ..." ->
|
|
144
|
+
// "ahood view|show <owner>/<skill> ...". Used by the top-level --help listing
|
|
145
|
+
// so aliases (like "show" for "view") aren't invisible to users who only
|
|
146
|
+
// skim `ahood --help`.
|
|
147
|
+
export function usageWithAliases(entry) {
|
|
148
|
+
const parts = entry.usage.split(" ");
|
|
149
|
+
const name = parts[1];
|
|
150
|
+
const aliases = Object.keys(COMMAND_ALIASES).filter((alias) => COMMAND_ALIASES[alias] === name);
|
|
151
|
+
if (aliases.length === 0)
|
|
152
|
+
return entry.usage;
|
|
153
|
+
parts[1] = [name, ...aliases].join("|");
|
|
154
|
+
return parts.join(" ");
|
|
155
|
+
}
|
|
97
156
|
export function formatCommandHelp(entry) {
|
|
98
157
|
const lines = [entry.usage, "", entry.desc];
|
|
99
158
|
if (entry.flags?.length)
|
|
@@ -103,8 +162,8 @@ export function formatCommandHelp(entry) {
|
|
|
103
162
|
return lines.join("\n");
|
|
104
163
|
}
|
|
105
164
|
export function formatHelp() {
|
|
106
|
-
const width = Math.max(...COMMANDS_HELP.map((c) => c.
|
|
107
|
-
const lines = COMMANDS_HELP.map((c) => ` ${c.
|
|
165
|
+
const width = Math.max(...COMMANDS_HELP.map((c) => usageWithAliases(c).length));
|
|
166
|
+
const lines = COMMANDS_HELP.map((c) => ` ${usageWithAliases(c).padEnd(width + 2)}${c.summary}`);
|
|
108
167
|
return [
|
|
109
168
|
"ahood -- CLI for the ahood skills registry (https://ahood.vercel.app)",
|
|
110
169
|
"",
|
package/dist/http.js
CHANGED
|
@@ -43,7 +43,7 @@ const MAX_ERROR_MESSAGE_LENGTH = 500;
|
|
|
43
43
|
// -- a regression on any endpoint would otherwise dump multi-KB infra details
|
|
44
44
|
// straight to a user's terminal. Anything HTML-shaped or implausibly long for
|
|
45
45
|
// a normal error string is replaced with a short, safe summary instead.
|
|
46
|
-
function sanitizeErrorMessage(message) {
|
|
46
|
+
export function sanitizeErrorMessage(message) {
|
|
47
47
|
const looksLikeHtml = /<!DOCTYPE|<html[\s>]/i.test(message);
|
|
48
48
|
if (!looksLikeHtml && message.length <= MAX_ERROR_MESSAGE_LENGTH)
|
|
49
49
|
return message;
|
package/dist/index.js
CHANGED
|
@@ -13,7 +13,9 @@ import { unpublish } from "./commands/unpublish.js";
|
|
|
13
13
|
import { listMine } from "./commands/list-mine.js";
|
|
14
14
|
import { star, unstar } from "./commands/star.js";
|
|
15
15
|
import { view } from "./commands/view.js";
|
|
16
|
+
import { versions } from "./commands/versions.js";
|
|
16
17
|
import { completion } from "./commands/completion.js";
|
|
18
|
+
import { init } from "./commands/init.js";
|
|
17
19
|
import { formatHelp, formatCommandHelp, findCommandHelp } from "./help.js";
|
|
18
20
|
import { ApiError } from "./http.js";
|
|
19
21
|
import { exitCodeFor } from "./exit-code.js";
|
|
@@ -27,6 +29,7 @@ const COMMANDS = {
|
|
|
27
29
|
update,
|
|
28
30
|
remove,
|
|
29
31
|
publish,
|
|
32
|
+
init,
|
|
30
33
|
token,
|
|
31
34
|
edit,
|
|
32
35
|
unpublish,
|
|
@@ -35,6 +38,7 @@ const COMMANDS = {
|
|
|
35
38
|
unstar,
|
|
36
39
|
view,
|
|
37
40
|
show: view, // alias -- "ahood show" is the name issue #30 proposed; same command as "view"
|
|
41
|
+
versions,
|
|
38
42
|
completion,
|
|
39
43
|
};
|
|
40
44
|
function levenshtein(a, b) {
|
package/dist/spec.js
CHANGED
|
@@ -4,11 +4,25 @@ import { join } from "node:path";
|
|
|
4
4
|
// "/", "\", or a leading dot -- this is what closes the path-traversal hole in add/remove
|
|
5
5
|
// (a spec like "alice/.." previously resolved outside .claude/skills/ entirely).
|
|
6
6
|
const SEGMENT_RE = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
7
|
-
export const SEMVER_RE =
|
|
7
|
+
export const SEMVER_RE = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
8
|
+
// The backend enforces these same caps on write (app/api/v1/profile/route.ts's
|
|
9
|
+
// MAX_USERNAME_LENGTH for owner usernames; lib/skills/mutations.ts's
|
|
10
|
+
// createSkill 3-64 check for skill slugs), so a segment over these lengths
|
|
11
|
+
// can never match a real owner/skill anyway. Rejecting it here -- before any
|
|
12
|
+
// request is built -- turns a validly-charset-formatted but absurdly long
|
|
13
|
+
// segment into a clean local error instead of a network round trip that, at
|
|
14
|
+
// a truly pathological length (thousands of characters), hits a raw infra/
|
|
15
|
+
// CDN-layer URL-length limit and 502s before the backend's own routing ever
|
|
16
|
+
// runs (ahood-cli#38).
|
|
17
|
+
const MAX_SEGMENT_LENGTH = { owner: 32, skill: 64 };
|
|
8
18
|
export function validateSegment(value, kind, spec) {
|
|
9
19
|
if (!SEGMENT_RE.test(value) || value === "." || value === "..") {
|
|
10
20
|
throw new Error(`Invalid ${kind} "${value}" in "${spec}" -- must start with a letter/digit and contain only letters, digits, ".", "_", or "-".`);
|
|
11
21
|
}
|
|
22
|
+
const maxLength = MAX_SEGMENT_LENGTH[kind];
|
|
23
|
+
if (value.length > maxLength) {
|
|
24
|
+
throw new Error(`Invalid ${kind} in "${spec.slice(0, 60)}${spec.length > 60 ? "..." : ""}" -- ${kind} segment is too long (${value.length} characters; must be at most ${maxLength}).`);
|
|
25
|
+
}
|
|
12
26
|
}
|
|
13
27
|
export function parseOwnerSkill(spec, usage) {
|
|
14
28
|
if (!spec)
|
|
@@ -36,6 +50,29 @@ export function parseOwnerSkillVersion(spec, usage) {
|
|
|
36
50
|
const { owner, skill } = parseOwnerSkill(ownerSkill, usage);
|
|
37
51
|
return { owner, skill, version };
|
|
38
52
|
}
|
|
53
|
+
// Applied to --homepage/--repository in edit and publish (ahood-cli#34):
|
|
54
|
+
// these are arbitrary user-supplied external links that the API stores
|
|
55
|
+
// verbatim and the web frontend renders as a raw <a href> -- a
|
|
56
|
+
// javascript:/data: value would be a stored-XSS vector the moment that
|
|
57
|
+
// page renders it. Rejecting anything but http(s) here, before any network
|
|
58
|
+
// call, mirrors config.ts's getApiUrl() validation pattern but with no
|
|
59
|
+
// localhost special-casing (there's no "trusted local dev" case for a
|
|
60
|
+
// public link). An empty string is allowed through un-validated so
|
|
61
|
+
// `edit --homepage ""` can still be used to clear a previously-set value.
|
|
62
|
+
export function validateExternalUrl(value, flag) {
|
|
63
|
+
if (value === "")
|
|
64
|
+
return;
|
|
65
|
+
let url;
|
|
66
|
+
try {
|
|
67
|
+
url = new URL(value);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new Error(`${flag} must be a valid http:// or https:// URL (got "${value}").`);
|
|
71
|
+
}
|
|
72
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
73
|
+
throw new Error(`${flag} must use http:// or https:// (got scheme "${url.protocol}" from "${value}").`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
39
76
|
export const SKILLS_ROOT = join(".claude", "skills");
|
|
40
77
|
export const LOCKFILE_PATH = join(".claude", "skills.lock.json");
|
|
41
78
|
export function skillDir(owner, skill) {
|
package/package.json
CHANGED