@ahood/cli 0.3.2 → 0.3.4

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
@@ -40,16 +40,17 @@ For CI or any non-interactive environment, set `AHOOD_TOKEN` instead of running
40
40
  | `ahood whoami [--json]` | Reports whether your stored token still authenticates. |
41
41
  | `ahood search <query> [--json] [--limit <n>]` | Search published skills. |
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
+ | `ahood versions <owner>/<skill> [--json]` | List a skill's published-version history -- version, changelog, size, and publish date. Most-recent first. |
43
44
  | `ahood list-mine [--json]` | List your own skills, public and private, with their visibility and download/star counts. |
44
45
  | `ahood add <owner>/<skill>[@version]` | Install into `.claude/skills/<owner>/<skill>/`, pinned in `.claude/skills.lock.json`. |
45
46
  | `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
47
  | `ahood remove <owner>/<skill>` | Uninstall and unpin (local only -- does not affect the published skill). |
47
48
  | `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. |
49
+ | `ahood unpublish <owner>/<skill>[@version] [--yes]` | Without `@version`, deletes the skill from the registry for every consumer, not just your local install. With `@version`, yanks just that version instead of deleting the whole skill -- it's marked yanked, not removed; existing lockfile pins still resolve, but new installs are warned off it. Prompts for a typed "yes" confirmation, or pass `--yes` for scripts/CI. |
49
50
  | `ahood star <owner>/<skill>` | Star a skill. |
50
51
  | `ahood unstar <owner>/<skill>` | Remove your star from a skill. |
51
52
  | `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 publish <owner>/<skill>@<version> [--path <dir>] [--name] [--tagline] [--tags] [--license] [--homepage] [--repository] [--json]` | 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. `--json` suppresses human progress lines and prints one `{version,status,...}` JSON object on completion instead. The legacy form `ahood publish <path> --owner <owner> --slug <skill> --version <x.y.z>` is still accepted. |
53
54
  | `ahood token create <name>\|list [--json]\|revoke <id>` | Manage personal API tokens. Creating a token requires an existing logged-in session -- see the docs. |
54
55
  | `ahood completion <bash\|zsh\|fish>` | Print a shell completion script for the command names. |
55
56
 
@@ -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
@@ -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
  }
@@ -36,5 +36,11 @@ export async function versions(args) {
36
36
  field("published:", v.created_at);
37
37
  field("size:", formatSize(v.package_size_bytes));
38
38
  field("changelog:", v.changelog_md ? v.changelog_md : "-");
39
+ // Marked right after the version's other details, only when the API
40
+ // actually flags it (issue #58) -- non-yanked versions print exactly as
41
+ // before, no "status:" line at all.
42
+ if (v.yanked_at) {
43
+ field("status:", v.yanked_reason ? `YANKED -- ${v.yanked_reason}` : "YANKED");
44
+ }
39
45
  }
40
46
  }
@@ -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.2",
3
+ "version": "0.3.4",
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",