@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 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. |
@@ -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
+ }
@@ -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
- console.log(`Created ${created.owner}/${created.slug} -- publishing its first version now.`);
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
- return await apiJson(path, { method: "POST", headers: { "Content-Type": "application/json" }, body });
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
- return await apiJson(path, { method: "POST", headers: { "Content-Type": "application/json" }, body });
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 { owner, skill, version, path, name, tagline, tags, license, homepage, repository } = parsePublishArgs(args);
195
- const skillMdPath = join(path, "SKILL.md");
196
- if (!existsSync(skillMdPath)) {
197
- throw new Error(`No SKILL.md found at ${skillMdPath} -- publish must point at a skill folder's root.`);
198
- }
199
- const archive = await tarGzDirectory(path);
200
- const init = await initVersion(owner, skill, version, archive.length, { name, tagline, tags, license, homepage, repository });
201
- // TS's lib.dom BodyInit (in scope here since tsconfig has no explicit
202
- // "lib" override, so DOM is included alongside the Node types) type-checks
203
- // Buffer/Uint8Array against `Uint8Array<ArrayBuffer>` specifically as of
204
- // TS 5.7+'s ArrayBufferLike generics, which a Node Buffer's
205
- // `Uint8Array<ArrayBufferLike>` doesn't structurally satisfy even though
206
- // it is a valid BufferSource at runtime (this is exactly what Node's own
207
- // fetch/undici accepts) -- see undici-types' BodyInit, which includes
208
- // `NodeJS.ArrayBufferView` (i.e. Buffer) directly with no such
209
- // restriction. Asserting through BodyInit here, rather than reshaping
210
- // tsconfig's "lib" for the whole package, keeps this fix local to the one
211
- // call site.
212
- const putRes = await fetch(init.upload_url, {
213
- method: "PUT",
214
- body: archive,
215
- signal: AbortSignal.timeout(120_000),
216
- });
217
- if (!putRes.ok) {
218
- const body = await putRes.text().catch(() => "");
219
- 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).`);
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
- await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions/complete`, {
222
- method: "POST",
223
- headers: { "Content-Type": "application/json" },
224
- body: JSON.stringify({ version_id: init.version_id }),
225
- });
226
- console.log(`Uploaded ${owner}/${skill}@${version} -- processing...`);
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
  }
@@ -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
- // `update` is the only command that moves the lockfile pin forward -- `add`
5
- // always resolves and pins whatever version it's given (or latest, once,
6
- // at install time), matching the ADR's "reproducible by default" design.
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 targets = args.length > 0 ? args : Object.keys(lockfile);
10
- if (targets.length === 0) {
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 targets) {
16
- if (!lockfile[ownerSlashSkill] && args.length === 0) {
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 ${targets.length} skill(s) failed to update: ${failures.join(", ")}`);
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
+ }
@@ -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("Authenticated with a personal API token.");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahood/cli",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "CLI for ahood, a registry for installing and publishing Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/AlexKay28/ahood-cli",