@ahood/cli 0.3.3 → 0.3.5

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
@@ -29,29 +29,32 @@ For CI or any non-interactive environment, set `AHOOD_TOKEN` instead of running
29
29
 
30
30
  ## Commands
31
31
 
32
- <!-- Kept in sync by hand with cli/src/help.ts's COMMANDS_HELP (the terminal
33
- --help output's source) and app/docs/page.tsx's COMMANDS array -- update
34
- all three when a command changes. -->
32
+ <!-- Generated from src/help.ts's COMMANDS_HELP by scripts/sync-readme.mjs --
33
+ do not hand-edit the table below. Run `npm run sync-readme` after
34
+ changing COMMANDS_HELP to regenerate it. -->
35
35
 
36
+ <!-- COMMANDS_TABLE_START -->
36
37
  | Command | What it does |
37
38
  | --- | --- |
38
- | `ahood login` | Device-code browser login, stores a token at `~/.config/ahood/credentials.json` (or `$XDG_CONFIG_HOME/ahood/`). |
39
+ | `ahood login` | Device-code browser login, stores a token locally. |
39
40
  | `ahood logout` | Removes the stored token. |
40
41
  | `ahood whoami [--json]` | Reports whether your stored token still authenticates. |
41
42
  | `ahood search <query> [--json] [--limit <n>]` | Search published skills. |
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
- | `ahood list-mine [--json]` | List your own skills, public and private, with their visibility and download/star counts. |
44
- | `ahood add <owner>/<skill>[@version]` | Install into `.claude/skills/<owner>/<skill>/`, pinned in `.claude/skills.lock.json`. |
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
- | `ahood remove <owner>/<skill>` | Uninstall and unpin (local only -- does not affect the published skill). |
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
- | `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. |
43
+ | `ahood view\|show <owner>/<skill> [--json] [--web]` | Show a single skill's details -- tags, license, homepage, repository, dates, and more -- without installing it (alias: ahood show). |
44
+ | `ahood versions <owner>/<skill> [--json]` | List a skill's published-version history -- version, changelog, size, and publish date. |
45
+ | `ahood list-mine [--json]` | List your own skills, public and private. |
46
+ | `ahood add <owner>/<skill>[@version]` | Install a skill into .claude/skills/, pinned in the lockfile. |
47
+ | `ahood update [<owner>/<skill> ...] [--dry-run] [--json]` | Move the lockfile pin(s) forward to the latest version, for one skill or all installed skills at once. |
48
+ | `ahood remove <owner>/<skill>` | Uninstall and unpin a skill (local only). |
49
+ | `ahood edit <owner>/<skill> [--tagline] [--tags] [--license] [--visibility] [--homepage] [--repository]` | Update a skill you own, changing only the flags you pass. |
50
+ | `ahood unpublish <owner>/<skill>[@version] [--yes]` | Delete a skill from the registry for every consumer, or yank a single version, not just your local install (prompts for confirmation unless --yes is passed). |
49
51
  | `ahood star <owner>/<skill>` | Star a skill. |
50
52
  | `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`. |
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. |
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
+ | `ahood init [name]` | Scaffold a new skill folder with a minimal, valid SKILL.md. |
54
+ | `ahood publish <owner>/<skill>@<version> [--path <dir>] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--json]` | Publish a new version of a skill from a folder containing SKILL.md, creating the skill first if it doesn't already exist. |
55
+ | `ahood token create <name>\|list [--json]\|revoke <id> [--yes]` | Manage personal API tokens. |
54
56
  | `ahood completion <bash\|zsh\|fish>` | Print a shell completion script for the command names. |
57
+ <!-- COMMANDS_TABLE_END -->
55
58
 
56
59
  Run `ahood help`, `ahood help <command>`, or `ahood <command> --help` for the same reference directly in your terminal. `ahood --version` prints the installed CLI version.
57
60
 
@@ -48,4 +48,7 @@ export async function edit(args) {
48
48
  body: JSON.stringify(body),
49
49
  });
50
50
  console.log(`Updated ${owner}/${updated.slug}: tagline=${JSON.stringify(updated.tagline)}, license=${JSON.stringify(updated.license)}, visibility=${updated.visibility}, tags=[${updated.tags.join(", ")}]`);
51
+ if (updated.tags_warning) {
52
+ console.warn(`WARNING: ${updated.tags_warning}`);
53
+ }
51
54
  }
@@ -1,6 +1,22 @@
1
1
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
- import { basename, join, resolve } from "node:path";
2
+ import { basename, join, resolve, sep } from "node:path";
3
3
  const USAGE = "Usage: ahood init [name]";
4
+ // Mirrors the server's slug convention referenced by ahood-cli#60 (and the
5
+ // same shape as spec.ts's SEGMENT_RE, minus "." and "_" -- skill slugs are
6
+ // strictly lowercase-alnum-and-hyphen). A name that already matches this is
7
+ // left untouched; one that doesn't gets normalized rather than rejected,
8
+ // since `init` is meant to be the friendly first-run command.
9
+ const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
10
+ // Lowercases and collapses any run of non-alphanumeric characters (spaces,
11
+ // punctuation, path separators that survived the containment check, etc.)
12
+ // into a single hyphen, then trims leading/trailing hyphens. E.g.
13
+ // "Bad Name!" -> "bad-name".
14
+ function normalizeToSlug(name) {
15
+ return name
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9]+/g, "-")
18
+ .replace(/^-+|-+$/g, "");
19
+ }
4
20
  // publish.ts's only client-side check on SKILL.md is that the file exists
5
21
  // (see publish.ts's `existsSync(skillMdPath)`) -- actual frontmatter
6
22
  // validation happens server-side, after upload, as part of the processing
@@ -40,9 +56,33 @@ function skillNameFor(dirPath) {
40
56
  return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(base) ? base : "my-skill";
41
57
  }
42
58
  export async function init(args) {
43
- const name = args[0];
59
+ let name = args[0];
44
60
  if (name !== undefined && name.startsWith("-"))
45
61
  throw new Error(USAGE);
62
+ if (name) {
63
+ // Path containment first (ahood-cli#61), mirroring spec.ts's
64
+ // validateSegment containment discipline used by add/remove: a resolved
65
+ // path that escapes the current directory is a hard rejection, since
66
+ // normalizing something meant to escape the directory doesn't make
67
+ // sense -- this has to run on the raw name, before any slug
68
+ // normalization would mangle "/" and ".." into harmless hyphens.
69
+ const cwd = resolve(process.cwd());
70
+ const resolvedName = resolve(cwd, name);
71
+ if (resolvedName !== cwd && !resolvedName.startsWith(cwd + sep)) {
72
+ throw new Error(`Invalid name "${name}" -- resolves to "${resolvedName}", which is outside the current directory (${cwd}). Refusing to create files outside the project directory.`);
73
+ }
74
+ // Then slug normalization (ahood-cli#60): a name that stays within cwd
75
+ // but isn't already a valid slug gets normalized with a note, rather
76
+ // than rejected outright.
77
+ if (!SLUG_RE.test(name)) {
78
+ const normalized = normalizeToSlug(name);
79
+ if (!normalized) {
80
+ throw new Error(`Invalid name "${name}" -- could not derive a valid name from it (must contain at least one letter or digit).`);
81
+ }
82
+ console.log(`Note: normalized "${name}" to "${normalized}".`);
83
+ name = normalized;
84
+ }
85
+ }
46
86
  const targetDir = name ? resolve(name) : process.cwd();
47
87
  const skillMdPath = join(targetDir, "SKILL.md");
48
88
  if (existsSync(skillMdPath)) {
@@ -16,7 +16,7 @@ export async function search(args) {
16
16
  }
17
17
  const qs = new URLSearchParams({ q: query });
18
18
  if (limit !== undefined)
19
- qs.set("limit", limit);
19
+ qs.set("per_page", limit);
20
20
  const { skills } = await apiJson(`/api/v1/skills?${qs}`);
21
21
  if (jsonOutput) {
22
22
  console.log(JSON.stringify(skills));
@@ -1,4 +1,5 @@
1
1
  import { apiJson } from "../http.js";
2
+ import { confirm } from "../confirm.js";
2
3
  export async function token(args) {
3
4
  const [subcommand, ...rest] = args;
4
5
  switch (subcommand) {
@@ -9,7 +10,7 @@ export async function token(args) {
9
10
  case "revoke":
10
11
  return tokenRevoke(rest);
11
12
  default:
12
- throw new Error("Usage: ahood token create <name>|list [--json]|revoke <id>");
13
+ throw new Error("Usage: ahood token create <name>|list [--json]|revoke <id> [--yes]");
13
14
  }
14
15
  }
15
16
  async function tokenCreate(args) {
@@ -48,9 +49,40 @@ async function tokenList(args) {
48
49
  }
49
50
  }
50
51
  async function tokenRevoke(args) {
51
- const id = args[0];
52
+ const yes = args.includes("--yes");
53
+ const id = args.find((a) => !a.startsWith("--"));
52
54
  if (!id)
53
- throw new Error("Usage: ahood token revoke <id>");
55
+ throw new Error("Usage: ahood token revoke <id> [--yes]");
56
+ // --yes bypasses both the lookup and the prompt entirely, preserving the
57
+ // original one-shot behavior (a single DELETE call) for scripts/CI that
58
+ // already call `ahood token revoke <id> --yes` unattended.
59
+ if (!yes) {
60
+ const confirmed = await confirm(`${await revokePrompt(id)} Type "yes" to confirm: `);
61
+ if (!confirmed) {
62
+ console.log("Aborted.");
63
+ return;
64
+ }
65
+ }
54
66
  await apiJson(`/api/v1/auth/tokens/${encodeURIComponent(id)}`, { method: "DELETE" });
55
67
  console.log(`Revoked ${id}`);
56
68
  }
69
+ // Looks up the token's name/prefix via the same list endpoint tokenList
70
+ // uses, so the confirmation prompt shows something a human recognizes
71
+ // (e.g. `Revoke token "ci-runner" (ahd_ab...)?`) instead of a bare opaque
72
+ // UUID copied from `ahood token list` output -- easy to mistype with
73
+ // nothing to catch it. The lookup is best-effort: if it fails for any
74
+ // reason (network hiccup, the id not being present in the list, ...) this
75
+ // falls back to the bare id rather than blocking the revoke on a lookup
76
+ // that isn't itself the destructive operation.
77
+ async function revokePrompt(id) {
78
+ try {
79
+ const { tokens } = await apiJson("/api/v1/auth/tokens");
80
+ const match = tokens.find((t) => t.id === id);
81
+ if (match)
82
+ return `Revoke token "${match.name}" (${match.token_prefix}...)?`;
83
+ }
84
+ catch {
85
+ // Fall through to the bare-id form below.
86
+ }
87
+ return `Revoke token ${id}?`;
88
+ }
@@ -1,32 +1,8 @@
1
- import { createInterface } from "node:readline";
2
1
  import { apiJson } from "../http.js";
2
+ import { confirm } from "../confirm.js";
3
3
  import { parseOwnerSkill, parseOwnerSkillVersion } from "../spec.js";
4
+ import { fetchVersionMeta } from "./add.js";
4
5
  const USAGE = "Usage: ahood unpublish <owner>/<skill>[@version] [--yes]";
5
- // On closed/non-interactive stdin, readline/promises' rl.question() promise
6
- // never settles -- but since nothing else keeps the event loop alive at that
7
- // point, the process used to exit 0 anyway once the loop drained, having
8
- // printed only the prompt (no "Aborted.", no deletion, no error: a silent
9
- // false success). The plain callback-style API is used instead specifically
10
- // so the "close" listener can be a reliable signal for "closed without ever
11
- // answering": `settled` is set synchronously inside the answer callback,
12
- // before rl.close() is called, so that same close doesn't self-reject.
13
- function confirm(promptText) {
14
- const rl = createInterface({ input: process.stdin, output: process.stdout });
15
- return new Promise((resolve, reject) => {
16
- let settled = false;
17
- rl.once("close", () => {
18
- if (settled)
19
- return;
20
- settled = true;
21
- reject(new Error("Confirmation was not answered (stdin closed)."));
22
- });
23
- rl.question(promptText, (answer) => {
24
- settled = true;
25
- rl.close();
26
- resolve(answer.trim().toLowerCase() === "yes");
27
- });
28
- });
29
- }
30
6
  // A bare "owner/skill" has no "@" at all, so lastIndexOf("@") is -1 and this
31
7
  // is a whole-skill unpublish (preserves the exact pre-existing behavior).
32
8
  // "owner/skill@1.2.3" has an "@" at index > 0 and is a single-version yank
@@ -42,7 +18,24 @@ export async function unpublish(args) {
42
18
  if (!spec)
43
19
  throw new Error(USAGE);
44
20
  if (isVersioned(spec)) {
45
- const { owner, skill, version } = parseOwnerSkillVersion(spec, USAGE);
21
+ const { owner, skill, version: parsedVersion } = parseOwnerSkillVersion(spec, USAGE);
22
+ // parseOwnerSkillVersion only validates *syntax* -- an omitted "@version"
23
+ // or an explicit "@latest" both parse to the literal string "latest".
24
+ // But the yank request below hits DELETE .../versions/{version}, which
25
+ // does an exact match against skill_versions.version (ahood-cli#62):
26
+ // there is no "latest" row there, only the download endpoint resolves
27
+ // that literal server-side. So "latest" has to be resolved to a real
28
+ // version string client-side, up front, or the DELETE just 404s with a
29
+ // generic "Not found" that gives no hint the problem is the word
30
+ // "latest". add.ts's fetchVersionMeta already solves exactly this (GET
31
+ // /api/v1/skills/{owner}/{skill}, whose response is joined against
32
+ // latest_version_id) for its own metadata lookup -- reused verbatim here
33
+ // rather than duplicating the resolution logic, and it already throws a
34
+ // clear "<owner>/<skill> has no published version" error for the
35
+ // no-versions-yet edge case instead of leaving that to a confusing 404.
36
+ // An explicit @1.2.3 skips this lookup entirely -- it's already a
37
+ // literal the DELETE endpoint understands, so no extra request is made.
38
+ const version = parsedVersion === "latest" ? (await fetchVersionMeta(owner, skill, "latest")).version : parsedVersion;
46
39
  const key = `${owner}/${skill}@${version}`;
47
40
  // Yanking is less destructive than a whole-skill delete (existing
48
41
  // lockfile-pinned installs keep resolving/verifying against it -- see
@@ -62,8 +55,18 @@ export async function unpublish(args) {
62
55
  // just scoped one level down to skill_versions.yanked_at/yanked_reason
63
56
  // instead of skills.deleted_at. Mirroring that existing verb/path
64
57
  // convention rather than inventing a new one.
65
- await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions/${encodeURIComponent(version)}`, { method: "DELETE" });
58
+ const result = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions/${encodeURIComponent(version)}`, { method: "DELETE" });
66
59
  console.log(`Yanked ${key}. Existing lockfile pins still resolve; new installs will be warned off it.`);
60
+ // yankVersion (lib/skills/mutations.ts) still returns ok:true here even
61
+ // when the yank itself succeeded but the latest_version_id recompute
62
+ // that follows it failed -- it attaches this field instead of failing
63
+ // the whole request, since the yank already took effect. Discarding the
64
+ // response body would silently drop that signal (ahood-cli#68): the
65
+ // pointer could now be stale with nothing in the terminal to say so, and
66
+ // a later `ahood add owner/skill` trusts latest_version_id completely.
67
+ if (result.latest_version_warning) {
68
+ console.warn(`WARNING: ${result.latest_version_warning}`);
69
+ }
67
70
  return;
68
71
  }
69
72
  const { owner, skill } = parseOwnerSkill(spec, USAGE);
@@ -86,9 +86,12 @@ export async function update(args) {
86
86
  }
87
87
  const failures = [];
88
88
  for (const ownerSlashSkill of skillKeys) {
89
- if (!lockfile[ownerSlashSkill] && targets.length === 0) {
90
- // Only reachable when targets came from the lockfile itself, so this
91
- // would mean the lockfile changed under us mid-loop -- skip, don't abort.
89
+ if (!lockfile[ownerSlashSkill]) {
90
+ // Reachable two ways: an explicitly-named skill that was never
91
+ // installed (`ahood add` is the right command for that -- `update`
92
+ // must not silently install and pin it), or -- when targets came from
93
+ // the lockfile itself -- the lockfile changing out from under us
94
+ // mid-loop. Either way: skip, don't abort.
92
95
  console.warn(`Skipping ${ownerSlashSkill}: not currently installed.`);
93
96
  continue;
94
97
  }
@@ -41,6 +41,13 @@ export async function view(args) {
41
41
  }
42
42
  const field = (label, value) => console.log(` ${label.padEnd(13)}${value}`);
43
43
  console.log(`${detail.owner}/${detail.slug}`);
44
+ // Mirrors the wording/tone of `ahood add`'s yanked warning (src/commands/add.ts)
45
+ // and the website's skill detail page banner -- this is the one CLI surface
46
+ // whose entire job is "let me check this out before deciding to install it",
47
+ // so it must not be the one place that stays silent about a yanked version.
48
+ if (detail.skill_versions?.yanked_at) {
49
+ console.warn(`WARNING: ${detail.owner}/${detail.slug}@${detail.skill_versions.version} has been yanked${detail.skill_versions.yanked_reason ? `: ${detail.skill_versions.yanked_reason}` : "."}`);
50
+ }
44
51
  field("name:", detail.name);
45
52
  if (detail.tagline)
46
53
  field("tagline:", detail.tagline);
@@ -0,0 +1,31 @@
1
+ import { createInterface } from "node:readline";
2
+ // On closed/non-interactive stdin, readline/promises' rl.question() promise
3
+ // never settles -- but since nothing else keeps the event loop alive at that
4
+ // point, the process used to exit 0 anyway once the loop drained, having
5
+ // printed only the prompt (no "Aborted.", no deletion, no error: a silent
6
+ // false success). The plain callback-style API is used instead specifically
7
+ // so the "close" listener can be a reliable signal for "closed without ever
8
+ // answering": `settled` is set synchronously inside the answer callback,
9
+ // before rl.close() is called, so that same close doesn't self-reject.
10
+ //
11
+ // Shared by any command that needs a "Type yes to confirm" prompt before a
12
+ // destructive/irreversible action (unpublish's whole-skill delete and
13
+ // per-version yank, token revoke, ...) so they all get the same TTY-safe
14
+ // behavior instead of each reimplementing it slightly differently.
15
+ export function confirm(promptText) {
16
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
17
+ return new Promise((resolve, reject) => {
18
+ let settled = false;
19
+ rl.once("close", () => {
20
+ if (settled)
21
+ return;
22
+ settled = true;
23
+ reject(new Error("Confirmation was not answered (stdin closed)."));
24
+ });
25
+ rl.question(promptText, (answer) => {
26
+ settled = true;
27
+ rl.close();
28
+ resolve(answer.trim().toLowerCase() === "yes");
29
+ });
30
+ });
31
+ }
package/dist/help.js CHANGED
@@ -124,10 +124,11 @@ export const COMMANDS_HELP = [
124
124
  ],
125
125
  },
126
126
  {
127
- usage: "ahood token create <name>|list [--json]|revoke <id>",
127
+ usage: "ahood token create <name>|list [--json]|revoke <id> [--yes]",
128
128
  summary: "Manage personal API tokens.",
129
129
  desc: "Manage personal API tokens. `create` requires an existing browser-backed session.",
130
- examples: ["ahood token create ci-runner", "ahood token list --json", "ahood token revoke <id>"],
130
+ flags: ["--yes (revoke only) Skip the confirmation prompt and revoke immediately."],
131
+ examples: ["ahood token create ci-runner", "ahood token list --json", "ahood token revoke <id>", "ahood token revoke <id> --yes"],
131
132
  },
132
133
  {
133
134
  usage: "ahood completion <bash|zsh|fish>",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahood/cli",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
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",
@@ -28,7 +28,8 @@
28
28
  "scripts": {
29
29
  "build": "tsc -p tsconfig.json",
30
30
  "prepublishOnly": "npm run build",
31
- "test": "vitest run"
31
+ "test": "vitest run",
32
+ "sync-readme": "node scripts/sync-readme.mjs"
32
33
  },
33
34
  "dependencies": {
34
35
  "tar-stream": "^3.2.1"