@ahood/cli 0.1.0 → 0.2.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/README.md +30 -15
- package/dist/commands/add.js +100 -37
- package/dist/commands/completion.js +46 -0
- package/dist/commands/edit.js +19 -13
- package/dist/commands/list-mine.js +7 -2
- package/dist/commands/login.js +23 -3
- package/dist/commands/publish.js +159 -38
- package/dist/commands/remove.js +18 -13
- package/dist/commands/search.js +25 -4
- package/dist/commands/star.js +11 -14
- package/dist/commands/token.js +10 -5
- package/dist/commands/unpublish.js +39 -22
- package/dist/commands/update.js +20 -3
- package/dist/commands/view.js +49 -0
- package/dist/commands/whoami.js +33 -8
- package/dist/config.js +30 -1
- package/dist/credentials.js +10 -3
- package/dist/flags.js +13 -0
- package/dist/help.js +96 -14
- package/dist/http.js +37 -4
- package/dist/index.js +79 -24
- package/dist/lockfile.js +55 -10
- package/dist/spec.js +43 -0
- package/dist/version.js +5 -0
- package/package.json +1 -1
package/dist/config.js
CHANGED
|
@@ -1,7 +1,36 @@
|
|
|
1
1
|
const DEFAULT_API_URL = "https://ahood.vercel.app";
|
|
2
|
+
let warnedAboutOverride = false;
|
|
3
|
+
// AHOOD_API_URL is trusted with the bearer token on every request (see
|
|
4
|
+
// http.ts's apiFetch), so a scheme/host is validated rather than accepted
|
|
5
|
+
// verbatim -- an unvalidated override lets one stray env var (a malicious
|
|
6
|
+
// .envrc, CI config, or a script inside an installed skill) exfiltrate the
|
|
7
|
+
// token to an arbitrary host.
|
|
2
8
|
export function getApiUrl() {
|
|
3
9
|
const configured = process.env.AHOOD_API_URL;
|
|
4
10
|
if (!configured)
|
|
5
11
|
return DEFAULT_API_URL;
|
|
6
|
-
|
|
12
|
+
const trimmed = configured.replace(/\/+$/, "");
|
|
13
|
+
let url;
|
|
14
|
+
try {
|
|
15
|
+
url = new URL(trimmed);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new Error(`AHOOD_API_URL is not a valid URL: "${configured}"`);
|
|
19
|
+
}
|
|
20
|
+
// Plain http:// is only allowed for localhost and the RFC 2606 reserved
|
|
21
|
+
// test TLDs (.test/.invalid/.example/.localhost), which can never resolve
|
|
22
|
+
// on the public internet -- everything else must be https:// so the
|
|
23
|
+
// bearer token attached in http.ts is never sent in cleartext.
|
|
24
|
+
const isLocalHost = url.hostname === "localhost" ||
|
|
25
|
+
url.hostname === "127.0.0.1" ||
|
|
26
|
+
url.hostname === "::1" ||
|
|
27
|
+
/\.(test|invalid|example|localhost)$/i.test(url.hostname);
|
|
28
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLocalHost)) {
|
|
29
|
+
throw new Error(`AHOOD_API_URL must use https:// (http:// is only allowed for localhost/test hosts); got "${configured}".`);
|
|
30
|
+
}
|
|
31
|
+
if (trimmed !== DEFAULT_API_URL && !warnedAboutOverride) {
|
|
32
|
+
console.error(`warning: using non-default API endpoint from AHOOD_API_URL: ${trimmed}`);
|
|
33
|
+
warnedAboutOverride = true;
|
|
34
|
+
}
|
|
35
|
+
return trimmed;
|
|
7
36
|
}
|
package/dist/credentials.js
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, chmodSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
function credentialsDir() {
|
|
5
|
+
return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "ahood");
|
|
6
|
+
}
|
|
4
7
|
function credentialsPath() {
|
|
5
|
-
return join(
|
|
8
|
+
return join(credentialsDir(), "credentials.json");
|
|
6
9
|
}
|
|
7
10
|
export function readCredentials() {
|
|
8
11
|
const path = credentialsPath();
|
|
9
12
|
if (!existsSync(path))
|
|
10
13
|
return null;
|
|
11
14
|
try {
|
|
12
|
-
|
|
15
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
16
|
+
if (!parsed || typeof parsed !== "object" || typeof parsed.token !== "string") {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
return parsed;
|
|
13
20
|
}
|
|
14
21
|
catch {
|
|
15
22
|
return null;
|
|
@@ -17,7 +24,7 @@ export function readCredentials() {
|
|
|
17
24
|
}
|
|
18
25
|
export function writeCredentials(creds) {
|
|
19
26
|
const path = credentialsPath();
|
|
20
|
-
const dir =
|
|
27
|
+
const dir = credentialsDir();
|
|
21
28
|
// The `mode` option on mkdirSync/writeFileSync only applies when the
|
|
22
29
|
// directory/file is actually created (POSIX O_CREAT semantics) -- if
|
|
23
30
|
// either already exists with looser permissions (manual chmod, a restored
|
package/dist/flags.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Shared flag-value parsing. Throws instead of silently treating the next flag
|
|
2
|
+
// as this flag's value (e.g. `--tagline --visibility public` used to send
|
|
3
|
+
// "--visibility" as the tagline and drop --visibility entirely).
|
|
4
|
+
export function flagValue(args, flag) {
|
|
5
|
+
const index = args.indexOf(flag);
|
|
6
|
+
if (index < 0)
|
|
7
|
+
return undefined;
|
|
8
|
+
const value = args[index + 1];
|
|
9
|
+
if (value === undefined || value.startsWith("--")) {
|
|
10
|
+
throw new Error(`${flag} requires a value.`);
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
package/dist/help.js
CHANGED
|
@@ -1,31 +1,108 @@
|
|
|
1
1
|
export const COMMANDS_HELP = [
|
|
2
2
|
{ usage: "ahood login", desc: "Device-code browser login, stores a token locally." },
|
|
3
3
|
{ usage: "ahood logout", desc: "Removes the stored token." },
|
|
4
|
-
{
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
{
|
|
5
|
+
usage: "ahood whoami [--json]",
|
|
6
|
+
desc: "Reports whether your stored token still authenticates.",
|
|
7
|
+
flags: ["--json Emit a machine-readable result instead of prose."],
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
usage: "ahood search <query> [--json] [--limit <n>]",
|
|
11
|
+
desc: "Search published skills.",
|
|
12
|
+
flags: [
|
|
13
|
+
"--json Emit the raw skill objects instead of formatted lines.",
|
|
14
|
+
"--limit <n> Cap the number of results.",
|
|
15
|
+
],
|
|
16
|
+
examples: ["ahood search pdf-tools", "ahood search pdf-tools --json"],
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
usage: "ahood view <owner>/<skill> [--json] [--web]",
|
|
20
|
+
desc: "Show a single skill's details without installing it.",
|
|
21
|
+
flags: [
|
|
22
|
+
"--json Emit the raw skill object instead of formatted lines.",
|
|
23
|
+
"--web Open the skill's page in your browser instead of printing.",
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
usage: "ahood list-mine [--json]",
|
|
28
|
+
desc: "List your own skills, public and private.",
|
|
29
|
+
flags: ["--json Emit the raw skill objects instead of formatted lines."],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
usage: "ahood add <owner>/<skill>[@version]",
|
|
33
|
+
desc: "Install a skill into .claude/skills/, pinned in the lockfile.",
|
|
34
|
+
examples: ["ahood add alice/pdf-tools", "ahood add alice/pdf-tools@1.2.0"],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
usage: "ahood update [<owner>/<skill> ...]",
|
|
38
|
+
desc: "Move the lockfile pin(s) forward to the latest version. With no argument, updates every installed skill; one failure doesn't stop the rest.",
|
|
39
|
+
},
|
|
9
40
|
{ usage: "ahood remove <owner>/<skill>", desc: "Uninstall and unpin (local only)." },
|
|
10
41
|
{
|
|
11
|
-
usage: "ahood edit <owner>/<skill> [--tagline] [--tags] [--license] [--visibility]",
|
|
12
|
-
desc: "Update a skill you own.",
|
|
42
|
+
usage: "ahood edit <owner>/<skill> [--tagline] [--tags] [--license] [--visibility] [--homepage] [--repository]",
|
|
43
|
+
desc: "Update a skill you own. Only the flags you pass are changed.",
|
|
44
|
+
flags: [
|
|
45
|
+
"--tagline <text> Short one-line description.",
|
|
46
|
+
"--tags <comma,separated> Replaces the skill's tag list.",
|
|
47
|
+
"--license <id> An SPDX license identifier, e.g. MIT.",
|
|
48
|
+
"--visibility public|private Who can see and install the skill.",
|
|
49
|
+
"--homepage <url> The skill's homepage URL.",
|
|
50
|
+
"--repository <url> The skill's source repository URL.",
|
|
51
|
+
],
|
|
52
|
+
examples: ['ahood edit alice/pdf-tools --tagline "Merge and split PDFs"'],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
usage: "ahood unpublish <owner>/<skill> [--yes]",
|
|
56
|
+
desc: "Delete a skill from the registry for every consumer (not just your local install). Prompts for a typed \"yes\" unless --yes is passed.",
|
|
57
|
+
flags: ["--yes Skip the interactive confirmation, for scripts/CI."],
|
|
13
58
|
},
|
|
14
|
-
{ usage: "ahood unpublish <owner>/<skill>", desc: "Delete a skill from the registry (asks for confirmation)." },
|
|
15
59
|
{ usage: "ahood star <owner>/<skill>", desc: "Star a skill." },
|
|
16
60
|
{ usage: "ahood unstar <owner>/<skill>", desc: "Remove your star from a skill." },
|
|
17
61
|
{
|
|
18
|
-
usage: "ahood publish <path> --
|
|
19
|
-
desc: "Publish a new version of
|
|
62
|
+
usage: "ahood publish <owner>/<skill>@<version> [--path <dir>] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>]",
|
|
63
|
+
desc: "Publish a new version of a skill from a folder containing SKILL.md. If the skill doesn't exist yet, this " +
|
|
64
|
+
"creates it first -- pass --name (required in that case) and optionally --tagline/--tags/--license/--homepage/--repository. " +
|
|
65
|
+
"Processing happens server-side after upload; this command polls and reports the final published/failed status. " +
|
|
66
|
+
"The legacy form `ahood publish <path> --owner <owner> --slug <skill> --version <x.y.z>` is still accepted.",
|
|
67
|
+
flags: [
|
|
68
|
+
"--name <text> Required only when creating the skill on this publish.",
|
|
69
|
+
"--tagline <text> Short one-line description, used only when creating.",
|
|
70
|
+
"--tags <comma,separated> Initial tags, used only when creating.",
|
|
71
|
+
"--license <id> An SPDX license identifier, e.g. MIT, used only when creating.",
|
|
72
|
+
"--homepage <url> The skill's homepage URL, used only when creating.",
|
|
73
|
+
"--repository <url> The skill's source repository URL, used only when creating.",
|
|
74
|
+
],
|
|
75
|
+
examples: [
|
|
76
|
+
"ahood publish alice/pdf-tools@1.1.0",
|
|
77
|
+
'ahood publish alice/pdf-tools@1.0.0 --name "PDF Tools" --tagline "Merge and split PDFs"',
|
|
78
|
+
"ahood publish alice/pdf-tools@1.1.0 --path ./pdf-tools",
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
usage: "ahood token create <name>|list [--json]|revoke <id>",
|
|
83
|
+
desc: "Manage personal API tokens. `create` requires an existing browser-backed session.",
|
|
84
|
+
examples: ["ahood token create ci-runner", "ahood token list --json", "ahood token revoke <id>"],
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
usage: "ahood completion <bash|zsh|fish>",
|
|
88
|
+
desc: "Print a shell completion script for the command names.",
|
|
89
|
+
examples: ["ahood completion bash >> ~/.bashrc"],
|
|
20
90
|
},
|
|
21
|
-
{ usage: "ahood token create|list|revoke", desc: "Manage personal API tokens (requires a browser session)." },
|
|
22
91
|
];
|
|
23
92
|
export function findCommandHelp(command) {
|
|
24
|
-
return COMMANDS_HELP.find((c) => c.usage
|
|
93
|
+
return COMMANDS_HELP.find((c) => c.usage.startsWith(`ahood ${command} `) || c.usage === `ahood ${command}`);
|
|
94
|
+
}
|
|
95
|
+
export function formatCommandHelp(entry) {
|
|
96
|
+
const lines = [entry.usage, "", entry.desc];
|
|
97
|
+
if (entry.flags?.length)
|
|
98
|
+
lines.push("", "Flags:", ...entry.flags.map((f) => ` ${f}`));
|
|
99
|
+
if (entry.examples?.length)
|
|
100
|
+
lines.push("", "Examples:", ...entry.examples.map((e) => ` ${e}`));
|
|
101
|
+
return lines.join("\n");
|
|
25
102
|
}
|
|
26
103
|
export function formatHelp() {
|
|
27
104
|
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}
|
|
105
|
+
const lines = COMMANDS_HELP.map((c) => ` ${c.usage.padEnd(width + 2)}${c.desc.split(". ")[0].replace(/\.$/, "")}.`);
|
|
29
106
|
return [
|
|
30
107
|
"ahood -- CLI for the ahood skills registry (https://ahood.vercel.app)",
|
|
31
108
|
"",
|
|
@@ -37,7 +114,12 @@ export function formatHelp() {
|
|
|
37
114
|
"Commands:",
|
|
38
115
|
...lines,
|
|
39
116
|
"",
|
|
40
|
-
"Run `ahood <command> --help` for a single command's
|
|
117
|
+
"Run `ahood <command> --help` (or `ahood help <command>`) for a single command's flags and examples.",
|
|
118
|
+
"Run `ahood --version` to print the installed CLI version.",
|
|
119
|
+
"",
|
|
120
|
+
"Exit codes: 0 success, 1 general error, 2 usage/validation error,",
|
|
121
|
+
" 4 authentication required or rejected, 5 not found, 6 network/transport error.",
|
|
122
|
+
"",
|
|
41
123
|
"Full reference: https://ahood.vercel.app/docs",
|
|
42
124
|
].join("\n");
|
|
43
125
|
}
|
package/dist/http.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getApiUrl } from "./config.js";
|
|
2
2
|
import { resolveToken } from "./credentials.js";
|
|
3
|
+
import { CLI_NAME, CLI_VERSION } from "./version.js";
|
|
3
4
|
export class ApiError extends Error {
|
|
4
5
|
status;
|
|
5
6
|
constructor(status, message) {
|
|
@@ -7,18 +8,50 @@ export class ApiError extends Error {
|
|
|
7
8
|
this.status = status;
|
|
8
9
|
}
|
|
9
10
|
}
|
|
11
|
+
// Distinguished from ApiError so index.ts's top-level handler can map it to
|
|
12
|
+
// its own exit code -- "the network/transport failed" is a different
|
|
13
|
+
// situation for a caller/script than "the server responded with an error".
|
|
14
|
+
export class NetworkError extends Error {
|
|
15
|
+
}
|
|
16
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
17
|
+
// A hung/black-holed server would otherwise stall the process indefinitely --
|
|
18
|
+
// relevant since this CLI is meant to run unattended in CI and be driven by
|
|
19
|
+
// agents. Callers (e.g. publish's upload) can still pass their own `signal`.
|
|
10
20
|
export async function apiFetch(path, init = {}) {
|
|
11
21
|
const token = resolveToken();
|
|
12
22
|
const headers = new Headers(init.headers);
|
|
13
23
|
if (token)
|
|
14
24
|
headers.set("Authorization", `Bearer ${token}`);
|
|
15
|
-
|
|
25
|
+
headers.set("User-Agent", `${CLI_NAME}/${CLI_VERSION}`);
|
|
26
|
+
try {
|
|
27
|
+
return await fetch(`${getApiUrl()}${path}`, {
|
|
28
|
+
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
|
29
|
+
...init,
|
|
30
|
+
headers,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
35
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
36
|
+
throw new NetworkError(`Request to ${getApiUrl()}${path} failed (${message}${cause})`);
|
|
37
|
+
}
|
|
16
38
|
}
|
|
17
39
|
export async function apiJson(path, init = {}) {
|
|
18
40
|
const res = await apiFetch(path, init);
|
|
19
|
-
const body = await res.json().catch(() => ({}));
|
|
20
41
|
if (!res.ok) {
|
|
21
|
-
|
|
42
|
+
// The error body may not be valid/object JSON (proxy error pages, an
|
|
43
|
+
// empty body, a literal `null`) -- fall back to the status-only message
|
|
44
|
+
// rather than crashing on `body.error` of something that isn't an object.
|
|
45
|
+
const body = await res.json().catch(() => undefined);
|
|
46
|
+
const message = body && typeof body === "object" && typeof body.error === "string"
|
|
47
|
+
? body.error
|
|
48
|
+
: `Request failed with status ${res.status}`;
|
|
49
|
+
throw new ApiError(res.status, message);
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
return (await res.json());
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new Error(`Malformed response from ${getApiUrl()}${path}: expected JSON.`);
|
|
22
56
|
}
|
|
23
|
-
return body;
|
|
24
57
|
}
|
package/dist/index.js
CHANGED
|
@@ -12,40 +12,99 @@ import { edit } from "./commands/edit.js";
|
|
|
12
12
|
import { unpublish } from "./commands/unpublish.js";
|
|
13
13
|
import { listMine } from "./commands/list-mine.js";
|
|
14
14
|
import { star, unstar } from "./commands/star.js";
|
|
15
|
-
import {
|
|
15
|
+
import { view } from "./commands/view.js";
|
|
16
|
+
import { completion } from "./commands/completion.js";
|
|
17
|
+
import { formatHelp, formatCommandHelp, findCommandHelp } from "./help.js";
|
|
18
|
+
import { ApiError, NetworkError } from "./http.js";
|
|
19
|
+
import { CLI_NAME, CLI_VERSION } from "./version.js";
|
|
16
20
|
const COMMANDS = {
|
|
17
21
|
login: () => login(),
|
|
18
22
|
logout: () => logout(),
|
|
19
|
-
whoami
|
|
20
|
-
search
|
|
21
|
-
add
|
|
22
|
-
update
|
|
23
|
-
remove
|
|
24
|
-
publish
|
|
25
|
-
token
|
|
26
|
-
edit
|
|
27
|
-
unpublish
|
|
28
|
-
"list-mine":
|
|
29
|
-
star
|
|
30
|
-
unstar
|
|
23
|
+
whoami,
|
|
24
|
+
search,
|
|
25
|
+
add,
|
|
26
|
+
update,
|
|
27
|
+
remove,
|
|
28
|
+
publish,
|
|
29
|
+
token,
|
|
30
|
+
edit,
|
|
31
|
+
unpublish,
|
|
32
|
+
"list-mine": listMine,
|
|
33
|
+
star,
|
|
34
|
+
unstar,
|
|
35
|
+
view,
|
|
36
|
+
completion,
|
|
31
37
|
};
|
|
38
|
+
function levenshtein(a, b) {
|
|
39
|
+
const dp = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
|
|
40
|
+
for (let i = 0; i <= a.length; i++)
|
|
41
|
+
dp[i][0] = i;
|
|
42
|
+
for (let j = 0; j <= b.length; j++)
|
|
43
|
+
dp[0][j] = j;
|
|
44
|
+
for (let i = 1; i <= a.length; i++) {
|
|
45
|
+
for (let j = 1; j <= b.length; j++) {
|
|
46
|
+
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return dp[a.length][b.length];
|
|
50
|
+
}
|
|
51
|
+
function closestCommand(input) {
|
|
52
|
+
let best;
|
|
53
|
+
let bestDistance = Infinity;
|
|
54
|
+
for (const name of Object.keys(COMMANDS)) {
|
|
55
|
+
const distance = levenshtein(input, name);
|
|
56
|
+
if (distance < bestDistance) {
|
|
57
|
+
bestDistance = distance;
|
|
58
|
+
best = name;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return bestDistance <= 2 ? best : undefined;
|
|
62
|
+
}
|
|
63
|
+
// A hung/black-holed connection throws undici's generic TypeError("fetch
|
|
64
|
+
// failed") -- http.ts wraps that into NetworkError so it gets its own exit
|
|
65
|
+
// code here, distinct from a server-returned ApiError.
|
|
66
|
+
function exitCodeFor(error) {
|
|
67
|
+
if (error instanceof ApiError) {
|
|
68
|
+
if (error.status === 401 || error.status === 403)
|
|
69
|
+
return 4;
|
|
70
|
+
if (error.status === 404)
|
|
71
|
+
return 5;
|
|
72
|
+
return 2;
|
|
73
|
+
}
|
|
74
|
+
if (error instanceof NetworkError)
|
|
75
|
+
return 6;
|
|
76
|
+
return 1;
|
|
77
|
+
}
|
|
32
78
|
async function main() {
|
|
33
79
|
const [command, ...args] = process.argv.slice(2);
|
|
80
|
+
if (command === "--version" || command === "-v") {
|
|
81
|
+
console.log(`${CLI_NAME} ${CLI_VERSION}`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (command === "help") {
|
|
85
|
+
const sub = args[0];
|
|
86
|
+
const entry = sub ? findCommandHelp(sub) : undefined;
|
|
87
|
+
console.log(entry ? formatCommandHelp(entry) : formatHelp());
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
34
90
|
// Bare invocation and an explicit help request both just want to see
|
|
35
91
|
// what's available -- neither is an error.
|
|
36
|
-
if (!command || command === "
|
|
92
|
+
if (!command || command === "--help" || command === "-h") {
|
|
37
93
|
console.log(formatHelp());
|
|
38
94
|
return;
|
|
39
95
|
}
|
|
40
96
|
const handler = COMMANDS[command];
|
|
41
97
|
if (!handler) {
|
|
42
|
-
console.error(`Unknown command: ${command}
|
|
43
|
-
|
|
44
|
-
|
|
98
|
+
console.error(`Unknown command: ${command}`);
|
|
99
|
+
const suggestion = closestCommand(command);
|
|
100
|
+
if (suggestion)
|
|
101
|
+
console.error(`Did you mean '${suggestion}'?`);
|
|
102
|
+
console.error("Run `ahood help` for a list of commands.");
|
|
103
|
+
process.exit(2);
|
|
45
104
|
}
|
|
46
105
|
if (args.includes("--help") || args.includes("-h")) {
|
|
47
106
|
const entry = findCommandHelp(command);
|
|
48
|
-
console.log(entry ?
|
|
107
|
+
console.log(entry ? formatCommandHelp(entry) : formatHelp());
|
|
49
108
|
return;
|
|
50
109
|
}
|
|
51
110
|
try {
|
|
@@ -54,14 +113,10 @@ async function main() {
|
|
|
54
113
|
catch (error) {
|
|
55
114
|
const message = error instanceof Error ? error.message : String(error);
|
|
56
115
|
console.error(message);
|
|
57
|
-
|
|
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)) {
|
|
116
|
+
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
|
|
62
117
|
console.error("Run `ahood login` first (or set AHOOD_TOKEN).");
|
|
63
118
|
}
|
|
64
|
-
process.exit(
|
|
119
|
+
process.exit(exitCodeFor(error));
|
|
65
120
|
}
|
|
66
121
|
}
|
|
67
122
|
main();
|
package/dist/lockfile.js
CHANGED
|
@@ -1,26 +1,71 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmdirSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
export function readLockfile(path) {
|
|
4
4
|
if (!existsSync(path))
|
|
5
5
|
return {};
|
|
6
|
+
const raw = readFileSync(path, "utf-8");
|
|
6
7
|
try {
|
|
7
|
-
return JSON.parse(
|
|
8
|
+
return JSON.parse(raw);
|
|
8
9
|
}
|
|
9
10
|
catch {
|
|
10
|
-
|
|
11
|
+
// Silently treating a corrupted lockfile as "nothing installed" is how
|
|
12
|
+
// every existing pin gets permanently discarded on the very next write --
|
|
13
|
+
// refuse instead, so a truncated/interrupted write is a loud, fixable
|
|
14
|
+
// error rather than silent data loss.
|
|
15
|
+
throw new Error(`Lockfile at ${path} is corrupted and could not be parsed as JSON. Fix or delete it before continuing.`);
|
|
11
16
|
}
|
|
12
17
|
}
|
|
13
18
|
function writeLockfile(path, lockfile) {
|
|
14
19
|
mkdirSync(dirname(path), { recursive: true });
|
|
15
|
-
|
|
20
|
+
// Write-then-rename so a process interrupted mid-write never leaves a
|
|
21
|
+
// truncated lockfile on disk -- a reader always sees either the old or the
|
|
22
|
+
// new complete content, never a partial one.
|
|
23
|
+
const tmpPath = `${path}.tmp-${process.pid}-${process.hrtime.bigint()}`;
|
|
24
|
+
writeFileSync(tmpPath, JSON.stringify(lockfile, null, 2) + "\n");
|
|
25
|
+
renameSync(tmpPath, path);
|
|
26
|
+
}
|
|
27
|
+
// Simple advisory lock via mkdir's atomicity (EEXIST on a second caller),
|
|
28
|
+
// so two concurrent `ahood add`/`remove` invocations against the same
|
|
29
|
+
// project don't race a read-modify-write and silently drop one another's
|
|
30
|
+
// entry.
|
|
31
|
+
function withLock(path, fn) {
|
|
32
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
33
|
+
const lockDir = `${path}.lock`;
|
|
34
|
+
const deadline = Date.now() + 5000;
|
|
35
|
+
for (;;) {
|
|
36
|
+
try {
|
|
37
|
+
mkdirSync(lockDir);
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error.code !== "EEXIST")
|
|
42
|
+
throw error;
|
|
43
|
+
if (Date.now() > deadline) {
|
|
44
|
+
throw new Error(`Timed out waiting for the lockfile lock at ${lockDir}. If no other ahood process is running, delete that directory manually.`);
|
|
45
|
+
}
|
|
46
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
return fn();
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
rmdirSync(lockDir);
|
|
54
|
+
}
|
|
16
55
|
}
|
|
17
56
|
export function writeLockfileEntry(path, ownerSlashSkill, entry) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
57
|
+
withLock(path, () => {
|
|
58
|
+
const lockfile = readLockfile(path);
|
|
59
|
+
lockfile[ownerSlashSkill] = entry;
|
|
60
|
+
writeLockfile(path, lockfile);
|
|
61
|
+
});
|
|
21
62
|
}
|
|
22
63
|
export function removeLockfileEntry(path, ownerSlashSkill) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
64
|
+
return withLock(path, () => {
|
|
65
|
+
const lockfile = readLockfile(path);
|
|
66
|
+
const existed = ownerSlashSkill in lockfile;
|
|
67
|
+
delete lockfile[ownerSlashSkill];
|
|
68
|
+
writeLockfile(path, lockfile);
|
|
69
|
+
return existed;
|
|
70
|
+
});
|
|
26
71
|
}
|
package/dist/spec.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
// Owner and skill segments become filesystem path components (.claude/skills/<owner>/<skill>)
|
|
3
|
+
// and URL path segments, so both are restricted to a safe charset that cannot contain "..",
|
|
4
|
+
// "/", "\", or a leading dot -- this is what closes the path-traversal hole in add/remove
|
|
5
|
+
// (a spec like "alice/.." previously resolved outside .claude/skills/ entirely).
|
|
6
|
+
const SEGMENT_RE = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
7
|
+
export const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
8
|
+
export function validateSegment(value, kind, spec) {
|
|
9
|
+
if (!SEGMENT_RE.test(value) || value === "." || value === "..") {
|
|
10
|
+
throw new Error(`Invalid ${kind} "${value}" in "${spec}" -- must start with a letter/digit and contain only letters, digits, ".", "_", or "-".`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function parseOwnerSkill(spec, usage) {
|
|
14
|
+
if (!spec)
|
|
15
|
+
throw new Error(usage);
|
|
16
|
+
const parts = spec.split("/");
|
|
17
|
+
if (parts.length !== 2 || !parts[0] || !parts[1])
|
|
18
|
+
throw new Error(usage);
|
|
19
|
+
const [owner, skill] = parts;
|
|
20
|
+
validateSegment(owner, "owner", spec);
|
|
21
|
+
validateSegment(skill, "skill", spec);
|
|
22
|
+
return { owner, skill };
|
|
23
|
+
}
|
|
24
|
+
export function parseOwnerSkillVersion(spec, usage) {
|
|
25
|
+
if (!spec)
|
|
26
|
+
throw new Error(usage);
|
|
27
|
+
const atIndex = spec.lastIndexOf("@");
|
|
28
|
+
const ownerSkill = atIndex > 0 ? spec.slice(0, atIndex) : spec;
|
|
29
|
+
const version = atIndex > 0 ? spec.slice(atIndex + 1) : "latest";
|
|
30
|
+
if (atIndex > 0 && version === "") {
|
|
31
|
+
throw new Error(`Missing version after "@" in "${spec}".\n${usage}`);
|
|
32
|
+
}
|
|
33
|
+
if (version !== "latest" && !SEMVER_RE.test(version)) {
|
|
34
|
+
throw new Error(`Invalid version "${version}" in "${spec}" -- expected "latest" or a semver like 1.2.3.\n${usage}`);
|
|
35
|
+
}
|
|
36
|
+
const { owner, skill } = parseOwnerSkill(ownerSkill, usage);
|
|
37
|
+
return { owner, skill, version };
|
|
38
|
+
}
|
|
39
|
+
export const SKILLS_ROOT = join(".claude", "skills");
|
|
40
|
+
export const LOCKFILE_PATH = join(".claude", "skills.lock.json");
|
|
41
|
+
export function skillDir(owner, skill) {
|
|
42
|
+
return join(SKILLS_ROOT, owner, skill);
|
|
43
|
+
}
|
package/dist/version.js
ADDED
package/package.json
CHANGED