@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.
@@ -1,8 +1,33 @@
1
- import { readFileSync, existsSync } from "node:fs";
1
+ import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { pack } from "tar-stream";
4
4
  import { createGzip } from "node:zlib";
5
- import { apiJson } from "../http.js";
5
+ import { apiJson, ApiError } from "../http.js";
6
+ import { flagValue } from "../flags.js";
7
+ import { parseOwnerSkill, SEMVER_RE } from "../spec.js";
8
+ // Processing (decompress/validate/checksum/scan/upload) now happens in a
9
+ // background workflow, not inline in versions/complete's response (server
10
+ // issue #120) -- complete returns 202 immediately, so this polls
11
+ // GET .../versions/{version} (which now returns {status, failure_reason}
12
+ // even for a non-published row) until the version leaves 'processing'.
13
+ const POLL_INTERVAL_MS = 1500;
14
+ const POLL_TIMEOUT_MS = 120_000;
15
+ function sleep(ms) {
16
+ return new Promise((resolve) => setTimeout(resolve, ms));
17
+ }
18
+ async function pollVersionStatus(owner, skill, version) {
19
+ const path = `/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions/${encodeURIComponent(version)}`;
20
+ const deadline = Date.now() + POLL_TIMEOUT_MS;
21
+ while (Date.now() < deadline) {
22
+ const result = await apiJson(path);
23
+ if (result.status !== "processing")
24
+ return result;
25
+ await sleep(POLL_INTERVAL_MS);
26
+ }
27
+ throw new Error(`Still processing after ${POLL_TIMEOUT_MS / 1000}s -- check \`ahood view ${owner}/${skill}\` later for the result.`);
28
+ }
29
+ const USAGE = "Usage: ahood publish <owner>/<skill>@<version> [--path <dir>] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>]\n" +
30
+ " or: ahood publish <path> --owner <owner> --slug <skill> --version <x.y.z>";
6
31
  // Matched by ENTRY NAME at every depth, not by path prefix, so a nested
7
32
  // `vendor/thing/.git` is skipped the same as a top-level one. Not a
8
33
  // .gitignore parser (out of scope) -- just the fixed set of names that must
@@ -13,17 +38,31 @@ import { apiJson } from "../http.js";
13
38
  // 32+ hex) do not match.
14
39
  // node_modules -- never part of a skill, and megabytes of it.
15
40
  // .env / .env.* -- the single most likely place a real secret lives.
41
+ // .npmrc / .yarnrc.yml / .netrc / .pypirc -- package-manager/network auth
42
+ // tokens (npm_..., ghp_...) that don't match the server
43
+ // scanner's regexes either.
44
+ // id_rsa / id_ed25519 / id_ecdsa / id_dsa (+ .pub) / .ssh -- SSH keys.
45
+ // .aws / .docker -- cloud and registry credentials.
16
46
  // .DS_Store -- noise.
17
- const EXCLUDED_NAMES = new Set([".git", "node_modules", ".DS_Store"]);
47
+ const EXCLUDED_NAMES = new Set([
48
+ ".git",
49
+ "node_modules",
50
+ ".DS_Store",
51
+ ".npmrc",
52
+ ".yarnrc.yml",
53
+ ".netrc",
54
+ ".pypirc",
55
+ ".ssh",
56
+ ".aws",
57
+ ".docker",
58
+ ]);
59
+ const EXCLUDED_PREFIXES = [".env", "id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"];
18
60
  function isExcluded(name) {
19
61
  if (EXCLUDED_NAMES.has(name))
20
62
  return true;
21
- if (name === ".env" || name.startsWith(".env."))
22
- return true;
23
- return false;
63
+ return EXCLUDED_PREFIXES.some((prefix) => name === prefix || name.startsWith(`${prefix}.`));
24
64
  }
25
65
  async function tarGzDirectory(dir) {
26
- const { readdirSync, statSync } = await import("node:fs");
27
66
  const tar = pack();
28
67
  const gzip = createGzip();
29
68
  const chunks = [];
@@ -33,11 +72,19 @@ async function tarGzDirectory(dir) {
33
72
  continue;
34
73
  const fullPath = join(current, entry);
35
74
  const relPath = prefix ? `${prefix}/${entry}` : entry;
36
- const stat = statSync(fullPath);
75
+ // lstat, not stat -- following a symlink here would pack whatever it
76
+ // points at (anywhere on disk, or a parent directory causing unbounded
77
+ // recursion) under an innocuous-looking name, completely bypassing the
78
+ // exclusion list above, which only ever matches by name.
79
+ const stat = lstatSync(fullPath);
80
+ if (stat.isSymbolicLink()) {
81
+ console.warn(`WARNING: skipping symlink, not publishing it: ${relPath}`);
82
+ continue;
83
+ }
37
84
  if (stat.isDirectory()) {
38
85
  addDir(fullPath, relPath);
39
86
  }
40
- else {
87
+ else if (stat.isFile()) {
41
88
  tar.entry({ name: relPath, size: stat.size }, readFileSync(fullPath));
42
89
  }
43
90
  }
@@ -51,36 +98,99 @@ async function tarGzDirectory(dir) {
51
98
  tar.pipe(gzip);
52
99
  });
53
100
  }
101
+ function parsePublishArgs(args) {
102
+ const pathFlag = flagValue(args, "--path");
103
+ let owner = flagValue(args, "--owner");
104
+ let skill = flagValue(args, "--slug");
105
+ let version = flagValue(args, "--version");
106
+ const name = flagValue(args, "--name");
107
+ const tagline = flagValue(args, "--tagline");
108
+ const tags = flagValue(args, "--tags");
109
+ const license = flagValue(args, "--license");
110
+ const homepage = flagValue(args, "--homepage");
111
+ const repository = flagValue(args, "--repository");
112
+ let legacyPath;
113
+ const first = args[0];
114
+ if (first && !first.startsWith("--")) {
115
+ if (first.includes("/") && first.includes("@")) {
116
+ // Primary form: ahood publish <owner>/<skill>@<version>
117
+ const parsed = parseOwnerSkill(first.slice(0, first.lastIndexOf("@")), USAGE);
118
+ owner = owner ?? parsed.owner;
119
+ skill = skill ?? parsed.skill;
120
+ version = version ?? first.slice(first.lastIndexOf("@") + 1);
121
+ }
122
+ else {
123
+ // Legacy form: ahood publish <path> --owner ... --slug ... --version ...
124
+ legacyPath = first;
125
+ }
126
+ }
127
+ if (!owner || !skill || !version)
128
+ throw new Error(USAGE);
129
+ if (!SEMVER_RE.test(version)) {
130
+ throw new Error(`--version must be a semver like 1.2.3 (got "${version}").\n${USAGE}`);
131
+ }
132
+ return { owner, skill, version, path: pathFlag ?? legacyPath ?? ".", name, tagline, tags, license, homepage, repository };
133
+ }
134
+ // Creates the skill under the caller's own account when versions/init 404s
135
+ // -- skill creation is CLI-only now (there is no separate `ahood create`;
136
+ // publish creates on first use). --name is required for this path since
137
+ // createSkill requires a name; --tagline/--tags/--license/--homepage/
138
+ // --repository are optional and only used here, never applied to an
139
+ // already-existing skill.
140
+ async function createSkillForPublish(owner, skill, opts) {
141
+ if (!opts.name) {
142
+ throw new Error(`${owner}/${skill} doesn't exist yet -- pass --name to create it as part of this publish (e.g. --name "My Skill").`);
143
+ }
144
+ const body = { slug: skill, name: opts.name };
145
+ if (opts.tagline !== undefined)
146
+ body.tagline = opts.tagline;
147
+ if (opts.tags !== undefined)
148
+ body.tags = opts.tags.split(",").map((t) => t.trim()).filter(Boolean);
149
+ if (opts.license !== undefined)
150
+ body.license = opts.license;
151
+ if (opts.homepage !== undefined)
152
+ body.homepage = opts.homepage;
153
+ if (opts.repository !== undefined)
154
+ body.repository = opts.repository;
155
+ const created = await apiJson("/api/v1/skills", {
156
+ method: "POST",
157
+ headers: { "Content-Type": "application/json" },
158
+ body: JSON.stringify(body),
159
+ });
160
+ // POST /api/v1/skills always creates under the authenticated caller's own
161
+ // account -- it never reads an owner from the request body. If the caller
162
+ // typed a different owner segment than their own username, the created
163
+ // skill lives at a path that doesn't match what they asked to publish to.
164
+ if (created.owner !== owner) {
165
+ throw new Error(`Created ${created.owner}/${created.slug}, but this publish targeted "${owner}/${skill}" -- skills ` +
166
+ `are always created under your own account (${created.owner}). Retry with: ` +
167
+ `ahood publish ${created.owner}/${skill}@<version>.`);
168
+ }
169
+ console.log(`Created ${created.owner}/${created.slug} -- publishing its first version now.`);
170
+ }
171
+ async function initVersion(owner, skill, version, packageSizeBytes, createOpts) {
172
+ const path = `/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions/init`;
173
+ const body = JSON.stringify({ version, package_size_bytes: packageSizeBytes });
174
+ try {
175
+ return await apiJson(path, { method: "POST", headers: { "Content-Type": "application/json" }, body });
176
+ }
177
+ catch (error) {
178
+ if (!(error instanceof ApiError) || error.status !== 404)
179
+ throw error;
180
+ await createSkillForPublish(owner, skill, createOpts);
181
+ // Retry exactly once. If this ALSO 404s (e.g. a race, or the create
182
+ // silently didn't take), let that error propagate rather than looping.
183
+ return await apiJson(path, { method: "POST", headers: { "Content-Type": "application/json" }, body });
184
+ }
185
+ }
54
186
  export async function publish(args) {
55
- const path = args[0] ?? ".";
187
+ const { owner, skill, version, path, name, tagline, tags, license, homepage, repository } = parsePublishArgs(args);
56
188
  const skillMdPath = join(path, "SKILL.md");
57
189
  if (!existsSync(skillMdPath)) {
58
190
  throw new Error(`No SKILL.md found at ${skillMdPath} -- publish must point at a skill folder's root.`);
59
191
  }
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
192
  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
- });
193
+ const init = await initVersion(owner, skill, version, archive.length, { name, tagline, tags, license, homepage, repository });
84
194
  // TS's lib.dom BodyInit (in scope here since tsconfig has no explicit
85
195
  // "lib" override, so DOM is included alongside the Node types) type-checks
86
196
  // Buffer/Uint8Array against `Uint8Array<ArrayBuffer>` specifically as of
@@ -92,13 +202,24 @@ export async function publish(args) {
92
202
  // restriction. Asserting through BodyInit here, rather than reshaping
93
203
  // tsconfig's "lib" for the whole package, keeps this fix local to the one
94
204
  // 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`, {
205
+ const putRes = await fetch(init.upload_url, {
206
+ method: "PUT",
207
+ body: archive,
208
+ signal: AbortSignal.timeout(120_000),
209
+ });
210
+ if (!putRes.ok) {
211
+ const body = await putRes.text().catch(() => "");
212
+ throw new Error(`Upload failed with status ${putRes.status}${body ? `: ${body}` : ""} (version_id: ${init.version_id}, retry with the same command once the underlying issue is fixed).`);
213
+ }
214
+ await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions/complete`, {
99
215
  method: "POST",
100
216
  headers: { "Content-Type": "application/json" },
101
217
  body: JSON.stringify({ version_id: init.version_id }),
102
218
  });
103
- console.log(`Published ${owner}/${slug}@${complete.version} (${complete.status})`);
219
+ console.log(`Uploaded ${owner}/${skill}@${version} -- processing...`);
220
+ const result = await pollVersionStatus(owner, skill, version);
221
+ if (result.status === "failed") {
222
+ throw new Error(`Publish failed: ${result.failure_reason ?? "unknown reason"}`);
223
+ }
224
+ console.log(`Published ${owner}/${skill}@${result.version} (${result.status})`);
104
225
  }
@@ -1,25 +1,30 @@
1
- import { rmSync, rmdirSync, readdirSync, existsSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { existsSync, readdirSync, rmSync, rmdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
3
  import { removeLockfileEntry } from "../lockfile.js";
4
+ import { LOCKFILE_PATH, parseOwnerSkill, skillDir } from "../spec.js";
5
+ const USAGE = "Usage: ahood remove <owner>/<skill>";
4
6
  export async function remove(args) {
5
7
  const spec = args[0];
6
8
  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))
9
+ throw new Error(USAGE);
10
+ const { owner, skill } = parseOwnerSkill(spec, USAGE);
11
+ const key = `${owner}/${skill}`;
12
+ const dir = skillDir(owner, skill);
13
+ const dirExisted = existsSync(dir);
14
+ if (dirExisted)
15
15
  rmSync(dir, { recursive: true, force: true });
16
16
  // Sweep the owner directory once it holds nothing, so uninstalling an
17
17
  // owner's last skill doesn't leave an empty namespace folder behind (npm
18
18
  // does the same with node_modules/@scope). Guarded on emptiness, so another
19
19
  // skill by the same owner is never touched.
20
- const ownerDir = join(".claude", "skills", owner);
20
+ const ownerDir = dirname(dir);
21
21
  if (existsSync(ownerDir) && readdirSync(ownerDir).length === 0)
22
22
  rmdirSync(ownerDir);
23
- removeLockfileEntry(join(".claude", "skills.lock.json"), spec);
24
- console.log(`Removed ${spec}`);
23
+ const hadLockfileEntry = removeLockfileEntry(LOCKFILE_PATH, key);
24
+ if (!dirExisted && !hadLockfileEntry) {
25
+ console.error(`${key} was not installed -- nothing to remove.`);
26
+ process.exitCode = 1;
27
+ return;
28
+ }
29
+ console.log(`Removed ${key}`);
25
30
  }
@@ -1,14 +1,35 @@
1
1
  import { apiJson } from "../http.js";
2
+ import { flagValue } from "../flags.js";
3
+ const USAGE = "Usage: ahood search <query> [--json] [--limit <n>]";
2
4
  export async function search(args) {
3
- const query = args.join(" ");
5
+ const jsonOutput = args.includes("--json");
6
+ const limit = flagValue(args, "--limit");
7
+ const queryParts = args.filter((a, i) => a !== "--json" && a !== "--limit" && args[i - 1] !== "--limit");
8
+ const unknownFlag = queryParts.find((a) => a.startsWith("--"));
9
+ if (unknownFlag)
10
+ throw new Error(`Unknown flag: ${unknownFlag}\n${USAGE}`);
11
+ const query = queryParts.join(" ");
4
12
  if (!query)
5
- throw new Error("Usage: ahood search <query>");
6
- const { skills } = await apiJson(`/api/v1/skills?q=${encodeURIComponent(query)}`);
13
+ throw new Error(USAGE);
14
+ if (limit !== undefined && (!/^\d+$/.test(limit) || Number(limit) < 1)) {
15
+ throw new Error(`--limit must be a positive integer (got "${limit}").\n${USAGE}`);
16
+ }
17
+ const qs = new URLSearchParams({ q: query });
18
+ if (limit !== undefined)
19
+ qs.set("limit", limit);
20
+ const { skills } = await apiJson(`/api/v1/skills?${qs}`);
21
+ if (jsonOutput) {
22
+ console.log(JSON.stringify(skills));
23
+ return;
24
+ }
7
25
  if (skills.length === 0) {
8
26
  console.log("No skills found.");
9
27
  return;
10
28
  }
11
29
  for (const skill of skills) {
12
- console.log(`${skill.profiles.username}/${skill.slug} — ${skill.name}${skill.tagline ? `: ${skill.tagline}` : ""} (${skill.downloads_count} downloads)`);
30
+ console.log(`${skill.profiles.username}/${skill.slug} - ${skill.name}${skill.tagline ? `: ${skill.tagline}` : ""} (${skill.downloads_count} downloads)`);
31
+ }
32
+ if (limit !== undefined && skills.length >= Number(limit)) {
33
+ console.log(`(showing up to ${limit} results -- pass a higher --limit for more)`);
13
34
  }
14
35
  }
@@ -1,26 +1,23 @@
1
1
  import { apiJson } from "../http.js";
2
+ import { parseOwnerSkill } from "../spec.js";
2
3
  // Mirrors POST/DELETE /api/v1/skills/{owner}/{skill}/star -- both are
3
4
  // idempotent server-side (POST upserts on the stars table's
4
5
  // unique(skill_id, user_id) constraint with ignoreDuplicates, DELETE just
5
6
  // deletes any matching row), so starring an already-starred skill or
6
7
  // unstarring one you never starred is a no-op 200, not an error.
7
8
  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" });
9
+ const USAGE = "Usage: ahood star <owner>/<skill>";
10
+ const { owner, skill } = parseOwnerSkill(args[0] ?? "", USAGE);
11
+ await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/star`, {
12
+ method: "POST",
13
+ });
15
14
  console.log(`Starred ${owner}/${skill}.`);
16
15
  }
17
16
  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" });
17
+ const USAGE = "Usage: ahood unstar <owner>/<skill>";
18
+ const { owner, skill } = parseOwnerSkill(args[0] ?? "", USAGE);
19
+ await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/star`, {
20
+ method: "DELETE",
21
+ });
25
22
  console.log(`Unstarred ${owner}/${skill}.`);
26
23
  }
@@ -5,11 +5,11 @@ export async function token(args) {
5
5
  case "create":
6
6
  return tokenCreate(rest);
7
7
  case "list":
8
- return tokenList();
8
+ return tokenList(rest);
9
9
  case "revoke":
10
10
  return tokenRevoke(rest);
11
11
  default:
12
- throw new Error("Usage: ahood token create|list|revoke");
12
+ throw new Error("Usage: ahood token create <name>|list [--json]|revoke <id>");
13
13
  }
14
14
  }
15
15
  async function tokenCreate(args) {
@@ -32,20 +32,25 @@ async function tokenCreate(args) {
32
32
  console.log(`Created token "${result.name}": ${result.token}`);
33
33
  console.log("Copy this now -- it will not be shown again.");
34
34
  }
35
- async function tokenList() {
35
+ async function tokenList(args) {
36
+ const jsonOutput = args.includes("--json");
36
37
  const { tokens } = await apiJson("/api/v1/auth/tokens");
38
+ if (jsonOutput) {
39
+ console.log(JSON.stringify(tokens));
40
+ return;
41
+ }
37
42
  if (tokens.length === 0) {
38
43
  console.log("No tokens.");
39
44
  return;
40
45
  }
41
46
  for (const t of tokens) {
42
- console.log(`${t.id} ${t.name} ${t.token_prefix}… ${t.scopes.join(",")}${t.revoked_at ? " (revoked)" : ""}`);
47
+ console.log(`${t.id} ${t.name} ${t.token_prefix}... ${t.scopes.join(",")}${t.revoked_at ? " (revoked)" : ""}`);
43
48
  }
44
49
  }
45
50
  async function tokenRevoke(args) {
46
51
  const id = args[0];
47
52
  if (!id)
48
53
  throw new Error("Usage: ahood token revoke <id>");
49
- await apiJson(`/api/v1/auth/tokens/${id}`, { method: "DELETE" });
54
+ await apiJson(`/api/v1/auth/tokens/${encodeURIComponent(id)}`, { method: "DELETE" });
50
55
  console.log(`Revoked ${id}`);
51
56
  }
@@ -1,32 +1,49 @@
1
- import { createInterface } from "node:readline/promises";
1
+ import { createInterface } from "node:readline";
2
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) {
3
+ import { parseOwnerSkill } from "../spec.js";
4
+ const USAGE = "Usage: ahood unpublish <owner>/<skill> [--yes]";
5
+ // On closed/non-interactive stdin, readline/promises' rl.question() promise
6
+ // never settles -- but since nothing else keeps the event loop alive at that
7
+ // point, the process used to exit 0 anyway once the loop drained, having
8
+ // printed only the prompt (no "Aborted.", no deletion, no error: a silent
9
+ // false success). The plain callback-style API is used instead specifically
10
+ // so the "close" listener can be a reliable signal for "closed without ever
11
+ // answering": `settled` is set synchronously inside the answer callback,
12
+ // before rl.close() is called, so that same close doesn't self-reject.
13
+ function confirm(promptText) {
9
14
  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
- }
15
+ return new Promise((resolve, reject) => {
16
+ let settled = false;
17
+ rl.once("close", () => {
18
+ if (settled)
19
+ return;
20
+ settled = true;
21
+ reject(new Error("Confirmation was not answered (stdin closed)."));
22
+ });
23
+ rl.question(promptText, (answer) => {
24
+ settled = true;
25
+ rl.close();
26
+ resolve(answer.trim().toLowerCase() === "yes");
27
+ });
28
+ });
17
29
  }
18
30
  export async function unpublish(args) {
19
- const spec = args[0];
31
+ const yes = args.includes("--yes");
32
+ const spec = args.find((a) => !a.startsWith("--"));
20
33
  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: `);
34
+ throw new Error(USAGE);
35
+ const { owner, skill } = parseOwnerSkill(spec, USAGE);
36
+ const key = `${owner}/${skill}`;
37
+ // --yes bypasses the prompt for scripts/CI; otherwise a closed stdin (no
38
+ // TTY, no piped answer either) now throws via confirm()'s "close" handling
39
+ // instead of silently exiting 0 with nothing prompted, confirmed, or done.
40
+ const confirmed = yes
41
+ ? true
42
+ : await confirm(`This will permanently delete ${key} for everyone who has installed it. Type "yes" to confirm: `);
26
43
  if (!confirmed) {
27
44
  console.log("Aborted.");
28
45
  return;
29
46
  }
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.`);
47
+ await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`, { method: "DELETE" });
48
+ console.log(`Unpublished ${key}. Run \`ahood remove ${key}\` to also remove your local copy.`);
32
49
  }
@@ -1,20 +1,37 @@
1
+ import { LOCKFILE_PATH } from "../spec.js";
1
2
  import { readLockfile } from "../lockfile.js";
2
3
  import { add } from "./add.js";
3
4
  // `update` is the only command that moves the lockfile pin forward -- `add`
4
5
  // always resolves and pins whatever version it's given (or latest, once,
5
6
  // at install time), matching the ADR's "reproducible by default" design.
6
7
  export async function update(args) {
7
- const lockfile = readLockfile(".claude/skills.lock.json");
8
+ const lockfile = readLockfile(LOCKFILE_PATH);
8
9
  const targets = args.length > 0 ? args : Object.keys(lockfile);
9
10
  if (targets.length === 0) {
10
11
  console.log("No installed skills to update.");
11
12
  return;
12
13
  }
14
+ const failures = [];
13
15
  for (const ownerSlashSkill of targets) {
14
- if (!lockfile[ownerSlashSkill]) {
16
+ if (!lockfile[ownerSlashSkill] && args.length === 0) {
17
+ // Only reachable when targets came from the lockfile itself, so this
18
+ // would mean the lockfile changed under us mid-loop -- skip, don't abort.
15
19
  console.warn(`Skipping ${ownerSlashSkill}: not currently installed.`);
16
20
  continue;
17
21
  }
18
- await add([ownerSlashSkill]); // no @version -- resolves to latest again
22
+ try {
23
+ await add([ownerSlashSkill]); // no @version -- resolves to latest again
24
+ }
25
+ catch (error) {
26
+ // One skill being removed/yanked/unreachable must not stop every other
27
+ // skill in the batch from updating.
28
+ const message = error instanceof Error ? error.message : String(error);
29
+ console.error(`Failed to update ${ownerSlashSkill}: ${message}`);
30
+ failures.push(ownerSlashSkill);
31
+ }
32
+ }
33
+ if (failures.length > 0) {
34
+ console.error(`${failures.length} of ${targets.length} skill(s) failed to update: ${failures.join(", ")}`);
35
+ process.exitCode = 1;
19
36
  }
20
37
  }
@@ -0,0 +1,49 @@
1
+ import { spawn } from "node:child_process";
2
+ import { apiJson } from "../http.js";
3
+ import { getApiUrl } from "../config.js";
4
+ import { parseOwnerSkill } from "../spec.js";
5
+ const USAGE = "Usage: ahood view <owner>/<skill> [--json] [--web]";
6
+ // Spawned with argv as an array (not a shell string), so a crafted owner/skill
7
+ // can never inject shell syntax -- and both segments are already restricted
8
+ // to a safe charset by parseOwnerSkill before this ever runs.
9
+ function openBrowser(url) {
10
+ try {
11
+ if (process.platform === "win32") {
12
+ spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true }).unref();
13
+ }
14
+ else if (process.platform === "darwin") {
15
+ spawn("open", [url], { stdio: "ignore", detached: true }).unref();
16
+ }
17
+ else {
18
+ spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref();
19
+ }
20
+ }
21
+ catch {
22
+ console.log(`Open this URL in your browser: ${url}`);
23
+ }
24
+ }
25
+ export async function view(args) {
26
+ const jsonOutput = args.includes("--json");
27
+ const web = args.includes("--web");
28
+ const spec = args.find((a) => !a.startsWith("--"));
29
+ if (!spec)
30
+ throw new Error(USAGE);
31
+ const { owner, skill } = parseOwnerSkill(spec, USAGE);
32
+ const url = `${getApiUrl()}/${owner}/${skill}`;
33
+ if (web) {
34
+ openBrowser(url);
35
+ return;
36
+ }
37
+ const detail = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`);
38
+ if (jsonOutput) {
39
+ console.log(JSON.stringify(detail));
40
+ return;
41
+ }
42
+ console.log(`${owner}/${detail.slug} (${detail.visibility})`);
43
+ console.log(detail.name);
44
+ if (detail.tagline)
45
+ console.log(detail.tagline);
46
+ console.log(`${detail.downloads_count} downloads, ${detail.stars_count} stars`);
47
+ console.log(detail.skill_versions ? `latest version: ${detail.skill_versions.version}` : "no published version");
48
+ console.log(url);
49
+ }
@@ -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
  }