@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 +21 -0
- package/README.md +53 -0
- package/dist/commands/add.js +120 -0
- package/dist/commands/edit.js +41 -0
- package/dist/commands/list-mine.js +11 -0
- package/dist/commands/login.js +42 -0
- package/dist/commands/logout.js +5 -0
- package/dist/commands/publish.js +104 -0
- package/dist/commands/remove.js +25 -0
- package/dist/commands/search.js +14 -0
- package/dist/commands/star.js +26 -0
- package/dist/commands/token.js +51 -0
- package/dist/commands/unpublish.js +32 -0
- package/dist/commands/update.js +20 -0
- package/dist/commands/whoami.js +36 -0
- package/dist/config.js +7 -0
- package/dist/credentials.js +43 -0
- package/dist/help.js +43 -0
- package/dist/http.js +24 -0
- package/dist/index.js +67 -0
- package/dist/lockfile.js +26 -0
- package/package.json +42 -0
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,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,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();
|
package/dist/lockfile.js
ADDED
|
@@ -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
|
+
}
|