@ahood/cli 0.7.3 → 0.8.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 CHANGED
@@ -16,7 +16,7 @@ alice/pdf-tools Merge, split, and compress PDFs from the command line
16
16
  bob/pdf-form-filler Fill PDF form fields from a JSON or CSV data source
17
17
 
18
18
  $ ahood skill add alice/pdf-tools
19
- Installed alice/pdf-tools@1.4.0 to .claude/skills/alice/pdf-tools
19
+ Installed alice/pdf-tools@1.4.0 to .claude/skills/alice@pdf-tools
20
20
  ```
21
21
 
22
22
  ---
@@ -85,7 +85,7 @@ Paths `ahood` reads and writes in your project, none of which need to be gitigno
85
85
 
86
86
  | Path | What's in it |
87
87
  | --- | --- |
88
- | `.claude/skills/<owner>/<skill>/` | Installed skill files. |
88
+ | `.claude/skills/<owner>@<skill>/` | Installed skill files. |
89
89
  | `.claude/agents/<owner>@<skill>.md` | An installed agent, as a single file (`@` owner/skill separator, since Claude Code's own subagent loader scans `.claude/agents/*.md` flat, non-recursively). |
90
90
  | `.claude/skills.lock.json` | Exact installed version + checksum per skill or agent, written by `add`/`update`, read by every install to verify integrity. |
91
91
 
@@ -116,7 +116,7 @@ description: Merge, split, and compress PDFs from the command line
116
116
  ...
117
117
 
118
118
  $ ahood skill add alice/pdf-tools@1.4.0
119
- Installed alice/pdf-tools@1.4.0 to .claude/skills/alice/pdf-tools
119
+ Installed alice/pdf-tools@1.4.0 to .claude/skills/alice@pdf-tools
120
120
  ```
121
121
  </details>
122
122
 
@@ -216,7 +216,7 @@ Sharing is additive -- it doesn't change `alice/pdf-tools`'s own public/private
216
216
  | `ahood skill add <owner>/<skill>[@version]` | Install a skill into .claude/skills/, pinned in the lockfile. |
217
217
  | `ahood skill update [<owner>/<skill> ...] [--dry-run] [--json]` | Move the lockfile pin(s) forward to the latest version, for one skill or all installed skills at once. |
218
218
  | `ahood skill outdated [<owner>/<skill> ...] [--json]` | Read-only staleness check comparing current and latest versions (with changelog) for installed skills. |
219
- | `ahood skill remove <owner>/<skill>` | Uninstall and unpin a skill (local only). |
219
+ | `ahood skill remove <owner>/<skill> [--yes]` | Uninstall and unpin a skill (local only, prompts for confirmation unless --yes is passed). |
220
220
  | `ahood skill edit <owner>/<skill> [--tagline] [--tags] [--license] [--visibility] [--homepage] [--repository]` | Update a skill you own, changing only the flags you pass. |
221
221
  | `ahood skill unpublish <owner>/<skill>[@version] [--yes]` | Delete a skill from the registry for every consumer, or yank a single version, not just your local install (prompts for confirmation unless --yes is passed). |
222
222
  | `ahood skill star <owner>/<skill>` | Star a skill. |
@@ -235,12 +235,24 @@ Sharing is additive -- it doesn't change `alice/pdf-tools`'s own public/private
235
235
  | `ahood group members <group> [--json]` | List a group's members and their role (owner-only visible to members). |
236
236
  | `ahood group invite-link <group> [--json]` | Create (or regenerate) a shareable invite link for a group you own. |
237
237
  | `ahood group join <invite-url-or-token>` | Join a group using an invite link or its raw token. |
238
- | `ahood group remove-member <group> <username>` | Remove a member from a group you own. |
239
- | `ahood group leave <group>` | Leave a group you belong to. |
238
+ | `ahood group remove-member <group> <username> [--yes]` | Remove a member from a group you own (prompts for confirmation unless --yes is passed). |
239
+ | `ahood group leave <group> [--yes]` | Leave a group you belong to (prompts for confirmation unless --yes is passed). |
240
240
  | `ahood group delete <group> [--yes]` | Permanently delete a group you own (prompts for confirmation unless --yes is passed). |
241
+
242
+ ### Snap
243
+
244
+ | Command | What it does |
245
+ | --- | --- |
246
+ | `ahood snap create <content> [--json]` | Capture a new private snap, from an argument or piped stdin. |
247
+ | `ahood snap list [--json] [--limit <n>]` | List your own snaps, most recent first. |
248
+ | `ahood snap search <query> [--json] [--limit <n>]` | Search your own snaps by content. |
249
+ | `ahood snap show <id> [--json]` | Print a single snap's full content. |
250
+ | `ahood snap remove <id> [--yes]` | Permanently delete a snap (prompts for confirmation unless --yes is passed). |
251
+ | `ahood snap share <id> [--json]` | Mint (or return the existing) shareable link for a snap. |
252
+ | `ahood snap unshare <id> [--yes]` | Revoke a snap's share link (the snap itself is untouched; prompts for confirmation unless --yes is passed). |
241
253
  <!-- COMMANDS_TABLE_END -->
242
254
 
243
- Run `ahood --help`, `ahood skill --help`, `ahood group --help`, or `ahood <command> --help` (also `ahood help <command>` / `ahood help skill <verb>` / `ahood help group <verb>`) for the same reference — including per-command flags and examples — directly in your terminal. `ahood --version` prints the installed CLI version.
255
+ Run `ahood --help`, `ahood skill --help`, `ahood group --help`, `ahood snap --help`, or `ahood <command> --help` (also `ahood help <command>` / `ahood help skill <verb>` / `ahood help group <verb>` / `ahood help snap <verb>`) for the same reference — including per-command flags and examples — directly in your terminal. `ahood --version` prints the installed CLI version.
244
256
 
245
257
  ## Exit codes
246
258
 
@@ -3,15 +3,21 @@ import { mkdirSync, renameSync, rmSync, rmdirSync, writeFileSync, readFileSync,
3
3
  import { basename, dirname, join, resolve, sep } from "node:path";
4
4
  import * as tarStream from "tar-stream";
5
5
  import { gunzipSync } from "node:zlib";
6
- import { apiFetch, apiJson } from "../http.js";
6
+ import { apiFetch, apiJson, ApiError, sanitizeErrorMessage } from "../http.js";
7
7
  import { LOCKFILE_PATH, parseOwnerSkillVersion, skillDir, agentPath, AGENTS_ROOT, MCP_CONFIG_PATH } from "../spec.js";
8
- import { readLockfile, writeLockfileEntry, withLock, writeJsonFileAtomic } from "../lockfile.js";
8
+ import { readLockfile, withLock, writeJsonFileAtomic, writeLockfileEntryVerifyingChecksum, LockfileChecksumConflictError, } from "../lockfile.js";
9
9
  import { promptSecret } from "../secret-prompt.js";
10
10
  import { UsageError } from "../usage-error.js";
11
11
  const USAGE = "Usage: ahood skill add <owner>/<skill>[@version]";
12
12
  const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024; // 50 MB compressed
13
13
  const MAX_EXTRACTED_BYTES = 200 * 1024 * 1024; // 200 MB decompressed
14
14
  const MAX_ENTRY_COUNT = 10_000;
15
+ // Shared wording for the checksum-pin tamper-detection guard, used both by
16
+ // the early unlocked fast-fail check in add() and by the real,
17
+ // concurrency-safe enforcement at each write site below (ahood-cli#101).
18
+ function checksumConflictMessage(key, meta, existing) {
19
+ return `Refusing to install ${key}@${meta.version}: its checksum (${meta.checksum_sha256}) does not match the one already pinned in the lockfile (${existing.checksum_sha256}) for this exact version. If you trust this change, remove ${key}'s lockfile entry first.`;
20
+ }
15
21
  // GET /api/v1/skills/{owner}/{skill}/versions/{version} matches the version
16
22
  // string with an exact .eq() (verified live against this branch's route) --
17
23
  // there is no "latest" literal in skill_versions.version, so requesting
@@ -398,7 +404,22 @@ async function installMcpEntry(owner, skill, meta, buffer) {
398
404
  fileContents.mcpServers[skill] = serverConfig;
399
405
  writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents);
400
406
  });
401
- writeLockfileEntry(LOCKFILE_PATH, `${owner}/${skill}`, { version: meta.version, checksum_sha256: meta.checksum_sha256 });
407
+ const mcpKey = `${owner}/${skill}`;
408
+ try {
409
+ writeLockfileEntryVerifyingChecksum(LOCKFILE_PATH, mcpKey, { version: meta.version, checksum_sha256: meta.checksum_sha256 });
410
+ }
411
+ catch (error) {
412
+ if (!(error instanceof LockfileChecksumConflictError))
413
+ throw error;
414
+ // Roll back the .mcp.json merge above under the same lock pattern --
415
+ // re-read+delete rather than assuming nothing else changed it since.
416
+ withLock(MCP_CONFIG_PATH, () => {
417
+ const fileContents = readMcpConfig();
418
+ delete fileContents.mcpServers[skill];
419
+ writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents);
420
+ });
421
+ throw new Error(checksumConflictMessage(mcpKey, meta, error.existing));
422
+ }
402
423
  console.log(`Installed ${owner}/${skill}@${meta.version} into ${MCP_CONFIG_PATH} as "${skill}"`);
403
424
  if (Object.keys(secretEnv).length > 0) {
404
425
  console.warn(`WARNING: ${MCP_CONFIG_PATH} now contains one or more secret values in plaintext -- do not commit it to version control.`);
@@ -412,8 +433,14 @@ async function installMcpEntry(owner, skill, meta, buffer) {
412
433
  export async function downloadVerifiedArchive(owner, skill, meta) {
413
434
  const downloadRes = await apiFetch(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/download?version=${encodeURIComponent(meta.version)}`, { headers: { "X-Ahood-Source": "cli" }, redirect: "follow" });
414
435
  if (!downloadRes.ok) {
415
- const body = await downloadRes.text().catch(() => "");
416
- throw new Error(`Download failed with status ${downloadRes.status}${body ? `: ${body}` : ""}`);
436
+ // Same host as every other apiJson call, so this must throw ApiError
437
+ // (not a plain Error) to get the right exitCodeFor mapping (401/404/5xx),
438
+ // and route the body through sanitizeErrorMessage -- an unsanitized WAF/
439
+ // proxy HTML page would otherwise dump straight to the terminal here,
440
+ // exactly what that function exists to prevent elsewhere (ahood-cli#102).
441
+ const rawBody = await downloadRes.text().catch(() => "");
442
+ const body = rawBody ? sanitizeErrorMessage(rawBody) : "";
443
+ throw new ApiError(downloadRes.status, `Download failed with status ${downloadRes.status}${body ? `: ${body}` : ""}`);
417
444
  }
418
445
  const buffer = await readBoundedBody(downloadRes, MAX_DOWNLOAD_BYTES);
419
446
  const actualChecksum = createHash("sha256").update(buffer).digest("hex");
@@ -433,10 +460,15 @@ export async function add(args) {
433
460
  // with; if a later fetch of the "same" version disagrees, either the
434
461
  // published artifact changed after being pinned or the registry response
435
462
  // was tampered with in transit -- either way this is not something to
436
- // silently accept and overwrite.
463
+ // silently accept and overwrite. This early, UNLOCKED read is a fast-fail
464
+ // optimization only (skip an entire download for the common, non-
465
+ // concurrent case) -- it provides no real guarantee under concurrent
466
+ // installs. The actual enforcement is the locked, read-check-write
467
+ // writeLockfileEntryVerifyingChecksum call at each write site below
468
+ // (ahood-cli#101).
437
469
  const existingEntry = readLockfile(LOCKFILE_PATH)[key];
438
470
  if (existingEntry && existingEntry.version === meta.version && existingEntry.checksum_sha256 !== meta.checksum_sha256) {
439
- throw new Error(`Refusing to install ${key}@${meta.version}: its checksum (${meta.checksum_sha256}) does not match the one already pinned in the lockfile (${existingEntry.checksum_sha256}) for this exact version. If you trust this change, remove ${key}'s lockfile entry first.`);
471
+ throw new Error(checksumConflictMessage(key, meta, existingEntry));
440
472
  }
441
473
  if (meta.yanked_at) {
442
474
  console.warn(`WARNING: ${key}@${meta.version} has been yanked. Installing anyway.`);
@@ -458,7 +490,15 @@ export async function add(args) {
458
490
  mkdirSync(AGENTS_ROOT, { recursive: true });
459
491
  const destPath = agentPath(owner, skill);
460
492
  writeFileSync(destPath, content);
461
- writeLockfileEntry(LOCKFILE_PATH, key, { version: meta.version, checksum_sha256: meta.checksum_sha256 });
493
+ try {
494
+ writeLockfileEntryVerifyingChecksum(LOCKFILE_PATH, key, { version: meta.version, checksum_sha256: meta.checksum_sha256 });
495
+ }
496
+ catch (error) {
497
+ if (!(error instanceof LockfileChecksumConflictError))
498
+ throw error;
499
+ rmSync(destPath, { force: true });
500
+ throw new Error(checksumConflictMessage(key, meta, error.existing));
501
+ }
462
502
  console.log(`Installed ${key}@${meta.version} to ${destPath}`);
463
503
  return;
464
504
  }
@@ -466,12 +506,21 @@ export async function add(args) {
466
506
  await installMcpEntry(owner, skill, meta, buffer);
467
507
  return;
468
508
  }
469
- // Owner-namespaced on disk, mirroring npm's node_modules/@scope/package.
509
+ // Flat, owner@skill-joined directory name so Claude Code's own one-level-deep
510
+ // project-skill scan actually discovers it (see skillDir's comment in spec.ts).
470
511
  const destDir = skillDir(owner, skill);
471
512
  await extractFreshVersion(buffer, destDir);
472
- writeLockfileEntry(LOCKFILE_PATH, key, {
473
- version: meta.version,
474
- checksum_sha256: meta.checksum_sha256,
475
- });
513
+ try {
514
+ writeLockfileEntryVerifyingChecksum(LOCKFILE_PATH, key, {
515
+ version: meta.version,
516
+ checksum_sha256: meta.checksum_sha256,
517
+ });
518
+ }
519
+ catch (error) {
520
+ if (!(error instanceof LockfileChecksumConflictError))
521
+ throw error;
522
+ rmSync(destDir, { recursive: true, force: true });
523
+ throw new Error(checksumConflictMessage(key, meta, error.existing));
524
+ }
476
525
  console.log(`Installed ${key}@${meta.version} to ${destDir}`);
477
526
  }
@@ -8,8 +8,8 @@ const LIST_USAGE = "Usage: ahood group list [--json]";
8
8
  const MEMBERS_USAGE = "Usage: ahood group members <group> [--json]";
9
9
  const INVITE_LINK_USAGE = "Usage: ahood group invite-link <group> [--json]";
10
10
  const JOIN_USAGE = "Usage: ahood group join <invite-url-or-token>";
11
- const REMOVE_MEMBER_USAGE = "Usage: ahood group remove-member <group> <username>";
12
- const LEAVE_USAGE = "Usage: ahood group leave <group>";
11
+ const REMOVE_MEMBER_USAGE = "Usage: ahood group remove-member <group> <username> [--yes]";
12
+ const LEAVE_USAGE = "Usage: ahood group leave <group> [--yes]";
13
13
  const DELETE_USAGE = "Usage: ahood group delete <group> [--yes]";
14
14
  // Creates a group (caller becomes owner). Follows edit.ts's convention of
15
15
  // requiring the positional argument first, since --description takes a
@@ -115,10 +115,22 @@ export async function joinGroup(args) {
115
115
  // "remove yourself"). No flags today, so plain positional filtering is
116
116
  // unambiguous.
117
117
  export async function removeMember(args) {
118
+ const yes = args.includes("--yes");
118
119
  const positional = args.filter((a) => !a.startsWith("--"));
119
120
  const [slug, username] = positional;
120
121
  if (!slug || !username)
121
122
  throw new UsageError(REMOVE_MEMBER_USAGE);
123
+ // Mirrors deleteGroup's confirm-before-destroy pattern (CLAUDE.md:
124
+ // "Destructive commands ... prompt for confirmation unless --yes is
125
+ // passed") -- a typo'd username here instantly removes the wrong member
126
+ // with no other command to undo it (ahood-cli#99).
127
+ const confirmed = yes
128
+ ? true
129
+ : await confirm(`Remove ${username} from ${slug}? Type "yes" to confirm: `);
130
+ if (!confirmed) {
131
+ console.log("Aborted.");
132
+ return;
133
+ }
122
134
  await apiJson(`/api/v1/groups/${encodeURIComponent(slug)}/members/${encodeURIComponent(username)}`, { method: "DELETE" });
123
135
  console.log(`Removed ${username} from ${slug}.`);
124
136
  }
@@ -134,9 +146,18 @@ async function getOwnUsername() {
134
146
  // handles both cases identically (DELETE .../members/{username} with your
135
147
  // own username removes you, same as an owner removing someone else).
136
148
  export async function leaveGroup(args) {
149
+ const yes = args.includes("--yes");
137
150
  const slug = args.find((a) => !a.startsWith("--"));
138
151
  if (!slug)
139
152
  throw new UsageError(LEAVE_USAGE);
153
+ // Same confirm-before-destroy gate as removeMember -- an accidentally
154
+ // re-run `leave` drops you from a private group with no other command to
155
+ // undo it (ahood-cli#99).
156
+ const confirmed = yes ? true : await confirm(`Leave ${slug}? Type "yes" to confirm: `);
157
+ if (!confirmed) {
158
+ console.log("Aborted.");
159
+ return;
160
+ }
140
161
  const username = await getOwnUsername();
141
162
  await apiJson(`/api/v1/groups/${encodeURIComponent(slug)}/members/${encodeURIComponent(username)}`, { method: "DELETE" });
142
163
  console.log(`Left ${slug}.`);
@@ -29,7 +29,7 @@ function normalizeToSlug(name) {
29
29
  // documented Claude Code skill format: YAML frontmatter delimited by `---`
30
30
  // lines, with a `name` and a `description` field -- the same shape every
31
31
  // real SKILL.md in this ecosystem uses (see e.g. any installed skill under
32
- // .claude/skills/<owner>/<skill>/SKILL.md). Keeping to exactly those two
32
+ // .claude/skills/<owner>@<skill>/SKILL.md). Keeping to exactly those two
33
33
  // fields, rather than inventing extra ones, means nothing here needs
34
34
  // updating just because publish.ts's own validation happens to be looser
35
35
  // today.
@@ -15,6 +15,10 @@ export async function listSkills(args = []) {
15
15
  return;
16
16
  }
17
17
  for (const skill of skills) {
18
- console.log(`${skill.profiles.username}/${skill.slug} (${skill.visibility}) - ${skill.name}${skill.tagline ? `: ${skill.tagline}` : ""} (${skill.downloads_count} downloads, ${skill.stars_count} stars)`);
18
+ // profiles comes from a server-side join that can plausibly be null for
19
+ // an individual row -- degrade that one row instead of crashing the
20
+ // whole command (ahood-cli#106).
21
+ const username = skill.profiles?.username ?? "(unknown)";
22
+ console.log(`${username}/${skill.slug} (${skill.visibility}) - ${skill.name}${skill.tagline ? `: ${skill.tagline}` : ""} (${skill.downloads_count} downloads, ${skill.stars_count} stars)`);
19
23
  }
20
24
  }
@@ -21,7 +21,6 @@ export async function login() {
21
21
  while (Date.now() < deadline) {
22
22
  await sleep(2000);
23
23
  let res;
24
- let body;
25
24
  try {
26
25
  // Poll against the CONFIGURED API host (getApiUrl()), not a URL derived
27
26
  // from the server-supplied verification_url -- that field is only ever
@@ -29,15 +28,33 @@ export async function login() {
29
28
  // or redirected registry response can't point this at a host that
30
29
  // then harvests the device code and hands back an attacker's token.
31
30
  res = await apiFetchWithTimeout(`/api/v1/auth/cli/device/${encodeURIComponent(code)}`);
31
+ }
32
+ catch (error) {
33
+ // A THROWN fetch (DNS blip, dropped socket) is transient by nature, and
34
+ // this loop runs for up to ten minutes while a human walks to their
35
+ // browser -- one bad network moment must not kill a login that is
36
+ // about to succeed. The deadline is untouched, so this cannot loop
37
+ // forever.
38
+ console.error(`Polling failed (${error instanceof Error ? error.message : String(error)}); retrying...`);
39
+ continue;
40
+ }
41
+ // Checked BEFORE res.json(): these are terminal statuses regardless of
42
+ // body shape, so a non-JSON body on them (e.g. a proxy/WAF error page --
43
+ // the same threat class sanitizeErrorMessage guards against elsewhere)
44
+ // must fail fast here instead of throwing inside the try below and being
45
+ // swallowed as "transient, retry" for up to ten minutes (ahood-cli#104).
46
+ if (res.status === 410 || res.status === 404) {
47
+ throw new Error("This login was cancelled or expired. Run `ahood login` again.");
48
+ }
49
+ let body;
50
+ try {
32
51
  body = (await res.json());
33
52
  }
34
53
  catch (error) {
35
- // A THROWN fetch (DNS blip, dropped socket, a body that isn't JSON) is
36
- // transient by nature, and this loop runs for up to ten minutes while a
37
- // human walks to their browser -- one bad network moment must not kill
38
- // a login that is about to succeed. HTTP *statuses* are still decided
39
- // below; only the transport failure is retried. The deadline is
40
- // untouched, so this cannot loop forever.
54
+ // A genuinely malformed/non-JSON body on a non-terminal status (e.g. a
55
+ // transient 502 while still pending) is the same kind of transient
56
+ // blip a thrown fetch above is -- retry rather than fail the whole
57
+ // login.
41
58
  console.error(`Polling failed (${error instanceof Error ? error.message : String(error)}); retrying...`);
42
59
  continue;
43
60
  }
@@ -46,9 +63,6 @@ export async function login() {
46
63
  console.log("Logged in.");
47
64
  return;
48
65
  }
49
- if (res.status === 410 || res.status === 404) {
50
- throw new Error("This login was cancelled or expired. Run `ahood login` again.");
51
- }
52
66
  // status === "pending" -- keep polling.
53
67
  }
54
68
  throw new Error("Login timed out. Run `ahood login` again.");
@@ -42,6 +42,10 @@ const USAGE = "Usage: ahood skill publish <owner>/<skill>@<version> [--path <dir
42
42
  // .npmrc / .yarnrc.yml / .netrc / .pypirc -- package-manager/network auth
43
43
  // tokens (npm_..., ghp_...) that don't match the server
44
44
  // scanner's regexes either.
45
+ // .envrc -- direnv's own file, commonly holding `export`ed secrets,
46
+ // exact-matched (not a `.env` prefix match -- "rc" makes it a
47
+ // distinct name, not a `.env.*` variant) since it doesn't
48
+ // match the server scanner's regexes either (ahood-cli#97).
45
49
  // id_rsa / id_ed25519 / id_ecdsa / id_dsa (+ .pub) / .ssh -- SSH keys.
46
50
  // .aws / .docker -- cloud and registry credentials.
47
51
  // .DS_Store -- noise.
@@ -53,6 +57,7 @@ const EXCLUDED_NAMES = new Set([
53
57
  ".yarnrc.yml",
54
58
  ".netrc",
55
59
  ".pypirc",
60
+ ".envrc",
56
61
  ".ssh",
57
62
  ".aws",
58
63
  ".docker",
@@ -46,5 +46,11 @@ export async function read(args) {
46
46
  // Plain mode prints the raw content verbatim -- no formatting/labels/
47
47
  // trailing decoration -- since the whole point is fast/pipeable access to
48
48
  // the exact prompt text (e.g. piping into a file or another tool).
49
- console.log(content);
49
+ // process.stdout.write, not console.log: console.log unconditionally
50
+ // appends its own "\n", so a well-formed SKILL.md that already ends in one
51
+ // (the normal case) came out with a second, spurious trailing blank line --
52
+ // a real, reported round-trip mismatch between "ahood skill read" and the
53
+ // source file, even though the registry itself stores/serves the content
54
+ // byte-exact (ahood-cli#90).
55
+ process.stdout.write(content);
50
56
  }
@@ -1,42 +1,44 @@
1
- import { existsSync, readdirSync, readFileSync, rmSync, rmdirSync, unlinkSync } from "node:fs";
2
- import { dirname } from "node:path";
3
- import { removeLockfileEntry } from "../lockfile.js";
1
+ import { existsSync, readFileSync, rmSync, unlinkSync } from "node:fs";
2
+ import { confirm } from "../confirm.js";
3
+ import { readLockfile, removeLockfileEntry } from "../lockfile.js";
4
4
  import { LOCKFILE_PATH, parseOwnerSkill, skillDir, agentPath, MCP_CONFIG_PATH } from "../spec.js";
5
5
  import { UsageError } from "../usage-error.js";
6
- const USAGE = "Usage: ahood skill remove <owner>/<skill>";
6
+ const USAGE = "Usage: ahood skill remove <owner>/<skill> [--yes]";
7
7
  export async function remove(args) {
8
8
  const spec = args[0];
9
9
  if (!spec)
10
10
  throw new UsageError(USAGE);
11
+ const yes = args.includes("--yes");
11
12
  const { owner, skill } = parseOwnerSkill(spec, USAGE);
12
13
  const key = `${owner}/${skill}`;
14
+ // Flat owner@skill directory (see skillDir's comment in spec.ts) -- no
15
+ // owner-namespace folder to sweep afterward, unlike the old nested layout.
13
16
  const dir = skillDir(owner, skill);
14
17
  const dirExisted = existsSync(dir);
15
- if (dirExisted)
16
- rmSync(dir, { recursive: true, force: true });
17
- // Sweep the owner directory once it holds nothing, so uninstalling an
18
- // owner's last skill doesn't leave an empty namespace folder behind (npm
19
- // does the same with node_modules/@scope). Guarded on emptiness, so another
20
- // skill by the same owner is never touched.
21
- const ownerDir = dirname(dir);
22
- if (existsSync(ownerDir) && readdirSync(ownerDir).length === 0)
23
- rmdirSync(ownerDir);
24
18
  // An agent installs as a single flat file (.claude/agents/<owner>@<skill>.md),
25
- // never a directory under .claude/skills/ -- skillDir's rmSync above never
26
- // touches it. Without this, removing an installed agent silently left the
27
- // file on disk (still loaded by Claude Code forever) while reporting
28
- // success and clearing the lockfile entry, so a later `update` wouldn't
29
- // catch it either (ahood-cli final review finding #2).
19
+ // never a directory under .claude/skills/ -- distinct from the dir check above.
30
20
  const agentFile = agentPath(owner, skill);
31
21
  const agentExisted = existsSync(agentFile);
32
- if (agentExisted)
33
- unlinkSync(agentFile);
34
- const hadLockfileEntry = removeLockfileEntry(LOCKFILE_PATH, key);
22
+ const hadLockfileEntry = key in readLockfile(LOCKFILE_PATH);
35
23
  if (!dirExisted && !agentExisted && !hadLockfileEntry) {
36
24
  console.error(`${key} was not installed -- nothing to remove.`);
37
25
  process.exitCode = 1;
38
26
  return;
39
27
  }
28
+ // Matches unpublish.ts/group.ts's confirm-before-destroy pattern (CLAUDE.md:
29
+ // "Destructive commands ... prompt for confirmation unless --yes is passed").
30
+ // Checked only once something is actually installed, so a no-op remove of an
31
+ // uninstalled skill never blocks on a prompt (ahood-cli#98).
32
+ const confirmed = yes ? true : await confirm(`Remove ${key} from this project? Type "yes" to confirm: `);
33
+ if (!confirmed) {
34
+ console.log("Aborted.");
35
+ return;
36
+ }
37
+ if (dirExisted)
38
+ rmSync(dir, { recursive: true, force: true });
39
+ if (agentExisted)
40
+ unlinkSync(agentFile);
41
+ removeLockfileEntry(LOCKFILE_PATH, key);
40
42
  // An mcp-kind install has no directory or agent file on disk -- its only
41
43
  // footprint here is the lockfile entry just cleared above and a live
42
44
  // entry in .mcp.json (which add.ts's installMcpEntry merged in, possibly
@@ -1,5 +1,5 @@
1
1
  import { apiJson } from "../http.js";
2
- import { flagValue } from "../flags.js";
2
+ import { flagValue, parseSearchQuery } from "../flags.js";
3
3
  import { UsageError } from "../usage-error.js";
4
4
  const USAGE = "Usage: ahood skill search <query> [--json] [--limit <n>]";
5
5
  export async function searchSkills(query, limit) {
@@ -12,13 +12,7 @@ export async function searchSkills(query, limit) {
12
12
  export async function search(args) {
13
13
  const jsonOutput = args.includes("--json");
14
14
  const limitStr = flagValue(args, "--limit");
15
- const queryParts = args.filter((a, i) => a !== "--json" && a !== "--limit" && args[i - 1] !== "--limit");
16
- const unknownFlag = queryParts.find((a) => a.startsWith("--"));
17
- if (unknownFlag)
18
- throw new UsageError(`Unknown flag: ${unknownFlag}\n${USAGE}`);
19
- const query = queryParts.join(" ");
20
- if (!query)
21
- throw new UsageError(USAGE);
15
+ const query = parseSearchQuery(args, USAGE);
22
16
  if (limitStr !== undefined && (!/^\d+$/.test(limitStr) || Number(limitStr) < 1)) {
23
17
  throw new UsageError(`--limit must be a positive integer (got "${limitStr}").\n${USAGE}`);
24
18
  }
@@ -33,7 +27,11 @@ export async function search(args) {
33
27
  return;
34
28
  }
35
29
  for (const skill of skills) {
36
- console.log(`${skill.profiles.username}/${skill.slug} - ${skill.name}${skill.tagline ? `: ${skill.tagline}` : ""} (${skill.downloads_count} downloads)`);
30
+ // profiles comes from a server-side join that can plausibly be null for
31
+ // an individual row (orphaned skill, deleted owner account) -- degrade
32
+ // that one row instead of crashing the whole command (ahood-cli#106).
33
+ const username = skill.profiles?.username ?? "(unknown)";
34
+ console.log(`${username}/${skill.slug} - ${skill.name}${skill.tagline ? `: ${skill.tagline}` : ""} (${skill.downloads_count} downloads)`);
37
35
  }
38
36
  if (limit !== undefined && skills.length >= limit) {
39
37
  console.log(`(showing up to ${limit} results -- pass a higher --limit for more)`);
@@ -0,0 +1,191 @@
1
+ import { apiJson } from "../http.js";
2
+ import { flagValue, parseSearchQuery } from "../flags.js";
3
+ import { confirm } from "../confirm.js";
4
+ import { UsageError } from "../usage-error.js";
5
+ const CREATE_USAGE = "Usage: ahood snap create <content> (or pipe content on stdin)";
6
+ const LIST_USAGE = "Usage: ahood snap list [--json] [--limit <n>]";
7
+ const SEARCH_USAGE = "Usage: ahood snap search <query> [--json] [--limit <n>]";
8
+ const SHOW_USAGE = "Usage: ahood snap show <id> [--json]";
9
+ const REMOVE_USAGE = "Usage: ahood snap remove <id> [--yes]";
10
+ const SHARE_USAGE = "Usage: ahood snap share <id> [--json]";
11
+ const UNSHARE_USAGE = "Usage: ahood snap unshare <id> [--yes]";
12
+ // There's no existing "read stdin to completion" helper in this codebase to
13
+ // reuse -- confirm.ts and secret-prompt.ts both only ever read a single
14
+ // line via readline, never an arbitrary-length paste/pipe. Standard Node
15
+ // read-to-end pattern: accumulate 'data' chunks until 'end'.
16
+ function readStdin() {
17
+ return new Promise((resolve, reject) => {
18
+ let data = "";
19
+ process.stdin.setEncoding("utf8");
20
+ process.stdin.on("data", (chunk) => {
21
+ data += chunk;
22
+ });
23
+ process.stdin.on("end", () => resolve(data));
24
+ process.stdin.on("error", reject);
25
+ });
26
+ }
27
+ function validateLimit(limitStr, usage) {
28
+ if (limitStr !== undefined && (!/^\d+$/.test(limitStr) || Number(limitStr) < 1)) {
29
+ throw new UsageError(`--limit must be a positive integer (got "${limitStr}").\n${usage}`);
30
+ }
31
+ }
32
+ // Shared by list/search -- both print the same one-line-per-snap shape and
33
+ // the same --json passthrough (the raw `snaps` array, matching
34
+ // search.ts/list.ts's convention of emitting just the result array, not the
35
+ // whole {snaps, next_cursor} envelope -- neither command exposes cursor-based
36
+ // pagination today, only --limit, per ahood-cli#107's spec).
37
+ function printSnaps(jsonOutput, snaps, emptyMessage) {
38
+ if (jsonOutput) {
39
+ console.log(JSON.stringify(snaps));
40
+ return;
41
+ }
42
+ if (snaps.length === 0) {
43
+ console.log(emptyMessage);
44
+ return;
45
+ }
46
+ for (const snap of snaps) {
47
+ // content_preview can plausibly contain the note's own internal
48
+ // newlines (it's freeform text) -- collapsed to spaces and re-truncated
49
+ // here so one snap never spills across multiple terminal lines and
50
+ // breaks the "one line per snap" contract.
51
+ const flat = snap.content_preview.replace(/\s+/g, " ").trim();
52
+ const preview = flat.length > 60 ? `${flat.slice(0, 60)}...` : flat;
53
+ console.log(`${snap.id} - ${preview} (${snap.created_at})${snap.shared ? " (shared)" : ""}`);
54
+ }
55
+ }
56
+ export async function createSnap(args) {
57
+ const jsonOutput = args.includes("--json");
58
+ // Joined, not just args[0] -- an unquoted multi-word note (e.g. `ahood
59
+ // snap create Debugged the flaky CI step`) arrives as multiple positional
60
+ // tokens, and taking only the first one silently dropped the rest with no
61
+ // error. Mirrors searchSnaps' own query-joining below.
62
+ const positionals = args.filter((a) => a !== "--json");
63
+ const positional = positionals.length > 0 ? positionals.join(" ") : undefined;
64
+ let content;
65
+ if (positional !== undefined) {
66
+ content = positional;
67
+ }
68
+ else {
69
+ // No positional content -- fall back to stdin (echo "..." | ahood snap
70
+ // create, or a piped heredoc), same convention as e.g. `git commit -F
71
+ // -`. A TTY with nothing piped in would otherwise hang forever waiting
72
+ // for input that will never arrive -- this CLI must never hang
73
+ // indefinitely for unattended/agent use (same reasoning as http.ts's
74
+ // 30s request timeout) -- so that case fails fast with the usage error
75
+ // instead of blocking on readStdin().
76
+ if (process.stdin.isTTY)
77
+ throw new UsageError(CREATE_USAGE);
78
+ content = await readStdin();
79
+ }
80
+ if (!content.trim())
81
+ throw new UsageError(CREATE_USAGE);
82
+ const created = await apiJson("/api/v1/snaps", {
83
+ method: "POST",
84
+ headers: { "Content-Type": "application/json" },
85
+ body: JSON.stringify({ content }),
86
+ });
87
+ if (jsonOutput) {
88
+ console.log(JSON.stringify(created));
89
+ return;
90
+ }
91
+ console.log(created.id);
92
+ }
93
+ export async function listSnaps(args) {
94
+ const jsonOutput = args.includes("--json");
95
+ const limitStr = flagValue(args, "--limit");
96
+ validateLimit(limitStr, LIST_USAGE);
97
+ const qs = new URLSearchParams();
98
+ if (limitStr !== undefined)
99
+ qs.set("limit", limitStr);
100
+ const query = qs.toString();
101
+ const { snaps } = await apiJson(`/api/v1/snaps${query ? `?${query}` : ""}`);
102
+ // ?? [], not a bare destructure -- a degraded response (snaps: null)
103
+ // must degrade to "no snaps" instead of crashing, matching list.ts/
104
+ // search.ts's own guard for the identical shape (ahood-cli#106).
105
+ printSnaps(jsonOutput, snaps ?? [], "You have no snaps yet. Run `ahood snap create <content>` to make one.");
106
+ }
107
+ export async function searchSnaps(args) {
108
+ const jsonOutput = args.includes("--json");
109
+ const limitStr = flagValue(args, "--limit");
110
+ const query = parseSearchQuery(args, SEARCH_USAGE);
111
+ validateLimit(limitStr, SEARCH_USAGE);
112
+ const qs = new URLSearchParams({ q: query });
113
+ if (limitStr !== undefined)
114
+ qs.set("limit", limitStr);
115
+ const { snaps } = await apiJson(`/api/v1/snaps?${qs}`);
116
+ // ?? [] -- see listSnaps' identical guard above (ahood-cli#106).
117
+ printSnaps(jsonOutput, snaps ?? [], "No snaps found.");
118
+ }
119
+ export async function showSnap(args) {
120
+ const jsonOutput = args.includes("--json");
121
+ const id = args.find((a) => !a.startsWith("--"));
122
+ if (!id)
123
+ throw new UsageError(SHOW_USAGE);
124
+ const snap = await apiJson(`/api/v1/snaps/${encodeURIComponent(id)}`);
125
+ if (jsonOutput) {
126
+ console.log(JSON.stringify(snap));
127
+ return;
128
+ }
129
+ // Plain mode prints the raw content verbatim -- no formatting/labels/
130
+ // trailing decoration -- mirroring read.ts's own plain-mode contract
131
+ // exactly, process.stdout.write and not console.log: console.log
132
+ // unconditionally appends its own "\n", which double-adds a trailing
133
+ // blank line when the content already ends in one (ahood-cli#108).
134
+ process.stdout.write(snap.content);
135
+ }
136
+ export async function removeSnap(args) {
137
+ const id = args.find((a) => !a.startsWith("--"));
138
+ if (!id)
139
+ throw new UsageError(REMOVE_USAGE);
140
+ const yes = args.includes("--yes");
141
+ // Matches remove.ts/group.ts's confirm-before-destroy pattern (CLAUDE.md:
142
+ // "Destructive commands ... prompt for confirmation unless --yes is
143
+ // passed") -- deletion is irreversible per the API contract, same class
144
+ // of mistake ahood-cli#98/#99 already fixed for other commands.
145
+ const confirmed = yes ? true : await confirm(`Delete snap ${id}? Type "yes" to confirm: `);
146
+ if (!confirmed) {
147
+ console.log("Aborted.");
148
+ return;
149
+ }
150
+ await apiJson(`/api/v1/snaps/${encodeURIComponent(id)}`, { method: "DELETE" });
151
+ console.log(`Deleted snap ${id}.`);
152
+ }
153
+ export async function shareSnap(args) {
154
+ const jsonOutput = args.includes("--json");
155
+ const id = args.find((a) => !a.startsWith("--"));
156
+ if (!id)
157
+ throw new UsageError(SHARE_USAGE);
158
+ // POST .../share is documented idempotent -- mints a link on first call,
159
+ // returns the same existing one on any later call -- so this never needs
160
+ // a confirm() gate: re-running it can't clobber or duplicate anything.
161
+ const { share_url } = await apiJson(`/api/v1/snaps/${encodeURIComponent(id)}/share`, {
162
+ method: "POST",
163
+ });
164
+ if (jsonOutput) {
165
+ console.log(JSON.stringify({ share_url }));
166
+ return;
167
+ }
168
+ console.log(share_url);
169
+ }
170
+ export async function unshareSnap(args) {
171
+ const id = args.find((a) => !a.startsWith("--"));
172
+ if (!id)
173
+ throw new UsageError(UNSHARE_USAGE);
174
+ const yes = args.includes("--yes");
175
+ // Not destructive in the data-loss sense -- the snap itself is untouched,
176
+ // and re-sharing mints/returns a link exactly as before (share is
177
+ // idempotent). But whoever the old link was handed to loses access the
178
+ // instant this runs, with no warning otherwise -- a real effect even
179
+ // though it's recoverable by re-running `share`. Gated the same way as
180
+ // every other snap verb with a live, external side effect (remove,
181
+ // and by analogy group.ts's removeMember/leaveGroup) for consistency
182
+ // across this command group, rather than carving out a silent exception
183
+ // here just because the data itself survives.
184
+ const confirmed = yes ? true : await confirm(`Revoke the share link for snap ${id}? Type "yes" to confirm: `);
185
+ if (!confirmed) {
186
+ console.log("Aborted.");
187
+ return;
188
+ }
189
+ await apiJson(`/api/v1/snaps/${encodeURIComponent(id)}/share`, { method: "DELETE" });
190
+ console.log(`Unshared snap ${id}.`);
191
+ }
package/dist/flags.js CHANGED
@@ -28,3 +28,21 @@ export function flagValue(args, flag) {
28
28
  }
29
29
  return undefined;
30
30
  }
31
+ // Shared by every "<verb> <query> [--json] [--limit <n>]" command (skill
32
+ // search, snap search) -- factored out after this exact logic was
33
+ // duplicated verbatim between them, which is exactly how ahood-cli#105's
34
+ // "--limit=5 misparsed as an unknown flag" bug could have silently come
35
+ // back in one copy while being fixed in the other. Strips --json and
36
+ // --limit (both "--limit N" and "--limit=N" forms) from the positional
37
+ // args, rejects any other unrecognized "--" flag, and joins what's left
38
+ // into a single query string.
39
+ export function parseSearchQuery(args, usage) {
40
+ const queryParts = args.filter((a, i) => a !== "--json" && a !== "--limit" && !a.startsWith("--limit=") && args[i - 1] !== "--limit");
41
+ const unknownFlag = queryParts.find((a) => a.startsWith("--"));
42
+ if (unknownFlag)
43
+ throw new UsageError(`Unknown flag: ${unknownFlag}\n${usage}`);
44
+ const query = queryParts.join(" ");
45
+ if (!query)
46
+ throw new UsageError(usage);
47
+ return query;
48
+ }
package/dist/help.js CHANGED
@@ -117,9 +117,9 @@ export const SKILL_COMMANDS_HELP = [
117
117
  examples: ["ahood skill outdated", "ahood skill outdated alice/pdf-tools --json"],
118
118
  },
119
119
  {
120
- usage: "ahood skill remove <owner>/<skill>",
121
- summary: "Uninstall and unpin a skill (local only).",
122
- desc: "Uninstall and unpin (local only).",
120
+ usage: "ahood skill remove <owner>/<skill> [--yes]",
121
+ summary: "Uninstall and unpin a skill (local only, prompts for confirmation unless --yes is passed).",
122
+ desc: "Uninstall and unpin (local only). Prompts for confirmation unless --yes is passed.",
123
123
  },
124
124
  {
125
125
  usage: "ahood skill edit <owner>/<skill> [--tagline] [--tags] [--license] [--visibility] [--homepage] [--repository]",
@@ -246,15 +246,19 @@ export const GROUP_COMMANDS_HELP = [
246
246
  ],
247
247
  },
248
248
  {
249
- usage: "ahood group remove-member <group> <username>",
250
- summary: "Remove a member from a group you own.",
249
+ usage: "ahood group remove-member <group> <username> [--yes]",
250
+ summary: "Remove a member from a group you own (prompts for confirmation unless --yes is passed).",
251
251
  desc: "Remove a member from a group you own. The group's owner cannot be removed this way -- delete the " +
252
- "group instead. Only the group's owner can remove someone other than themselves.",
252
+ "group instead. Only the group's owner can remove someone other than themselves. Prompts for a typed " +
253
+ "\"yes\" unless --yes is passed.",
254
+ flags: ["--yes Skip the interactive confirmation, for scripts/CI."],
253
255
  },
254
256
  {
255
- usage: "ahood group leave <group>",
256
- summary: "Leave a group you belong to.",
257
- desc: "Leave a group you belong to. The group's owner cannot leave -- delete the group instead.",
257
+ usage: "ahood group leave <group> [--yes]",
258
+ summary: "Leave a group you belong to (prompts for confirmation unless --yes is passed).",
259
+ desc: "Leave a group you belong to. The group's owner cannot leave -- delete the group instead. Prompts for " +
260
+ "a typed \"yes\" unless --yes is passed.",
261
+ flags: ["--yes Skip the interactive confirmation, for scripts/CI."],
258
262
  },
259
263
  {
260
264
  usage: "ahood group delete <group> [--yes]",
@@ -264,6 +268,68 @@ export const GROUP_COMMANDS_HELP = [
264
268
  flags: ["--yes Skip the interactive confirmation, for scripts/CI."],
265
269
  },
266
270
  ];
271
+ // Every snap-entity verb, all reached as `ahood snap <verb>` -- mirrors
272
+ // GROUP_COMMANDS_HELP above, just for the "Snaps" feature (ahood-cli#107):
273
+ // a private, freeform text note capturing "what happened this session",
274
+ // searchable later by its owner, optionally shareable via a revocable link,
275
+ // never public otherwise.
276
+ export const SNAP_COMMANDS_HELP = [
277
+ {
278
+ usage: "ahood snap create <content> [--json]",
279
+ summary: "Capture a new private snap, from an argument or piped stdin.",
280
+ desc: "Capture a new private snap. Pass the content as an argument, or omit it and pipe content on stdin " +
281
+ "(e.g. `echo \"...\" | ahood snap create`, or a piped heredoc) -- useful for capturing a whole session's " +
282
+ "worth of freeform text at once. Prints the new snap's id on success.",
283
+ flags: ["--json Emit {id, created_at} instead of just the bare id."],
284
+ examples: ['ahood snap create "Debugged the flaky CI step, root cause was a race in the cache key."', 'echo "..." | ahood snap create'],
285
+ },
286
+ {
287
+ usage: "ahood snap list [--json] [--limit <n>]",
288
+ summary: "List your own snaps, most recent first.",
289
+ desc: "List your own snaps: id, a truncated content preview, created_at, and a (shared) marker if shared.",
290
+ flags: [
291
+ "--json Emit the raw snap objects instead of formatted lines.",
292
+ "--limit <n> Cap the number of results.",
293
+ ],
294
+ },
295
+ {
296
+ usage: "ahood snap search <query> [--json] [--limit <n>]",
297
+ summary: "Search your own snaps by content.",
298
+ desc: "Search your own snaps by content. Same output shape as `ahood snap list`.",
299
+ flags: [
300
+ "--json Emit the raw snap objects instead of formatted lines.",
301
+ "--limit <n> Cap the number of results.",
302
+ ],
303
+ examples: ["ahood snap search flaky-ci"],
304
+ },
305
+ {
306
+ usage: "ahood snap show <id> [--json]",
307
+ summary: "Print a single snap's full content.",
308
+ desc: "Print a single snap's full content. Plain mode prints the raw content verbatim to stdout -- no " +
309
+ "formatting, no labels -- so it's safe to pipe into a file or another tool.",
310
+ flags: ["--json Emit the full snap object instead of the raw content."],
311
+ },
312
+ {
313
+ usage: "ahood snap remove <id> [--yes]",
314
+ summary: "Permanently delete a snap (prompts for confirmation unless --yes is passed).",
315
+ desc: "Permanently delete a snap. Irreversible. Prompts for a typed \"yes\" unless --yes is passed.",
316
+ flags: ["--yes Skip the interactive confirmation, for scripts/CI."],
317
+ },
318
+ {
319
+ usage: "ahood snap share <id> [--json]",
320
+ summary: "Mint (or return the existing) shareable link for a snap.",
321
+ desc: "Mint or return the existing shareable link for a snap. Idempotent -- safe to run more than once.",
322
+ flags: ["--json Emit {share_url} instead of just the bare URL."],
323
+ },
324
+ {
325
+ usage: "ahood snap unshare <id> [--yes]",
326
+ summary: "Revoke a snap's share link (the snap itself is untouched; prompts for confirmation unless --yes is passed).",
327
+ desc: "Revoke a snap's share link -- the snap itself is untouched, and re-running `ahood snap share` mints a new " +
328
+ "link. Prompts for a typed \"yes\" unless --yes is passed, since anyone using the old link loses access " +
329
+ "immediately.",
330
+ flags: ["--yes Skip the interactive confirmation, for scripts/CI."],
331
+ },
332
+ ];
267
333
  // Exported so other consumers of the command list (e.g. shell completion) can
268
334
  // surface aliases without needing their own copy of this map. Aliases apply
269
335
  // at the skill-verb level today (e.g. "show" for "view"); nothing at the
@@ -276,6 +342,7 @@ export const COMMAND_ALIASES = { show: "view" };
276
342
  const ENTITY_COMMANDS_HELP = {
277
343
  skill: SKILL_COMMANDS_HELP,
278
344
  group: GROUP_COMMANDS_HELP,
345
+ snap: SNAP_COMMANDS_HELP,
279
346
  };
280
347
  // Two-token lookup for an entity verb (findCommandHelp("skill", "search"),
281
348
  // findCommandHelp("group", "create")) or a single-token lookup for a
@@ -283,6 +350,16 @@ const ENTITY_COMMANDS_HELP = {
283
350
  export function findCommandHelp(command, subcommand) {
284
351
  const entityList = ENTITY_COMMANDS_HELP[command];
285
352
  if (entityList && subcommand !== undefined) {
353
+ // Try the literal verb first -- COMMAND_ALIASES is a single global map
354
+ // ("show" -> "view", for skill's alias), but a different entity can
355
+ // have its own real verb that happens to share the alias's name (e.g.
356
+ // `ahood snap show`, a real verb, not an alias for anything). Resolving
357
+ // through COMMAND_ALIASES unconditionally would send that lookup to a
358
+ // nonexistent "ahood snap view" entry instead of the literal "ahood
359
+ // snap show" one that's actually in this list.
360
+ const literal = entityList.find((c) => c.usage.startsWith(`ahood ${command} ${subcommand} `) || c.usage === `ahood ${command} ${subcommand}`);
361
+ if (literal)
362
+ return literal;
286
363
  const resolved = COMMAND_ALIASES[subcommand] ?? subcommand;
287
364
  return entityList.find((c) => c.usage.startsWith(`ahood ${command} ${resolved} `) || c.usage === `ahood ${command} ${resolved}`);
288
365
  }
@@ -403,18 +480,34 @@ export function formatGroupHelp() {
403
480
  "Run `ahood group <command> --help` for a single command's flags and examples.",
404
481
  ].join("\n");
405
482
  }
483
+ // `ahood snap --help` -- the group-level listing for every snap verb.
484
+ export function formatSnapHelp() {
485
+ const lines = formatCommandTable(SNAP_COMMANDS_HELP.map((c) => ({ usage: usageWithAliases(c), summary: c.summary })));
486
+ return [
487
+ "ahood snap -- capture and search private, session-scoped notes",
488
+ "",
489
+ "Commands:",
490
+ ...lines,
491
+ "",
492
+ "Run `ahood snap <command> --help` for a single command's flags and examples.",
493
+ ].join("\n");
494
+ }
406
495
  // `ahood --help` -- the top-level listing: account commands rendered in
407
- // full, plus a single summary line each pointing at `ahood skill --help`
408
- // and `ahood group --help` for their (much longer) entity command lists.
496
+ // full, plus a single summary line each pointing at `ahood skill --help`,
497
+ // `ahood group --help`, and `ahood snap --help` for their (much longer)
498
+ // entity command lists.
409
499
  export function formatHelp() {
410
500
  const skillGroupUsage = "ahood skill <command>";
411
501
  const skillGroupSummary = "Search, install, and publish skills -- run `ahood skill --help` for the full list.";
412
502
  const groupGroupUsage = "ahood group <command>";
413
503
  const groupGroupSummary = "Create private groups and share skills with them -- run `ahood group --help` for the full list.";
504
+ const snapGroupUsage = "ahood snap <command>";
505
+ const snapGroupSummary = "Capture and search private, session-scoped notes -- run `ahood snap --help` for the full list.";
414
506
  const lines = formatCommandTable([
415
507
  ...TOP_LEVEL_COMMANDS_HELP.map((c) => ({ usage: usageWithAliases(c), summary: c.summary })),
416
508
  { usage: skillGroupUsage, summary: skillGroupSummary },
417
509
  { usage: groupGroupUsage, summary: groupGroupSummary },
510
+ { usage: snapGroupUsage, summary: snapGroupSummary },
418
511
  ]);
419
512
  return [
420
513
  "ahood -- CLI for the ahood skills registry (https://ahood.vercel.app)",
@@ -430,6 +523,7 @@ export function formatHelp() {
430
523
  "Run `ahood <command> --help` (or `ahood help <command>`) for a single command's flags and examples.",
431
524
  "Run `ahood skill --help` for the full list of skill commands.",
432
525
  "Run `ahood group --help` for the full list of group commands.",
526
+ "Run `ahood snap --help` for the full list of snap commands.",
433
527
  "Run `ahood --version` to print the installed CLI version.",
434
528
  "",
435
529
  "Exit codes: 0 success, 1 general error, 2 usage/validation error,",
package/dist/http.js CHANGED
@@ -65,8 +65,17 @@ export async function apiJson(path, init = {}) {
65
65
  : `Request failed with status ${res.status}`;
66
66
  throw new ApiError(res.status, message);
67
67
  }
68
+ // A 204 No Content (or any 2xx with an empty body) has nothing to parse --
69
+ // res.json() throws on empty input, which previously surfaced as a
70
+ // generic "Malformed response" error even though the request succeeded
71
+ // (ahood-cli#103). Callers that don't need response data (e.g. token
72
+ // revoke, which awaits this without using the result) get `undefined`
73
+ // back instead of a spurious failure.
74
+ const text = await res.text();
75
+ if (text === "")
76
+ return undefined;
68
77
  try {
69
- return (await res.json());
78
+ return JSON.parse(text);
70
79
  }
71
80
  catch {
72
81
  throw new Error(`Malformed response from ${getApiUrl()}${path}: expected JSON.`);
package/dist/index.js CHANGED
@@ -22,7 +22,8 @@ import { completion } from "./commands/completion.js";
22
22
  import { mcp } from "./commands/mcp.js";
23
23
  import { init } from "./commands/init.js";
24
24
  import { createGroup, listGroups, groupMembers, inviteLink, joinGroup, removeMember, leaveGroup, deleteGroup, } from "./commands/group.js";
25
- import { formatHelp, formatSkillHelp, formatGroupHelp, formatCommandHelp, findCommandHelp } from "./help.js";
25
+ import { createSnap, listSnaps, searchSnaps, showSnap, removeSnap, shareSnap, unshareSnap, } from "./commands/snap.js";
26
+ import { formatHelp, formatSkillHelp, formatGroupHelp, formatSnapHelp, formatCommandHelp, findCommandHelp } from "./help.js";
26
27
  import { ApiError } from "./http.js";
27
28
  import { exitCodeFor } from "./exit-code.js";
28
29
  import { CLI_NAME, CLI_VERSION } from "./version.js";
@@ -68,10 +69,22 @@ const GROUP_VERBS = {
68
69
  leave: leaveGroup,
69
70
  delete: deleteGroup,
70
71
  };
72
+ // Every snap-entity verb, reached only as `ahood snap <verb>` -- see
73
+ // dispatchSnap() below. Mirrors GROUP_VERBS above exactly; a snap is a pure
74
+ // API-client entity like a group, with no local filesystem footprint.
75
+ const SNAP_VERBS = {
76
+ create: createSnap,
77
+ list: listSnaps,
78
+ search: searchSnaps,
79
+ show: showSnap,
80
+ remove: removeSnap,
81
+ share: shareSnap,
82
+ unshare: unshareSnap,
83
+ };
71
84
  // Top level is now just account/auth-scoped commands (not entity-specific --
72
- // same reasoning `gh auth login` isn't `gh account login`) plus the `skill`
73
- // and `group` entity groups. Future entities (e.g. "personality",
74
- // "protocols") get their own entry here alongside these two.
85
+ // same reasoning `gh auth login` isn't `gh account login`) plus the `skill`,
86
+ // `group`, and `snap` entity groups. Future entities get their own entry
87
+ // here alongside these.
75
88
  const COMMANDS = {
76
89
  login: () => login(),
77
90
  logout: () => logout(),
@@ -81,6 +94,7 @@ const COMMANDS = {
81
94
  mcp,
82
95
  skill: dispatchSkill,
83
96
  group: dispatchGroup,
97
+ snap: dispatchSnap,
84
98
  };
85
99
  // Commands whose handler owns its own --help handling (at both the group
86
100
  // level, e.g. `ahood skill --help` / `ahood group --help`, and the per-verb
@@ -88,8 +102,9 @@ const COMMANDS = {
88
102
  // instead of the generic top-level interception in main(). Despite the
89
103
  // name, this Set has nothing to do with the "Groups" feature -- "group"
90
104
  // here just means "a command that owns its own sub-verb dispatch", the same
91
- // sense "skill" already used before "group" (the entity) existed.
92
- const GROUP_COMMANDS = new Set(["skill", "group"]);
105
+ // sense "skill" already used before "group" (the entity) existed. "snap"
106
+ // joins this set for the exact same reason (ahood-cli#107).
107
+ const GROUP_COMMANDS = new Set(["skill", "group", "snap"]);
93
108
  function levenshtein(a, b) {
94
109
  const dp = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
95
110
  for (let i = 0; i <= a.length; i++)
@@ -124,6 +139,9 @@ function closestSkillCommand(input) {
124
139
  function closestGroupVerb(input) {
125
140
  return closestOf(input, Object.keys(GROUP_VERBS));
126
141
  }
142
+ function closestSnapVerb(input) {
143
+ return closestOf(input, Object.keys(SNAP_VERBS));
144
+ }
127
145
  // `ahood skill <verb> [...]` -- owns its own help handling at both the
128
146
  // group level (`ahood skill` / `ahood skill --help`) and the per-verb level
129
147
  // (`ahood skill <verb> --help`), since neither should fall through to
@@ -175,6 +193,32 @@ async function dispatchGroup(args) {
175
193
  }
176
194
  await handler(rest);
177
195
  }
196
+ // `ahood snap <verb> [...]` -- owns its own help handling at both the
197
+ // group level (`ahood snap` / `ahood snap --help`) and the per-verb level
198
+ // (`ahood snap <verb> --help`), exactly mirroring dispatchSkill()/
199
+ // dispatchGroup() above.
200
+ async function dispatchSnap(args) {
201
+ const [sub, ...rest] = args;
202
+ if (!sub || sub === "--help" || sub === "-h") {
203
+ console.log(formatSnapHelp());
204
+ return;
205
+ }
206
+ const handler = SNAP_VERBS[sub];
207
+ if (!handler) {
208
+ console.error(`Unknown snap command: ${sub}`);
209
+ const suggestion = closestSnapVerb(sub);
210
+ if (suggestion)
211
+ console.error(`Did you mean '${suggestion}'?`);
212
+ console.error("Run `ahood snap --help` for a list of commands.");
213
+ process.exit(2);
214
+ }
215
+ if (rest.includes("--help") || rest.includes("-h")) {
216
+ const entry = findCommandHelp("snap", sub);
217
+ console.log(entry ? formatCommandHelp(entry) : formatSnapHelp());
218
+ return;
219
+ }
220
+ await handler(rest);
221
+ }
178
222
  async function main() {
179
223
  const [command, ...args] = process.argv.slice(2);
180
224
  if (command === "--version" || command === "-v") {
@@ -195,6 +239,12 @@ async function main() {
195
239
  console.log(entry ? formatCommandHelp(entry) : formatGroupHelp());
196
240
  return;
197
241
  }
242
+ if (sub === "snap") {
243
+ const verb = args[1];
244
+ const entry = verb ? findCommandHelp("snap", verb) : undefined;
245
+ console.log(entry ? formatCommandHelp(entry) : formatSnapHelp());
246
+ return;
247
+ }
198
248
  const entry = sub ? findCommandHelp(sub) : undefined;
199
249
  console.log(entry ? formatCommandHelp(entry) : formatHelp());
200
250
  return;
@@ -223,8 +273,10 @@ async function main() {
223
273
  console.error("Run `ahood help` for a list of commands.");
224
274
  process.exit(2);
225
275
  }
226
- // Group commands (currently just "skill") own their own --help handling
227
- // at both the group and per-verb level -- don't intercept here.
276
+ // Entities with their own sub-dispatch ("skill", "group", "snap" --
277
+ // GROUP_COMMANDS despite the name, unrelated to the "group" entity itself)
278
+ // own their own --help handling at both the entity and per-verb level --
279
+ // don't intercept here.
228
280
  if (!GROUP_COMMANDS.has(command) && (args.includes("--help") || args.includes("-h"))) {
229
281
  const entry = findCommandHelp(command);
230
282
  console.log(entry ? formatCommandHelp(entry) : formatHelp());
@@ -257,4 +309,4 @@ if (isEntrypoint) {
257
309
  main();
258
310
  }
259
311
  // Exported for tests only -- the CLI itself only ever calls main() above.
260
- export { main, dispatchSkill, dispatchGroup, COMMANDS, SKILL_COMMANDS, GROUP_VERBS };
312
+ export { main, dispatchSkill, dispatchGroup, dispatchSnap, COMMANDS, SKILL_COMMANDS, GROUP_VERBS, SNAP_VERBS };
package/dist/lockfile.js CHANGED
@@ -1,5 +1,5 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, rmdirSync, writeFileSync } from "node:fs";
2
- import { dirname } from "node:path";
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
3
  export function readLockfile(path) {
4
4
  if (!existsSync(path))
5
5
  return {};
@@ -31,24 +31,83 @@ export function writeJsonFileAtomic(path, data) {
31
31
  function writeLockfile(path, lockfile) {
32
32
  writeJsonFileAtomic(path, lockfile);
33
33
  }
34
+ // A lock directory is stale if the PID recorded inside it (written by the
35
+ // acquirer below) belongs to a process that's no longer running -- signal 0
36
+ // doesn't actually deliver a signal, just probes whether the process could
37
+ // be signaled. ESRCH means it's dead; EPERM means it's alive but owned by
38
+ // someone else, which is conservatively treated as still alive since that
39
+ // can't be disproven. A missing/unparseable pid file is also treated as
40
+ // "not stale" -- either a lock from before this file existed, or another
41
+ // process is still mid-way through acquiring (mkdirSync succeeded, the pid
42
+ // write hasn't landed yet) -- safer to wait it out than to reclaim a lock
43
+ // that's actually still being set up.
44
+ function isLockStale(pidFile) {
45
+ let pidText;
46
+ try {
47
+ pidText = readFileSync(pidFile, "utf-8");
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ const pid = Number(pidText);
53
+ if (!Number.isInteger(pid) || pid <= 0)
54
+ return false;
55
+ try {
56
+ process.kill(pid, 0);
57
+ return false;
58
+ }
59
+ catch (error) {
60
+ return error.code === "ESRCH";
61
+ }
62
+ }
34
63
  // Simple advisory lock via mkdir's atomicity (EEXIST on a second caller),
35
64
  // so two concurrent `ahood skill add`/`remove` invocations against the same
36
65
  // project don't race a read-modify-write and silently drop one another's
37
66
  // entry. Exported so other same-directory JSON files with the same
38
67
  // read-modify-write shape (e.g. add.ts's .mcp.json merge) can reuse it
39
68
  // instead of duplicating the pattern.
69
+ //
70
+ // The lock directory holds a `pid` file naming its holder, so a process
71
+ // that finds the lock held can tell a live holder from one that was
72
+ // hard-killed (SIGKILL/OOM) mid-critical-section: without this, such a lock
73
+ // is never released, and every subsequent add/remove/update in that project
74
+ // busy-waits out the full timeout below and then fails permanently until a
75
+ // human manually deletes the stale directory (ahood-cli#100).
40
76
  export function withLock(path, fn) {
41
77
  mkdirSync(dirname(path), { recursive: true });
42
78
  const lockDir = `${path}.lock`;
79
+ const pidFile = join(lockDir, "pid");
43
80
  const deadline = Date.now() + 5000;
44
81
  for (;;) {
45
82
  try {
46
83
  mkdirSync(lockDir);
84
+ // Best-effort: mkdirSync above is what actually holds the lock: if
85
+ // this write fails for some reason, the lock is still correctly held,
86
+ // just without staleness detection for this particular acquisition.
87
+ try {
88
+ writeFileSync(pidFile, String(process.pid));
89
+ }
90
+ catch {
91
+ // non-fatal, see comment above
92
+ }
47
93
  break;
48
94
  }
49
95
  catch (error) {
50
96
  if (error.code !== "EEXIST")
51
97
  throw error;
98
+ if (isLockStale(pidFile)) {
99
+ // Reclaim it. Best-effort removal: if a concurrent process wins the
100
+ // race and reclaims (or a live holder finishes and releases) first,
101
+ // this just falls through to the normal wait/retry below instead of
102
+ // throwing.
103
+ try {
104
+ rmSync(lockDir, { recursive: true, force: true });
105
+ }
106
+ catch {
107
+ // lost the race -- fall through to wait/retry
108
+ }
109
+ continue;
110
+ }
52
111
  if (Date.now() > deadline) {
53
112
  throw new Error(`Timed out waiting for the lockfile lock at ${lockDir}. If no other ahood process is running, delete that directory manually.`);
54
113
  }
@@ -59,7 +118,7 @@ export function withLock(path, fn) {
59
118
  return fn();
60
119
  }
61
120
  finally {
62
- rmdirSync(lockDir);
121
+ rmSync(lockDir, { recursive: true, force: true });
63
122
  }
64
123
  }
65
124
  export function writeLockfileEntry(path, ownerSlashSkill, entry) {
@@ -69,6 +128,40 @@ export function writeLockfileEntry(path, ownerSlashSkill, entry) {
69
128
  writeLockfile(path, lockfile);
70
129
  });
71
130
  }
131
+ // Thrown by writeLockfileEntryVerifyingChecksum -- carries the conflicting
132
+ // entry so the caller (add.ts) can build its own user-facing message and
133
+ // decide what to roll back (an already-extracted skill dir, an already-
134
+ // written agent file, an already-merged .mcp.json entry).
135
+ export class LockfileChecksumConflictError extends Error {
136
+ existing;
137
+ constructor(existing) {
138
+ super("Lockfile checksum conflict");
139
+ this.existing = existing;
140
+ }
141
+ }
142
+ // add.ts's checksum-pin tamper-detection guard used to read the lockfile
143
+ // (unlocked) well before the eventual writeLockfileEntry call, which
144
+ // acquires its OWN, separate lock later -- two independently-locked steps,
145
+ // not one atomic operation. Under concurrent installs of "the same"
146
+ // version with different (tampered) checksums, both processes' unlocked
147
+ // reads could see no conflict, so both writes would proceed and whichever
148
+ // wins the later lock silently overwrites the other with no error ever
149
+ // surfaced -- exactly the tampering this guard exists to catch
150
+ // (ahood-cli#101). Performing the read, the check, and the write inside one
151
+ // locked critical section closes that window: whichever caller's write
152
+ // actually lands is checked against the lockfile state as it exists AT
153
+ // THAT MOMENT, not a possibly-stale snapshot from earlier.
154
+ export function writeLockfileEntryVerifyingChecksum(path, ownerSlashSkill, entry) {
155
+ withLock(path, () => {
156
+ const lockfile = readLockfile(path);
157
+ const existing = lockfile[ownerSlashSkill];
158
+ if (existing && existing.version === entry.version && existing.checksum_sha256 !== entry.checksum_sha256) {
159
+ throw new LockfileChecksumConflictError(existing);
160
+ }
161
+ lockfile[ownerSlashSkill] = entry;
162
+ writeLockfile(path, lockfile);
163
+ });
164
+ }
72
165
  export function removeLockfileEntry(path, ownerSlashSkill) {
73
166
  return withLock(path, () => {
74
167
  const lockfile = readLockfile(path);
package/dist/spec.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { join } from "node:path";
2
2
  import { UsageError } from "./usage-error.js";
3
- // Owner and skill segments become filesystem path components (.claude/skills/<owner>/<skill>)
3
+ // Owner and skill segments become filesystem path components (.claude/skills/<owner>@<skill>)
4
4
  // and URL path segments, so both are restricted to a safe charset that cannot contain "..",
5
5
  // "/", "\", or a leading dot -- this is what closes the path-traversal hole in add/remove
6
6
  // (a spec like "alice/.." previously resolved outside .claude/skills/ entirely).
@@ -76,23 +76,34 @@ export function validateExternalUrl(value, flag) {
76
76
  }
77
77
  export const SKILLS_ROOT = join(".claude", "skills");
78
78
  export const LOCKFILE_PATH = join(".claude", "skills.lock.json");
79
+ // Claude Code's own project-skill loader scans .claude/skills/ exactly one
80
+ // level deep (each immediate child directory is checked for a SKILL.md),
81
+ // not recursively -- so the previous owner-nested layout
82
+ // (.claude/skills/<owner>/<skill>/SKILL.md) was never discovered by Claude
83
+ // Code at all; it only ever worked via this CLI's own commands (ahood-cli#96).
84
+ // Joining owner and skill into one flat directory name with "@" fixes that
85
+ // while staying collision-resistant, mirroring agentPath below: SEGMENT_RE's
86
+ // character class [a-z0-9._-] (case-insensitive) never contains "@", so the
87
+ // joined name always has exactly one "@", unambiguous for any pair of
88
+ // SEGMENT_RE-valid values. "@" is a valid path-segment character on Linux,
89
+ // macOS, and Windows.
79
90
  export function skillDir(owner, skill) {
80
- return join(SKILLS_ROOT, owner, skill);
91
+ return join(SKILLS_ROOT, `${owner}@${skill}`);
81
92
  }
82
93
  // Claude Code's own subagent loader scans .claude/agents/*.md as flat files
83
- // (non-recursive), unlike .claude/skills/<owner>/<skill>/ which nests by
84
- // owner -- a bare .claude/agents/<skill>.md would let two different owners'
85
- // same-named agent collide and silently overwrite each other. Joining the
86
- // owner into the filename with "@" keeps the layout flat (so Claude Code
87
- // still finds it) while staying collision-resistant: SEGMENT_RE's character
88
- // class is exactly [a-z0-9._-] (case-insensitive), which does not include
89
- // "@" -- so "@" can never appear inside a valid owner or skill segment, and
90
- // the joined filename always contains exactly one "@", at the boundary
91
- // between the two segments. That makes the join unambiguous for any pair of
92
- // SEGMENT_RE-valid values, including ones containing hyphens (e.g.
93
- // owner="al-ice"/skill="bob" joins to "al-ice@bob.md", which cannot also be
94
- // produced by any other valid owner/skill pair). "@" is a valid filename
95
- // character on Linux, macOS, and Windows.
94
+ // (non-recursive), same reasoning as skillDir above -- a bare
95
+ // .claude/agents/<skill>.md would let two different owners' same-named agent
96
+ // collide and silently overwrite each other. Joining the owner into the
97
+ // filename with "@" keeps the layout flat (so Claude Code still finds it)
98
+ // while staying collision-resistant: SEGMENT_RE's character class is exactly
99
+ // [a-z0-9._-] (case-insensitive), which does not include "@" -- so "@" can
100
+ // never appear inside a valid owner or skill segment, and the joined
101
+ // filename always contains exactly one "@", at the boundary between the two
102
+ // segments. That makes the join unambiguous for any pair of SEGMENT_RE-valid
103
+ // values, including ones containing hyphens (e.g. owner="al-ice"/skill="bob"
104
+ // joins to "al-ice@bob.md", which cannot also be produced by any other valid
105
+ // owner/skill pair). "@" is a valid filename character on Linux, macOS, and
106
+ // Windows.
96
107
  export const AGENTS_ROOT = join(".claude", "agents");
97
108
  export function agentPath(owner, skill) {
98
109
  return join(AGENTS_ROOT, `${owner}@${skill}.md`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahood/cli",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
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",