@ahood/cli 0.1.0 → 0.2.1

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.
@@ -1,9 +1,17 @@
1
1
  import { ApiError, apiJson } from "../http.js";
2
2
  import { resolveToken } from "../credentials.js";
3
- export async function whoami() {
3
+ export async function whoami(args = []) {
4
+ const wantsJson = args.includes("--json");
4
5
  const token = resolveToken();
5
6
  if (!token) {
6
- console.log("Not logged in. Run `ahood login`.");
7
+ // Previously exited 0 here, defeating whoami's purpose as a scriptable
8
+ // auth check -- "no token configured at all" must fail just like "token
9
+ // rejected by the server" does below.
10
+ if (wantsJson)
11
+ console.log(JSON.stringify({ authenticated: false }));
12
+ else
13
+ console.error("Not logged in. Run `ahood login` (or set AHOOD_TOKEN).");
14
+ process.exitCode = 1;
7
15
  return;
8
16
  }
9
17
  // There is no endpoint that returns an identity for a bearer caller yet, so
@@ -16,21 +24,38 @@ export async function whoami() {
16
24
  // being a token rather than a session. The token is valid.
17
25
  // 401 -> resolveCaller could not resolve the token at all: unknown,
18
26
  // revoked, or expired.
27
+ // anything else (network failure, 5xx, timeout) is NOT the same as an
28
+ // invalid token, and is reported as its own distinct failure.
19
29
  try {
20
30
  await apiJson("/api/v1/auth/tokens");
21
31
  // A session-backed caller (only reachable if this ever runs against a
22
32
  // cookie-bearing client) -- the token list came back.
23
- console.log("Authenticated.");
33
+ if (wantsJson)
34
+ console.log(JSON.stringify({ authenticated: true, mode: "session" }));
35
+ else
36
+ console.log("Authenticated.");
24
37
  }
25
38
  catch (error) {
26
39
  if (error instanceof ApiError && error.status === 403) {
27
- console.log("Authenticated with a personal API token.");
40
+ if (wantsJson)
41
+ console.log(JSON.stringify({ authenticated: true, mode: "token" }));
42
+ else
43
+ console.log("Authenticated with a personal API token.");
44
+ return;
45
+ }
46
+ if (error instanceof ApiError && error.status === 401) {
47
+ if (wantsJson)
48
+ console.log(JSON.stringify({ authenticated: false, reason: "invalid_token" }));
49
+ else
50
+ console.error("Not authenticated -- your token is invalid or has been revoked.");
51
+ process.exitCode = 1;
28
52
  return;
29
53
  }
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.");
54
+ const message = error instanceof Error ? error.message : String(error);
55
+ if (wantsJson)
56
+ console.log(JSON.stringify({ authenticated: null, error: message }));
57
+ else
58
+ console.error(`Could not verify your token: ${message}`);
34
59
  process.exitCode = 1;
35
60
  }
36
61
  }
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
- return configured.endsWith("/") ? configured.slice(0, -1) : configured;
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
  }
@@ -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(homedir(), ".config", "ahood", "credentials.json");
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
- return JSON.parse(readFileSync(path, "utf-8"));
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 = join(homedir(), ".config", "ahood");
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,110 @@
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
- { 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." },
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 (tags, license, homepage, repository, dates, etc.) without installing it. Alias: ahood show.",
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> --owner <owner> --slug <skill> --version <x.y.z>",
19
- desc: "Publish a new version of an existing skill.",
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
  ];
92
+ const COMMAND_ALIASES = { show: "view" };
23
93
  export function findCommandHelp(command) {
24
- return COMMANDS_HELP.find((c) => c.usage === command || c.usage.startsWith(`ahood ${command} `) || c.usage === `ahood ${command}`);
94
+ const resolved = COMMAND_ALIASES[command] ?? command;
95
+ return COMMANDS_HELP.find((c) => c.usage.startsWith(`ahood ${resolved} `) || c.usage === `ahood ${resolved}`);
96
+ }
97
+ export function formatCommandHelp(entry) {
98
+ const lines = [entry.usage, "", entry.desc];
99
+ if (entry.flags?.length)
100
+ lines.push("", "Flags:", ...entry.flags.map((f) => ` ${f}`));
101
+ if (entry.examples?.length)
102
+ lines.push("", "Examples:", ...entry.examples.map((e) => ` ${e}`));
103
+ return lines.join("\n");
25
104
  }
26
105
  export function formatHelp() {
27
106
  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}`);
107
+ const lines = COMMANDS_HELP.map((c) => ` ${c.usage.padEnd(width + 2)}${c.desc.split(". ")[0].replace(/\.$/, "")}.`);
29
108
  return [
30
109
  "ahood -- CLI for the ahood skills registry (https://ahood.vercel.app)",
31
110
  "",
@@ -37,7 +116,12 @@ export function formatHelp() {
37
116
  "Commands:",
38
117
  ...lines,
39
118
  "",
40
- "Run `ahood <command> --help` for a single command's usage.",
119
+ "Run `ahood <command> --help` (or `ahood help <command>`) for a single command's flags and examples.",
120
+ "Run `ahood --version` to print the installed CLI version.",
121
+ "",
122
+ "Exit codes: 0 success, 1 general error, 2 usage/validation error,",
123
+ " 4 authentication required or rejected, 5 not found, 6 network/transport error.",
124
+ "",
41
125
  "Full reference: https://ahood.vercel.app/docs",
42
126
  ].join("\n");
43
127
  }
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
- return fetch(`${getApiUrl()}${path}`, { ...init, headers });
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
- throw new ApiError(res.status, body.error ?? `Request failed with status ${res.status}`);
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,100 @@ 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 { formatHelp, findCommandHelp } from "./help.js";
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: () => 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),
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
+ show: view, // alias -- "ahood show" is the name issue #30 proposed; same command as "view"
37
+ completion,
31
38
  };
39
+ function levenshtein(a, b) {
40
+ const dp = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
41
+ for (let i = 0; i <= a.length; i++)
42
+ dp[i][0] = i;
43
+ for (let j = 0; j <= b.length; j++)
44
+ dp[0][j] = j;
45
+ for (let i = 1; i <= a.length; i++) {
46
+ for (let j = 1; j <= b.length; j++) {
47
+ 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]);
48
+ }
49
+ }
50
+ return dp[a.length][b.length];
51
+ }
52
+ function closestCommand(input) {
53
+ let best;
54
+ let bestDistance = Infinity;
55
+ for (const name of Object.keys(COMMANDS)) {
56
+ const distance = levenshtein(input, name);
57
+ if (distance < bestDistance) {
58
+ bestDistance = distance;
59
+ best = name;
60
+ }
61
+ }
62
+ return bestDistance <= 2 ? best : undefined;
63
+ }
64
+ // A hung/black-holed connection throws undici's generic TypeError("fetch
65
+ // failed") -- http.ts wraps that into NetworkError so it gets its own exit
66
+ // code here, distinct from a server-returned ApiError.
67
+ function exitCodeFor(error) {
68
+ if (error instanceof ApiError) {
69
+ if (error.status === 401 || error.status === 403)
70
+ return 4;
71
+ if (error.status === 404)
72
+ return 5;
73
+ return 2;
74
+ }
75
+ if (error instanceof NetworkError)
76
+ return 6;
77
+ return 1;
78
+ }
32
79
  async function main() {
33
80
  const [command, ...args] = process.argv.slice(2);
81
+ if (command === "--version" || command === "-v") {
82
+ console.log(`${CLI_NAME} ${CLI_VERSION}`);
83
+ return;
84
+ }
85
+ if (command === "help") {
86
+ const sub = args[0];
87
+ const entry = sub ? findCommandHelp(sub) : undefined;
88
+ console.log(entry ? formatCommandHelp(entry) : formatHelp());
89
+ return;
90
+ }
34
91
  // Bare invocation and an explicit help request both just want to see
35
92
  // what's available -- neither is an error.
36
- if (!command || command === "help" || command === "--help" || command === "-h") {
93
+ if (!command || command === "--help" || command === "-h") {
37
94
  console.log(formatHelp());
38
95
  return;
39
96
  }
40
97
  const handler = COMMANDS[command];
41
98
  if (!handler) {
42
- console.error(`Unknown command: ${command}\n`);
43
- console.error(formatHelp());
44
- process.exit(1);
99
+ console.error(`Unknown command: ${command}`);
100
+ const suggestion = closestCommand(command);
101
+ if (suggestion)
102
+ console.error(`Did you mean '${suggestion}'?`);
103
+ console.error("Run `ahood help` for a list of commands.");
104
+ process.exit(2);
45
105
  }
46
106
  if (args.includes("--help") || args.includes("-h")) {
47
107
  const entry = findCommandHelp(command);
48
- console.log(entry ? `${entry.usage}\n\n${entry.desc}` : formatHelp());
108
+ console.log(entry ? formatCommandHelp(entry) : formatHelp());
49
109
  return;
50
110
  }
51
111
  try {
@@ -54,14 +114,10 @@ async function main() {
54
114
  catch (error) {
55
115
  const message = error instanceof Error ? error.message : String(error);
56
116
  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)) {
117
+ if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
62
118
  console.error("Run `ahood login` first (or set AHOOD_TOKEN).");
63
119
  }
64
- process.exit(1);
120
+ process.exit(exitCodeFor(error));
65
121
  }
66
122
  }
67
123
  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(readFileSync(path, "utf-8"));
8
+ return JSON.parse(raw);
8
9
  }
9
10
  catch {
10
- return {};
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
- writeFileSync(path, JSON.stringify(lockfile, null, 2) + "\n");
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
- const lockfile = readLockfile(path);
19
- lockfile[ownerSlashSkill] = entry;
20
- writeLockfile(path, lockfile);
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
- const lockfile = readLockfile(path);
24
- delete lockfile[ownerSlashSkill];
25
- writeLockfile(path, lockfile);
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
+ }
@@ -0,0 +1,5 @@
1
+ import { createRequire } from "node:module";
2
+ const require = createRequire(import.meta.url);
3
+ const pkg = require("../package.json");
4
+ export const CLI_NAME = pkg.name;
5
+ export const CLI_VERSION = pkg.version;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahood/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "CLI for ahood, a registry for installing and publishing Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/AlexKay28/ahood-cli",