@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 +3 -2
- package/dist/commands/init.js +42 -2
- package/dist/commands/search.js +1 -1
- package/dist/commands/token.js +35 -3
- package/dist/commands/unpublish.js +20 -27
- package/dist/commands/update.js +6 -3
- package/dist/commands/versions.js +6 -0
- package/dist/commands/view.js +7 -0
- package/dist/confirm.js +31 -0
- package/dist/help.js +3 -2
- package/package.json +1 -1
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]` |
|
|
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
|
|
package/dist/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
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)) {
|
package/dist/commands/search.js
CHANGED
|
@@ -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("
|
|
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));
|
package/dist/commands/token.js
CHANGED
|
@@ -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
|
|
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
|
package/dist/commands/update.js
CHANGED
|
@@ -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]
|
|
90
|
-
//
|
|
91
|
-
//
|
|
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
|
}
|
package/dist/commands/view.js
CHANGED
|
@@ -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);
|
package/dist/confirm.js
ADDED
|
@@ -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
|
-
|
|
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