@ahood/cli 0.2.3 → 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 +1 -1
- package/dist/commands/init.js +56 -0
- package/dist/commands/publish.js +86 -45
- package/dist/commands/update.js +83 -10
- package/dist/commands/versions.js +40 -0
- package/dist/commands/whoami.js +22 -4
- package/dist/help.js +19 -1
- package/dist/http.js +1 -1
- package/dist/index.js +4 -0
- 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)
|
|
@@ -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,7 +2,7 @@ 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
7
|
import { parseOwnerSkill, SEMVER_RE, validateExternalUrl } from "../spec.js";
|
|
8
8
|
// Processing (decompress/validate/checksum/scan/upload) now happens in a
|
|
@@ -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
|
|
@@ -144,7 +144,7 @@ function parsePublishArgs(args) {
|
|
|
144
144
|
// createSkill requires a name; --tagline/--tags/--license/--homepage/
|
|
145
145
|
// --repository are optional and only used here, never applied to an
|
|
146
146
|
// already-existing skill.
|
|
147
|
-
async function createSkillForPublish(owner, skill, opts) {
|
|
147
|
+
async function createSkillForPublish(owner, skill, opts, jsonOutput) {
|
|
148
148
|
if (!opts.name) {
|
|
149
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").`);
|
|
150
150
|
}
|
|
@@ -173,60 +173,101 @@ async function createSkillForPublish(owner, skill, opts) {
|
|
|
173
173
|
`are always created under your own account (${created.owner}). Retry with: ` +
|
|
174
174
|
`ahood publish ${created.owner}/${skill}@<version>.`);
|
|
175
175
|
}
|
|
176
|
-
|
|
176
|
+
if (!jsonOutput) {
|
|
177
|
+
console.log(`Created ${created.owner}/${created.slug} -- publishing its first version now.`);
|
|
178
|
+
}
|
|
179
|
+
return created;
|
|
177
180
|
}
|
|
178
|
-
async function initVersion(owner, skill, version, packageSizeBytes, createOpts) {
|
|
181
|
+
async function initVersion(owner, skill, version, packageSizeBytes, createOpts, jsonOutput) {
|
|
179
182
|
const path = `/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions/init`;
|
|
180
183
|
const body = JSON.stringify({ version, package_size_bytes: packageSizeBytes });
|
|
181
184
|
try {
|
|
182
|
-
|
|
185
|
+
const init = await apiJson(path, { method: "POST", headers: { "Content-Type": "application/json" }, body });
|
|
186
|
+
return { init };
|
|
183
187
|
}
|
|
184
188
|
catch (error) {
|
|
185
189
|
if (!(error instanceof ApiError) || error.status !== 404)
|
|
186
190
|
throw error;
|
|
187
|
-
await createSkillForPublish(owner, skill, createOpts);
|
|
191
|
+
const created = await createSkillForPublish(owner, skill, createOpts, jsonOutput);
|
|
188
192
|
// Retry exactly once. If this ALSO 404s (e.g. a race, or the create
|
|
189
193
|
// silently didn't take), let that error propagate rather than looping.
|
|
190
|
-
|
|
194
|
+
const init = await apiJson(path, { method: "POST", headers: { "Content-Type": "application/json" }, body });
|
|
195
|
+
return { init, created };
|
|
191
196
|
}
|
|
192
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.
|
|
193
205
|
export async function publish(args) {
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
+
}
|
|
220
265
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const result = await pollVersionStatus(owner, skill, version);
|
|
228
|
-
if (result.status === "failed") {
|
|
229
|
-
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;
|
|
230
272
|
}
|
|
231
|
-
console.log(`Published ${owner}/${skill}@${result.version} (${result.status})`);
|
|
232
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/help.js
CHANGED
|
@@ -30,6 +30,12 @@ export const COMMANDS_HELP = [
|
|
|
30
30
|
"--web Open the skill's page in your browser instead of printing.",
|
|
31
31
|
],
|
|
32
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
|
+
},
|
|
33
39
|
{
|
|
34
40
|
usage: "ahood list-mine [--json]",
|
|
35
41
|
summary: "List your own skills, public and private.",
|
|
@@ -43,9 +49,14 @@ export const COMMANDS_HELP = [
|
|
|
43
49
|
examples: ["ahood add alice/pdf-tools", "ahood add alice/pdf-tools@1.2.0"],
|
|
44
50
|
},
|
|
45
51
|
{
|
|
46
|
-
usage: "ahood update [<owner>/<skill> ...]",
|
|
52
|
+
usage: "ahood update [<owner>/<skill> ...] [--dry-run] [--json]",
|
|
47
53
|
summary: "Move the lockfile pin(s) forward to the latest version, for one skill or all installed skills at once.",
|
|
48
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"],
|
|
49
60
|
},
|
|
50
61
|
{
|
|
51
62
|
usage: "ahood remove <owner>/<skill>",
|
|
@@ -79,6 +90,13 @@ export const COMMANDS_HELP = [
|
|
|
79
90
|
summary: "Remove your star from a skill.",
|
|
80
91
|
desc: "Remove your star from a skill.",
|
|
81
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
|
+
},
|
|
82
100
|
{
|
|
83
101
|
usage: "ahood publish <owner>/<skill>@<version> [--path <dir>] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>]",
|
|
84
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.",
|
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/package.json
CHANGED