@ahood/cli 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ahood
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # ahood
2
+
3
+ CLI for [ahood](https://ahood.vercel.app), a registry for installing and publishing Claude Code skills.
4
+
5
+ ## Quick start
6
+
7
+ ```
8
+ npx @alexkay/ahood-cli@latest login
9
+ npx @alexkay/ahood-cli@latest search <something>
10
+ npx @alexkay/ahood-cli@latest add <owner>/<skill>
11
+ ```
12
+
13
+ ## Install
14
+
15
+ ```
16
+ npx @alexkay/ahood-cli@latest <command>
17
+ ```
18
+
19
+ or install it globally, so the plain `ahood` command works without `npx`:
20
+
21
+ ```
22
+ npm i -g @alexkay/ahood-cli
23
+ ahood <command>
24
+ ```
25
+
26
+ For CI or any non-interactive environment, set `AHOOD_TOKEN` instead of running `login` -- every command checks it first.
27
+
28
+ ## Commands
29
+
30
+ <!-- Kept in sync by hand with cli/src/help.ts's COMMANDS_HELP (the terminal
31
+ --help output's source) and app/docs/page.tsx's COMMANDS array -- update
32
+ all three when a command changes. -->
33
+
34
+ | Command | What it does |
35
+ | --- | --- |
36
+ | `ahood login` | Device-code browser login, stores a token at `~/.config/ahood/credentials.json`. |
37
+ | `ahood logout` | Removes the stored token. |
38
+ | `ahood whoami` | Reports whether your stored token still authenticates. |
39
+ | `ahood search <query>` | Search published skills. |
40
+ | `ahood list-mine` | List your own skills, public and private, with their visibility and download/star counts. |
41
+ | `ahood add <owner>/<skill>[@version]` | Install into `.claude/skills/<owner>/<skill>/`, pinned in `.claude/skills.lock.json`. |
42
+ | `ahood update [<owner>/<skill>]` | Move the lockfile pin forward to the latest version. |
43
+ | `ahood remove <owner>/<skill>` | Uninstall and unpin (local only -- does not affect the published skill). |
44
+ | `ahood edit <owner>/<skill> [--tagline] [--tags] [--license] [--visibility]` | Update a skill you own. Only the flags you pass are changed; requires the `publish` scope. |
45
+ | `ahood unpublish <owner>/<skill>` | Deletes the skill from the registry for every consumer, not just your local install -- prompts for a typed "yes" confirmation first. |
46
+ | `ahood star <owner>/<skill>` | Star a skill. |
47
+ | `ahood unstar <owner>/<skill>` | Remove your star from a skill. |
48
+ | `ahood publish <path> --owner <owner> --slug <skill> --version <x.y.z>` | Publish a new version of an existing skill from a folder containing `SKILL.md`. The skill itself is created from the web UI first. |
49
+ | `ahood token create\|list\|revoke` | Manage personal API tokens. Creating a token requires an existing logged-in session -- see the docs. |
50
+
51
+ Run `ahood help` or `ahood <command> --help` for the same reference directly in your terminal.
52
+
53
+ Full reference, including personal API tokens, CI usage, the public REST API, and the MCP server: **https://ahood.vercel.app/docs**
@@ -0,0 +1,120 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { join, resolve, sep } from "node:path";
4
+ import * as tarStream from "tar-stream";
5
+ import { gunzipSync } from "node:zlib";
6
+ import { apiFetch, apiJson } from "../http.js";
7
+ import { writeLockfileEntry } from "../lockfile.js";
8
+ // GET /api/v1/skills/{owner}/{skill}/versions/{version} matches the version
9
+ // string with an exact .eq() (verified live against this branch's route) --
10
+ // there is no "latest" literal in skill_versions.version, so requesting
11
+ // versions/latest 404s. Only the download endpoint has "latest" resolution
12
+ // built in. For metadata (which we need up front, to verify the checksum
13
+ // *before* trusting the download), we instead resolve "latest" through
14
+ // GET /api/v1/skills/{owner}/{skill}, whose `skill_versions` field is already
15
+ // joined against the skill's latest_version_id. That response has no
16
+ // yanked_at (latest_version_id isn't expected to ever point at a yanked
17
+ // version in this codebase), so the yanked-skill warning below only fires
18
+ // for an explicit @version -- confirmed by reading both route.ts files
19
+ // rather than assumed.
20
+ async function fetchVersionMeta(owner, skill, version) {
21
+ if (version === "latest") {
22
+ const { skill_versions } = await apiJson(`/api/v1/skills/${owner}/${skill}`);
23
+ if (!skill_versions)
24
+ throw new Error(`${owner}/${skill} has no published version`);
25
+ return { ...skill_versions, yanked_at: null };
26
+ }
27
+ return apiJson(`/api/v1/skills/${owner}/${skill}/versions/${version}`);
28
+ }
29
+ function parseSpec(spec) {
30
+ const atIndex = spec.lastIndexOf("@");
31
+ const ownerSkill = atIndex > 0 ? spec.slice(0, atIndex) : spec;
32
+ const version = atIndex > 0 ? spec.slice(atIndex + 1) : "latest";
33
+ const [owner, skill] = ownerSkill.split("/");
34
+ if (!owner || !skill)
35
+ throw new Error("Usage: ahood add <owner>/<skill>[@version]");
36
+ return { owner, skill, version };
37
+ }
38
+ export async function extractTarGz(buffer, destDir) {
39
+ const tarBuffer = gunzipSync(buffer);
40
+ const extract = tarStream.extract();
41
+ const resolvedDest = resolve(destDir);
42
+ await new Promise((resolvePromise, reject) => {
43
+ extract.on("entry", (header, stream, next) => {
44
+ // Path containment, checked BEFORE anything is written. A tar entry
45
+ // name is attacker-controlled data in an archive we merely downloaded
46
+ // -- the server's publish-time validateEntries is supposed to have
47
+ // rejected `..` already, but a client that extracts a remote archive
48
+ // must not depend on a check it cannot see. resolve() collapses `..`
49
+ // segments (and, on Windows, drive-absolute and backslash-separated
50
+ // names) so the comparison catches every escape shape, not just a
51
+ // literal leading "../".
52
+ const entryPath = header.name.replace(/^\.\//, "");
53
+ const fullPath = resolve(destDir, entryPath);
54
+ if (fullPath !== resolvedDest && !fullPath.startsWith(resolvedDest + sep)) {
55
+ stream.resume();
56
+ reject(new Error(`Refusing to extract unsafe archive entry: ${header.name}`));
57
+ // next() is deliberately NOT called -- nothing further in this archive
58
+ // should be processed. destroy() tears the paused extractor down
59
+ // rather than leaving it stalled mid-entry.
60
+ extract.destroy();
61
+ return;
62
+ }
63
+ if (header.type !== "file") {
64
+ stream.resume();
65
+ next();
66
+ return;
67
+ }
68
+ const chunks = [];
69
+ stream.on("data", (chunk) => chunks.push(chunk));
70
+ stream.on("end", () => {
71
+ mkdirSync(join(fullPath, ".."), { recursive: true });
72
+ writeFileSync(fullPath, Buffer.concat(chunks));
73
+ next();
74
+ });
75
+ stream.on("error", reject);
76
+ });
77
+ extract.on("finish", () => resolvePromise());
78
+ extract.on("error", reject);
79
+ extract.end(tarBuffer);
80
+ });
81
+ }
82
+ export async function add(args) {
83
+ const spec = args[0];
84
+ if (!spec)
85
+ throw new Error("Usage: ahood add <owner>/<skill>[@version]");
86
+ const { owner, skill, version: requestedVersion } = parseSpec(spec);
87
+ const meta = await fetchVersionMeta(owner, skill, requestedVersion);
88
+ if (meta.yanked_at) {
89
+ console.warn(`WARNING: ${owner}/${skill}@${meta.version} has been yanked. Installing anyway.`);
90
+ }
91
+ // scripts/ warning: mirrors the web detail page's banner (platform ADR's
92
+ // Open Risk #7) -- a CLI-only user installing via `add` would otherwise
93
+ // never see this at all.
94
+ if (meta.manifest.some((f) => f.path.startsWith("scripts/"))) {
95
+ console.warn("WARNING: this skill includes a scripts/ directory. Review its contents before use.");
96
+ }
97
+ const downloadRes = await apiFetch(`/api/v1/skills/${owner}/${skill}/download?version=${meta.version}`, {
98
+ headers: { "X-Ahood-Source": "cli" },
99
+ redirect: "follow",
100
+ });
101
+ if (!downloadRes.ok)
102
+ throw new Error(`Download failed with status ${downloadRes.status}`);
103
+ const buffer = Buffer.from(await downloadRes.arrayBuffer());
104
+ const actualChecksum = createHash("sha256").update(buffer).digest("hex");
105
+ if (actualChecksum !== meta.checksum_sha256) {
106
+ throw new Error(`Checksum mismatch for ${owner}/${skill}@${meta.version}: expected ${meta.checksum_sha256}, got ${actualChecksum}. Refusing to install.`);
107
+ }
108
+ // Owner-namespaced on disk, mirroring npm's node_modules/@scope/package.
109
+ // Keyed on the slug alone, alice/utils and bob/utils were the same
110
+ // directory: installing one silently overwrote the other's files, and
111
+ // `remove` deleted whichever happened to be there. The lockfile key is
112
+ // already "owner/skill" and needs no change.
113
+ const destDir = join(".claude", "skills", owner, skill);
114
+ await extractTarGz(buffer, destDir);
115
+ writeLockfileEntry(join(".claude", "skills.lock.json"), `${owner}/${skill}`, {
116
+ version: meta.version,
117
+ checksum_sha256: meta.checksum_sha256,
118
+ });
119
+ console.log(`Installed ${owner}/${skill}@${meta.version} to ${destDir}`);
120
+ }
@@ -0,0 +1,41 @@
1
+ import { apiJson } from "../http.js";
2
+ function flagValue(args, flag) {
3
+ const index = args.indexOf(flag);
4
+ return index >= 0 ? args[index + 1] : undefined;
5
+ }
6
+ // Mirrors PATCH /api/v1/skills/{owner}/{skill}'s allow-list exactly
7
+ // (lib/skills/mutations.ts's UpdateSkillInput) -- only a field the caller
8
+ // explicitly passed a flag for is included in the body, so an omitted flag
9
+ // never clobbers an existing value (the route only validates/writes keys
10
+ // that are present on the body at all).
11
+ export async function edit(args) {
12
+ const spec = args[0];
13
+ if (!spec || spec.startsWith("--")) {
14
+ throw new Error("Usage: ahood edit <owner>/<skill> [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--visibility public|private]");
15
+ }
16
+ const [owner, skill] = spec.split("/");
17
+ if (!owner || !skill)
18
+ throw new Error("Usage: ahood edit <owner>/<skill> [--tagline ...] [--tags ...] [--license ...] [--visibility ...]");
19
+ const body = {};
20
+ const tagline = flagValue(args, "--tagline");
21
+ if (tagline !== undefined)
22
+ body.tagline = tagline;
23
+ const tags = flagValue(args, "--tags");
24
+ if (tags !== undefined)
25
+ body.tags = tags.split(",").map((t) => t.trim()).filter(Boolean);
26
+ const license = flagValue(args, "--license");
27
+ if (license !== undefined)
28
+ body.license = license;
29
+ const visibility = flagValue(args, "--visibility");
30
+ if (visibility !== undefined)
31
+ body.visibility = visibility;
32
+ if (Object.keys(body).length === 0) {
33
+ throw new Error("Nothing to update -- pass at least one of --tagline, --tags, --license, --visibility.");
34
+ }
35
+ const updated = await apiJson(`/api/v1/skills/${owner}/${skill}`, {
36
+ method: "PATCH",
37
+ headers: { "Content-Type": "application/json" },
38
+ body: JSON.stringify(body),
39
+ });
40
+ console.log(`Updated ${owner}/${updated.slug}: tagline=${JSON.stringify(updated.tagline)}, license=${JSON.stringify(updated.license)}, visibility=${updated.visibility}, tags=[${updated.tags.join(", ")}]`);
41
+ }
@@ -0,0 +1,11 @@
1
+ import { apiJson } from "../http.js";
2
+ export async function listMine() {
3
+ const { skills } = await apiJson("/api/v1/skills?mine=true");
4
+ if (skills.length === 0) {
5
+ console.log("You haven't published any skills yet.");
6
+ return;
7
+ }
8
+ for (const skill of skills) {
9
+ console.log(`${skill.slug} (${skill.visibility}) — ${skill.name}${skill.tagline ? `: ${skill.tagline}` : ""} (${skill.downloads_count} downloads, ${skill.stars_count} stars)`);
10
+ }
11
+ }
@@ -0,0 +1,42 @@
1
+ import { apiJson } from "../http.js";
2
+ import { writeCredentials } from "../credentials.js";
3
+ function sleep(ms) {
4
+ return new Promise((resolve) => setTimeout(resolve, ms));
5
+ }
6
+ export async function login() {
7
+ const { code, verification_url, expires_in } = await apiJson("/api/v1/auth/cli/device", {
8
+ method: "POST",
9
+ });
10
+ console.log(`First, confirm this code matches what you see in your browser: ${code}`);
11
+ console.log(`Open ${verification_url} to approve.`);
12
+ const deadline = Date.now() + expires_in * 1000;
13
+ while (Date.now() < deadline) {
14
+ await sleep(2000);
15
+ let res;
16
+ let body;
17
+ try {
18
+ res = await fetch(`${verification_url.split("?")[0].replace("/cli-auth", "")}/api/v1/auth/cli/device/${code}`);
19
+ body = await res.json();
20
+ }
21
+ catch (error) {
22
+ // A THROWN fetch (DNS blip, dropped socket, a body that isn't JSON) is
23
+ // transient by nature, and this loop runs for up to ten minutes while a
24
+ // human walks to their browser -- one bad network moment must not kill
25
+ // a login that is about to succeed. HTTP *statuses* are still decided
26
+ // below; only the transport failure is retried. The deadline is
27
+ // untouched, so this cannot loop forever.
28
+ console.error(`Polling failed (${error instanceof Error ? error.message : String(error)}); retrying...`);
29
+ continue;
30
+ }
31
+ if (res.status === 200 && body.status === "approved" && body.token) {
32
+ writeCredentials({ token: body.token });
33
+ console.log("Logged in.");
34
+ return;
35
+ }
36
+ if (res.status === 410 || res.status === 404) {
37
+ throw new Error("This login was cancelled or expired. Run `ahood login` again.");
38
+ }
39
+ // status === "pending" -- keep polling.
40
+ }
41
+ throw new Error("Login timed out. Run `ahood login` again.");
42
+ }
@@ -0,0 +1,5 @@
1
+ import { clearCredentials } from "../credentials.js";
2
+ export async function logout() {
3
+ clearCredentials();
4
+ console.log("Logged out.");
5
+ }
@@ -0,0 +1,104 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { pack } from "tar-stream";
4
+ import { createGzip } from "node:zlib";
5
+ import { apiJson } from "../http.js";
6
+ // Matched by ENTRY NAME at every depth, not by path prefix, so a nested
7
+ // `vendor/thing/.git` is skipped the same as a top-level one. Not a
8
+ // .gitignore parser (out of scope) -- just the fixed set of names that must
9
+ // never end up in a published archive:
10
+ // .git -- .git/config routinely carries a remote URL with embedded
11
+ // credentials (https://user:ghp_xxx@github.com/...), which the
12
+ // server-side secret scanner's regexes (AKIA, PEM headers,
13
+ // 32+ hex) do not match.
14
+ // node_modules -- never part of a skill, and megabytes of it.
15
+ // .env / .env.* -- the single most likely place a real secret lives.
16
+ // .DS_Store -- noise.
17
+ const EXCLUDED_NAMES = new Set([".git", "node_modules", ".DS_Store"]);
18
+ function isExcluded(name) {
19
+ if (EXCLUDED_NAMES.has(name))
20
+ return true;
21
+ if (name === ".env" || name.startsWith(".env."))
22
+ return true;
23
+ return false;
24
+ }
25
+ async function tarGzDirectory(dir) {
26
+ const { readdirSync, statSync } = await import("node:fs");
27
+ const tar = pack();
28
+ const gzip = createGzip();
29
+ const chunks = [];
30
+ function addDir(current, prefix) {
31
+ for (const entry of readdirSync(current)) {
32
+ if (isExcluded(entry))
33
+ continue;
34
+ const fullPath = join(current, entry);
35
+ const relPath = prefix ? `${prefix}/${entry}` : entry;
36
+ const stat = statSync(fullPath);
37
+ if (stat.isDirectory()) {
38
+ addDir(fullPath, relPath);
39
+ }
40
+ else {
41
+ tar.entry({ name: relPath, size: stat.size }, readFileSync(fullPath));
42
+ }
43
+ }
44
+ }
45
+ addDir(dir, "");
46
+ tar.finalize();
47
+ return new Promise((resolve, reject) => {
48
+ gzip.on("data", (chunk) => chunks.push(chunk));
49
+ gzip.on("end", () => resolve(Buffer.concat(chunks)));
50
+ gzip.on("error", reject);
51
+ tar.pipe(gzip);
52
+ });
53
+ }
54
+ export async function publish(args) {
55
+ const path = args[0] ?? ".";
56
+ const skillMdPath = join(path, "SKILL.md");
57
+ if (!existsSync(skillMdPath)) {
58
+ throw new Error(`No SKILL.md found at ${skillMdPath} -- publish must point at a skill folder's root.`);
59
+ }
60
+ // A real ahood CLI would parse owner/slug/version/name out of SKILL.md
61
+ // frontmatter or a companion manifest -- this MVP takes them as explicit
62
+ // flags, matching how `versions/init`'s API itself requires them
63
+ // separately from the archive. Kept simple: --slug, --owner, --version are
64
+ // required; metadata creation (POST /skills) is assumed already done via
65
+ // the web UI or a prior publish -- this command only pushes a NEW VERSION
66
+ // of an existing skill, matching the ADR's "publish tars, calls
67
+ // versions/init -> upload -> versions/complete" description exactly (it
68
+ // does not create the skill's top-level metadata row).
69
+ const ownerIndex = args.indexOf("--owner");
70
+ const slugIndex = args.indexOf("--slug");
71
+ const versionIndex = args.indexOf("--version");
72
+ const owner = ownerIndex >= 0 ? args[ownerIndex + 1] : undefined;
73
+ const slug = slugIndex >= 0 ? args[slugIndex + 1] : undefined;
74
+ const version = versionIndex >= 0 ? args[versionIndex + 1] : undefined;
75
+ if (!owner || !slug || !version) {
76
+ throw new Error("Usage: ahood publish <path> --owner <owner> --slug <skill> --version <x.y.z>");
77
+ }
78
+ const archive = await tarGzDirectory(path);
79
+ const init = await apiJson(`/api/v1/skills/${owner}/${slug}/versions/init`, {
80
+ method: "POST",
81
+ headers: { "Content-Type": "application/json" },
82
+ body: JSON.stringify({ version, package_size_bytes: archive.length }),
83
+ });
84
+ // TS's lib.dom BodyInit (in scope here since tsconfig has no explicit
85
+ // "lib" override, so DOM is included alongside the Node types) type-checks
86
+ // Buffer/Uint8Array against `Uint8Array<ArrayBuffer>` specifically as of
87
+ // TS 5.7+'s ArrayBufferLike generics, which a Node Buffer's
88
+ // `Uint8Array<ArrayBufferLike>` doesn't structurally satisfy even though
89
+ // it is a valid BufferSource at runtime (this is exactly what Node's own
90
+ // fetch/undici accepts) -- see undici-types' BodyInit, which includes
91
+ // `NodeJS.ArrayBufferView` (i.e. Buffer) directly with no such
92
+ // restriction. Asserting through BodyInit here, rather than reshaping
93
+ // tsconfig's "lib" for the whole package, keeps this fix local to the one
94
+ // call site.
95
+ const putRes = await fetch(init.upload_url, { method: "PUT", body: archive });
96
+ if (!putRes.ok)
97
+ throw new Error(`Upload failed with status ${putRes.status}`);
98
+ const complete = await apiJson(`/api/v1/skills/${owner}/${slug}/versions/complete`, {
99
+ method: "POST",
100
+ headers: { "Content-Type": "application/json" },
101
+ body: JSON.stringify({ version_id: init.version_id }),
102
+ });
103
+ console.log(`Published ${owner}/${slug}@${complete.version} (${complete.status})`);
104
+ }
@@ -0,0 +1,25 @@
1
+ import { rmSync, rmdirSync, readdirSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { removeLockfileEntry } from "../lockfile.js";
4
+ export async function remove(args) {
5
+ const spec = args[0];
6
+ if (!spec)
7
+ throw new Error("Usage: ahood remove <owner>/<skill>");
8
+ const [owner, skill] = spec.split("/");
9
+ if (!owner || !skill)
10
+ throw new Error("Usage: ahood remove <owner>/<skill>");
11
+ // Must mirror add.ts's owner-namespaced destDir exactly -- keyed on the
12
+ // slug alone, `remove bob/utils` deleted alice/utils' files.
13
+ const dir = join(".claude", "skills", owner, skill);
14
+ if (existsSync(dir))
15
+ rmSync(dir, { recursive: true, force: true });
16
+ // Sweep the owner directory once it holds nothing, so uninstalling an
17
+ // owner's last skill doesn't leave an empty namespace folder behind (npm
18
+ // does the same with node_modules/@scope). Guarded on emptiness, so another
19
+ // skill by the same owner is never touched.
20
+ const ownerDir = join(".claude", "skills", owner);
21
+ if (existsSync(ownerDir) && readdirSync(ownerDir).length === 0)
22
+ rmdirSync(ownerDir);
23
+ removeLockfileEntry(join(".claude", "skills.lock.json"), spec);
24
+ console.log(`Removed ${spec}`);
25
+ }
@@ -0,0 +1,14 @@
1
+ import { apiJson } from "../http.js";
2
+ export async function search(args) {
3
+ const query = args.join(" ");
4
+ if (!query)
5
+ throw new Error("Usage: ahood search <query>");
6
+ const { skills } = await apiJson(`/api/v1/skills?q=${encodeURIComponent(query)}`);
7
+ if (skills.length === 0) {
8
+ console.log("No skills found.");
9
+ return;
10
+ }
11
+ for (const skill of skills) {
12
+ console.log(`${skill.profiles.username}/${skill.slug} — ${skill.name}${skill.tagline ? `: ${skill.tagline}` : ""} (${skill.downloads_count} downloads)`);
13
+ }
14
+ }
@@ -0,0 +1,26 @@
1
+ import { apiJson } from "../http.js";
2
+ // Mirrors POST/DELETE /api/v1/skills/{owner}/{skill}/star -- both are
3
+ // idempotent server-side (POST upserts on the stars table's
4
+ // unique(skill_id, user_id) constraint with ignoreDuplicates, DELETE just
5
+ // deletes any matching row), so starring an already-starred skill or
6
+ // unstarring one you never starred is a no-op 200, not an error.
7
+ export async function star(args) {
8
+ const spec = args[0];
9
+ if (!spec)
10
+ throw new Error("Usage: ahood star <owner>/<skill>");
11
+ const [owner, skill] = spec.split("/");
12
+ if (!owner || !skill)
13
+ throw new Error("Usage: ahood star <owner>/<skill>");
14
+ await apiJson(`/api/v1/skills/${owner}/${skill}/star`, { method: "POST" });
15
+ console.log(`Starred ${owner}/${skill}.`);
16
+ }
17
+ export async function unstar(args) {
18
+ const spec = args[0];
19
+ if (!spec)
20
+ throw new Error("Usage: ahood unstar <owner>/<skill>");
21
+ const [owner, skill] = spec.split("/");
22
+ if (!owner || !skill)
23
+ throw new Error("Usage: ahood unstar <owner>/<skill>");
24
+ await apiJson(`/api/v1/skills/${owner}/${skill}/star`, { method: "DELETE" });
25
+ console.log(`Unstarred ${owner}/${skill}.`);
26
+ }
@@ -0,0 +1,51 @@
1
+ import { apiJson } from "../http.js";
2
+ export async function token(args) {
3
+ const [subcommand, ...rest] = args;
4
+ switch (subcommand) {
5
+ case "create":
6
+ return tokenCreate(rest);
7
+ case "list":
8
+ return tokenList();
9
+ case "revoke":
10
+ return tokenRevoke(rest);
11
+ default:
12
+ throw new Error("Usage: ahood token create|list|revoke");
13
+ }
14
+ }
15
+ async function tokenCreate(args) {
16
+ const name = args[0];
17
+ if (!name)
18
+ throw new Error("Usage: ahood token create <name>");
19
+ // Note: this itself requires an existing session-backed token or a
20
+ // browser login -- per docs/adr/backend/0001-backend-services.md's Phase
21
+ // 3 section, tokens can't mint tokens (POST /auth/tokens is session-only,
22
+ // enforced server-side in Task 3). A CLI-only user with no browser access
23
+ // at all cannot bootstrap their very first token through this command;
24
+ // `ahood login`'s device-code flow is the only bootstrap path, which
25
+ // is by design -- see the ADR's "login: device-code flow" as the sole
26
+ // credential-issuing entry point for a CLI-only session.
27
+ const result = await apiJson("/api/v1/auth/tokens", {
28
+ method: "POST",
29
+ headers: { "Content-Type": "application/json" },
30
+ body: JSON.stringify({ name }),
31
+ });
32
+ console.log(`Created token "${result.name}": ${result.token}`);
33
+ console.log("Copy this now -- it will not be shown again.");
34
+ }
35
+ async function tokenList() {
36
+ const { tokens } = await apiJson("/api/v1/auth/tokens");
37
+ if (tokens.length === 0) {
38
+ console.log("No tokens.");
39
+ return;
40
+ }
41
+ for (const t of tokens) {
42
+ console.log(`${t.id} ${t.name} ${t.token_prefix}… ${t.scopes.join(",")}${t.revoked_at ? " (revoked)" : ""}`);
43
+ }
44
+ }
45
+ async function tokenRevoke(args) {
46
+ const id = args[0];
47
+ if (!id)
48
+ throw new Error("Usage: ahood token revoke <id>");
49
+ await apiJson(`/api/v1/auth/tokens/${id}`, { method: "DELETE" });
50
+ console.log(`Revoked ${id}`);
51
+ }
@@ -0,0 +1,32 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { apiJson } from "../http.js";
3
+ // Confirmation is required and has no --yes/--force bypass -- this deletes
4
+ // the skill for every consumer who has ever `ahood add`ed it, not just the
5
+ // local install (that's `ahood remove`). If a non-interactive/CI use case
6
+ // shows up later, add an explicit --yes flag then; defaulting to one now
7
+ // would make the destructive path the easy one.
8
+ async function confirm(promptText) {
9
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
10
+ try {
11
+ const answer = await rl.question(promptText);
12
+ return answer.trim().toLowerCase() === "yes";
13
+ }
14
+ finally {
15
+ rl.close();
16
+ }
17
+ }
18
+ export async function unpublish(args) {
19
+ const spec = args[0];
20
+ if (!spec)
21
+ throw new Error("Usage: ahood unpublish <owner>/<skill>");
22
+ const [owner, skill] = spec.split("/");
23
+ if (!owner || !skill)
24
+ throw new Error("Usage: ahood unpublish <owner>/<skill>");
25
+ const confirmed = await confirm(`This will permanently delete ${owner}/${skill} for everyone who has installed it. Type "yes" to confirm: `);
26
+ if (!confirmed) {
27
+ console.log("Aborted.");
28
+ return;
29
+ }
30
+ await apiJson(`/api/v1/skills/${owner}/${skill}`, { method: "DELETE" });
31
+ console.log(`Unpublished ${owner}/${skill}. Run \`ahood remove ${owner}/${skill}\` to also remove your local copy.`);
32
+ }
@@ -0,0 +1,20 @@
1
+ import { readLockfile } from "../lockfile.js";
2
+ import { add } from "./add.js";
3
+ // `update` is the only command that moves the lockfile pin forward -- `add`
4
+ // always resolves and pins whatever version it's given (or latest, once,
5
+ // at install time), matching the ADR's "reproducible by default" design.
6
+ export async function update(args) {
7
+ const lockfile = readLockfile(".claude/skills.lock.json");
8
+ const targets = args.length > 0 ? args : Object.keys(lockfile);
9
+ if (targets.length === 0) {
10
+ console.log("No installed skills to update.");
11
+ return;
12
+ }
13
+ for (const ownerSlashSkill of targets) {
14
+ if (!lockfile[ownerSlashSkill]) {
15
+ console.warn(`Skipping ${ownerSlashSkill}: not currently installed.`);
16
+ continue;
17
+ }
18
+ await add([ownerSlashSkill]); // no @version -- resolves to latest again
19
+ }
20
+ }
@@ -0,0 +1,36 @@
1
+ import { ApiError, apiJson } from "../http.js";
2
+ import { resolveToken } from "../credentials.js";
3
+ export async function whoami() {
4
+ const token = resolveToken();
5
+ if (!token) {
6
+ console.log("Not logged in. Run `ahood login`.");
7
+ return;
8
+ }
9
+ // There is no endpoint that returns an identity for a bearer caller yet, so
10
+ // whoami can only answer "does this token still authenticate?". It probes
11
+ // /api/v1/auth/tokens, which is deliberately session-only (Task 3), and
12
+ // reads the STATUS to tell the two failure modes apart -- swallowing every
13
+ // error and printing "Authenticated." unconditionally (as this did) meant a
14
+ // revoked token and a garbage token both reported success with exit code 0.
15
+ // 403 -> resolveCaller accepted the token, the route then rejected it for
16
+ // being a token rather than a session. The token is valid.
17
+ // 401 -> resolveCaller could not resolve the token at all: unknown,
18
+ // revoked, or expired.
19
+ try {
20
+ await apiJson("/api/v1/auth/tokens");
21
+ // A session-backed caller (only reachable if this ever runs against a
22
+ // cookie-bearing client) -- the token list came back.
23
+ console.log("Authenticated.");
24
+ }
25
+ catch (error) {
26
+ if (error instanceof ApiError && error.status === 403) {
27
+ console.log("Authenticated with a personal API token.");
28
+ return;
29
+ }
30
+ // 401 and anything else (network failure, 5xx) are both "we could not
31
+ // confirm this token works" -- exit non-zero so `ahood whoami` is
32
+ // usable as a scriptable auth check.
33
+ console.error("Not authenticated -- your token is invalid or has been revoked.");
34
+ process.exitCode = 1;
35
+ }
36
+ }
package/dist/config.js ADDED
@@ -0,0 +1,7 @@
1
+ const DEFAULT_API_URL = "https://ahood.vercel.app";
2
+ export function getApiUrl() {
3
+ const configured = process.env.AHOOD_API_URL;
4
+ if (!configured)
5
+ return DEFAULT_API_URL;
6
+ return configured.endsWith("/") ? configured.slice(0, -1) : configured;
7
+ }
@@ -0,0 +1,43 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, chmodSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ function credentialsPath() {
5
+ return join(homedir(), ".config", "ahood", "credentials.json");
6
+ }
7
+ export function readCredentials() {
8
+ const path = credentialsPath();
9
+ if (!existsSync(path))
10
+ return null;
11
+ try {
12
+ return JSON.parse(readFileSync(path, "utf-8"));
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ export function writeCredentials(creds) {
19
+ const path = credentialsPath();
20
+ const dir = join(homedir(), ".config", "ahood");
21
+ // The `mode` option on mkdirSync/writeFileSync only applies when the
22
+ // directory/file is actually created (POSIX O_CREAT semantics) -- if
23
+ // either already exists with looser permissions (manual chmod, a restored
24
+ // backup, etc.), `mode` is silently ignored and the looser bits persist.
25
+ // chmodSync is unconditional, so every write re-asserts 0700/0600
26
+ // regardless of what was there before.
27
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
28
+ chmodSync(dir, 0o700);
29
+ writeFileSync(path, JSON.stringify(creds, null, 2), { mode: 0o600 });
30
+ chmodSync(path, 0o600);
31
+ }
32
+ export function clearCredentials() {
33
+ const path = credentialsPath();
34
+ if (existsSync(path))
35
+ unlinkSync(path);
36
+ }
37
+ // AHOOD_TOKEN always wins -- this is what lets CI use a token without
38
+ // ever running the interactive device-code `login` flow.
39
+ export function resolveToken() {
40
+ if (process.env.AHOOD_TOKEN)
41
+ return process.env.AHOOD_TOKEN;
42
+ return readCredentials()?.token ?? null;
43
+ }
package/dist/help.js ADDED
@@ -0,0 +1,43 @@
1
+ export const COMMANDS_HELP = [
2
+ { usage: "ahood login", desc: "Device-code browser login, stores a token locally." },
3
+ { usage: "ahood logout", desc: "Removes the stored token." },
4
+ { usage: "ahood whoami", desc: "Reports whether your stored token still authenticates." },
5
+ { usage: "ahood search <query>", desc: "Search published skills." },
6
+ { usage: "ahood list-mine", desc: "List your own skills, public and private." },
7
+ { usage: "ahood add <owner>/<skill>[@version]", desc: "Install a skill into .claude/skills/, pinned in the lockfile." },
8
+ { usage: "ahood update [<owner>/<skill>]", desc: "Move the lockfile pin forward to the latest version." },
9
+ { usage: "ahood remove <owner>/<skill>", desc: "Uninstall and unpin (local only)." },
10
+ {
11
+ usage: "ahood edit <owner>/<skill> [--tagline] [--tags] [--license] [--visibility]",
12
+ desc: "Update a skill you own.",
13
+ },
14
+ { usage: "ahood unpublish <owner>/<skill>", desc: "Delete a skill from the registry (asks for confirmation)." },
15
+ { usage: "ahood star <owner>/<skill>", desc: "Star a skill." },
16
+ { usage: "ahood unstar <owner>/<skill>", desc: "Remove your star from a skill." },
17
+ {
18
+ usage: "ahood publish <path> --owner <owner> --slug <skill> --version <x.y.z>",
19
+ desc: "Publish a new version of an existing skill.",
20
+ },
21
+ { usage: "ahood token create|list|revoke", desc: "Manage personal API tokens (requires a browser session)." },
22
+ ];
23
+ export function findCommandHelp(command) {
24
+ return COMMANDS_HELP.find((c) => c.usage === command || c.usage.startsWith(`ahood ${command} `) || c.usage === `ahood ${command}`);
25
+ }
26
+ export function formatHelp() {
27
+ const width = Math.max(...COMMANDS_HELP.map((c) => c.usage.length));
28
+ const lines = COMMANDS_HELP.map((c) => ` ${c.usage.padEnd(width + 2)}${c.desc}`);
29
+ return [
30
+ "ahood -- CLI for the ahood skills registry (https://ahood.vercel.app)",
31
+ "",
32
+ "Quick start:",
33
+ " ahood login",
34
+ " ahood search <something>",
35
+ " ahood add <owner>/<skill>",
36
+ "",
37
+ "Commands:",
38
+ ...lines,
39
+ "",
40
+ "Run `ahood <command> --help` for a single command's usage.",
41
+ "Full reference: https://ahood.vercel.app/docs",
42
+ ].join("\n");
43
+ }
package/dist/http.js ADDED
@@ -0,0 +1,24 @@
1
+ import { getApiUrl } from "./config.js";
2
+ import { resolveToken } from "./credentials.js";
3
+ export class ApiError extends Error {
4
+ status;
5
+ constructor(status, message) {
6
+ super(message);
7
+ this.status = status;
8
+ }
9
+ }
10
+ export async function apiFetch(path, init = {}) {
11
+ const token = resolveToken();
12
+ const headers = new Headers(init.headers);
13
+ if (token)
14
+ headers.set("Authorization", `Bearer ${token}`);
15
+ return fetch(`${getApiUrl()}${path}`, { ...init, headers });
16
+ }
17
+ export async function apiJson(path, init = {}) {
18
+ const res = await apiFetch(path, init);
19
+ const body = await res.json().catch(() => ({}));
20
+ if (!res.ok) {
21
+ throw new ApiError(res.status, body.error ?? `Request failed with status ${res.status}`);
22
+ }
23
+ return body;
24
+ }
package/dist/index.js ADDED
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ import { login } from "./commands/login.js";
3
+ import { logout } from "./commands/logout.js";
4
+ import { whoami } from "./commands/whoami.js";
5
+ import { search } from "./commands/search.js";
6
+ import { add } from "./commands/add.js";
7
+ import { update } from "./commands/update.js";
8
+ import { remove } from "./commands/remove.js";
9
+ import { publish } from "./commands/publish.js";
10
+ import { token } from "./commands/token.js";
11
+ import { edit } from "./commands/edit.js";
12
+ import { unpublish } from "./commands/unpublish.js";
13
+ import { listMine } from "./commands/list-mine.js";
14
+ import { star, unstar } from "./commands/star.js";
15
+ import { formatHelp, findCommandHelp } from "./help.js";
16
+ const COMMANDS = {
17
+ login: () => login(),
18
+ logout: () => logout(),
19
+ whoami: () => whoami(),
20
+ search: (args) => search(args),
21
+ add: (args) => add(args),
22
+ update: (args) => update(args),
23
+ remove: (args) => remove(args),
24
+ publish: (args) => publish(args),
25
+ token: (args) => token(args),
26
+ edit: (args) => edit(args),
27
+ unpublish: (args) => unpublish(args),
28
+ "list-mine": () => listMine(),
29
+ star: (args) => star(args),
30
+ unstar: (args) => unstar(args),
31
+ };
32
+ async function main() {
33
+ const [command, ...args] = process.argv.slice(2);
34
+ // Bare invocation and an explicit help request both just want to see
35
+ // what's available -- neither is an error.
36
+ if (!command || command === "help" || command === "--help" || command === "-h") {
37
+ console.log(formatHelp());
38
+ return;
39
+ }
40
+ const handler = COMMANDS[command];
41
+ if (!handler) {
42
+ console.error(`Unknown command: ${command}\n`);
43
+ console.error(formatHelp());
44
+ process.exit(1);
45
+ }
46
+ if (args.includes("--help") || args.includes("-h")) {
47
+ const entry = findCommandHelp(command);
48
+ console.log(entry ? `${entry.usage}\n\n${entry.desc}` : formatHelp());
49
+ return;
50
+ }
51
+ try {
52
+ await handler(args);
53
+ }
54
+ catch (error) {
55
+ const message = error instanceof Error ? error.message : String(error);
56
+ console.error(message);
57
+ // "Unauthorized" is the exact, literal body every resolveCaller-gated
58
+ // route returns on a 401 (see lib/resolve-caller.ts's callers) -- the
59
+ // one signal worth appending a next-step to, since every other error
60
+ // message is already command-specific.
61
+ if (/^unauthorized$/i.test(message)) {
62
+ console.error("Run `ahood login` first (or set AHOOD_TOKEN).");
63
+ }
64
+ process.exit(1);
65
+ }
66
+ }
67
+ main();
@@ -0,0 +1,26 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ export function readLockfile(path) {
4
+ if (!existsSync(path))
5
+ return {};
6
+ try {
7
+ return JSON.parse(readFileSync(path, "utf-8"));
8
+ }
9
+ catch {
10
+ return {};
11
+ }
12
+ }
13
+ function writeLockfile(path, lockfile) {
14
+ mkdirSync(dirname(path), { recursive: true });
15
+ writeFileSync(path, JSON.stringify(lockfile, null, 2) + "\n");
16
+ }
17
+ export function writeLockfileEntry(path, ownerSlashSkill, entry) {
18
+ const lockfile = readLockfile(path);
19
+ lockfile[ownerSlashSkill] = entry;
20
+ writeLockfile(path, lockfile);
21
+ }
22
+ export function removeLockfileEntry(path, ownerSlashSkill) {
23
+ const lockfile = readLockfile(path);
24
+ delete lockfile[ownerSlashSkill];
25
+ writeLockfile(path, lockfile);
26
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@ahood/cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for ahood, a registry for installing and publishing Claude Code skills.",
5
+ "license": "MIT",
6
+ "homepage": "https://github.com/AlexKay28/ahood-cli",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/AlexKay28/ahood-cli.git"
10
+ },
11
+ "keywords": [
12
+ "ahood",
13
+ "claude",
14
+ "claude-code",
15
+ "cli",
16
+ "skills"
17
+ ],
18
+ "type": "module",
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "bin": {
23
+ "ahood": "dist/index.js"
24
+ },
25
+ "files": [
26
+ "dist"
27
+ ],
28
+ "scripts": {
29
+ "build": "tsc -p tsconfig.json",
30
+ "prepublishOnly": "npm run build",
31
+ "test": "vitest run"
32
+ },
33
+ "dependencies": {
34
+ "tar-stream": "^3.2.1"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^20",
38
+ "@types/tar-stream": "^3.1.4",
39
+ "typescript": "^5",
40
+ "vitest": "^4.1.11"
41
+ }
42
+ }