@ahood/cli 0.7.3 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/help.js CHANGED
@@ -100,7 +100,9 @@ export const SKILL_COMMANDS_HELP = [
100
100
  {
101
101
  usage: "ahood skill update [<owner>/<skill> ...] [--dry-run] [--json]",
102
102
  summary: "Move the lockfile pin(s) forward to the latest version, for one skill or all installed skills at once.",
103
- desc: "Move the lockfile pin(s) forward to the latest version. With no argument, updates every installed skill; one failure doesn't stop the rest.",
103
+ desc: "Move the lockfile pin(s) forward to the latest version. With no argument, updates every installed skill; one failure doesn't stop the rest. " +
104
+ "An mcp-kind entry is updated in place (re-resolving any secret environment variables) as long as its .mcp.json entry still matches what " +
105
+ "ahood last installed there -- a hand-edited or unverifiable entry is refused rather than silently overwritten.",
104
106
  flags: [
105
107
  "--dry-run Preview current vs. latest version (and the changelog for anything behind) without installing anything.",
106
108
  "--json With --dry-run, emit structured preview objects instead of a formatted table.",
@@ -117,9 +119,11 @@ export const SKILL_COMMANDS_HELP = [
117
119
  examples: ["ahood skill outdated", "ahood skill outdated alice/pdf-tools --json"],
118
120
  },
119
121
  {
120
- usage: "ahood skill remove <owner>/<skill>",
121
- summary: "Uninstall and unpin a skill (local only).",
122
- desc: "Uninstall and unpin (local only).",
122
+ usage: "ahood skill remove <owner>/<skill> [--yes]",
123
+ summary: "Uninstall and unpin a skill (local only, prompts for confirmation unless --yes is passed).",
124
+ desc: "Uninstall and unpin (local only). Prompts for confirmation unless --yes is passed. For an mcp-kind install, " +
125
+ "also deletes its .mcp.json entry as long as it still matches what ahood last installed there -- a hand-edited " +
126
+ "or unverifiable entry is left in place and warned about instead of being silently touched.",
123
127
  },
124
128
  {
125
129
  usage: "ahood skill edit <owner>/<skill> [--tagline] [--tags] [--license] [--visibility] [--homepage] [--repository]",
@@ -246,15 +250,19 @@ export const GROUP_COMMANDS_HELP = [
246
250
  ],
247
251
  },
248
252
  {
249
- usage: "ahood group remove-member <group> <username>",
250
- summary: "Remove a member from a group you own.",
253
+ usage: "ahood group remove-member <group> <username> [--yes]",
254
+ summary: "Remove a member from a group you own (prompts for confirmation unless --yes is passed).",
251
255
  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.",
256
+ "group instead. Only the group's owner can remove someone other than themselves. Prompts for a typed " +
257
+ "\"yes\" unless --yes is passed.",
258
+ flags: ["--yes Skip the interactive confirmation, for scripts/CI."],
253
259
  },
254
260
  {
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.",
261
+ usage: "ahood group leave <group> [--yes]",
262
+ summary: "Leave a group you belong to (prompts for confirmation unless --yes is passed).",
263
+ desc: "Leave a group you belong to. The group's owner cannot leave -- delete the group instead. Prompts for " +
264
+ "a typed \"yes\" unless --yes is passed.",
265
+ flags: ["--yes Skip the interactive confirmation, for scripts/CI."],
258
266
  },
259
267
  {
260
268
  usage: "ahood group delete <group> [--yes]",
@@ -264,6 +272,84 @@ export const GROUP_COMMANDS_HELP = [
264
272
  flags: ["--yes Skip the interactive confirmation, for scripts/CI."],
265
273
  },
266
274
  ];
275
+ // Every snap-entity verb, all reached as `ahood snap <verb>` -- mirrors
276
+ // GROUP_COMMANDS_HELP above, just for the "Snaps" feature (ahood-cli#107):
277
+ // a private, freeform text note capturing "what happened this session",
278
+ // searchable later by its owner, optionally shareable via a revocable link,
279
+ // never public otherwise.
280
+ export const SNAP_COMMANDS_HELP = [
281
+ {
282
+ usage: "ahood snap create <content> [--tags tag1,tag2] [--json]",
283
+ summary: "Capture a new private snap, from an argument or piped stdin.",
284
+ desc: "Capture a new private snap. Pass the content as an argument, or omit it and pipe content on stdin " +
285
+ "(e.g. `echo \"...\" | ahood snap create`, or a piped heredoc) -- useful for capturing a whole session's " +
286
+ "worth of freeform text at once. Prints the new snap's id on success.",
287
+ flags: [
288
+ "--tags <comma,separated> Attach tags to the new snap.",
289
+ "--json Emit {id, created_at} instead of just the bare id.",
290
+ ],
291
+ examples: [
292
+ 'ahood snap create "Debugged the flaky CI step, root cause was a race in the cache key." --tags deploy,bugfix',
293
+ 'echo "..." | ahood snap create',
294
+ ],
295
+ },
296
+ {
297
+ usage: "ahood snap list [--json] [--limit <n>]",
298
+ summary: "List your own snaps, most recent first.",
299
+ desc: "List your own snaps: id, a truncated content preview, created_at, a (shared) marker if shared, and " +
300
+ "any tags in brackets.",
301
+ flags: [
302
+ "--json Emit the raw snap objects instead of formatted lines.",
303
+ "--limit <n> Cap the number of results.",
304
+ ],
305
+ },
306
+ {
307
+ usage: "ahood snap search <query> [--json] [--limit <n>]",
308
+ summary: "Search your own snaps by content.",
309
+ desc: "Search your own snaps by content. Same output shape as `ahood snap list`.",
310
+ flags: [
311
+ "--json Emit the raw snap objects instead of formatted lines.",
312
+ "--limit <n> Cap the number of results.",
313
+ ],
314
+ examples: ["ahood snap search flaky-ci"],
315
+ },
316
+ {
317
+ usage: "ahood snap show <id> [--json]",
318
+ summary: "Print a single snap's full content.",
319
+ desc: "Print a single snap's full content. Plain mode prints the raw content verbatim to stdout -- no " +
320
+ "formatting, no labels -- so it's safe to pipe into a file or another tool.",
321
+ flags: ["--json Emit the full snap object instead of the raw content."],
322
+ },
323
+ {
324
+ usage: "ahood snap remove <id> [--yes]",
325
+ summary: "Permanently delete a snap (prompts for confirmation unless --yes is passed).",
326
+ desc: "Permanently delete a snap. Irreversible. Prompts for a typed \"yes\" unless --yes is passed.",
327
+ flags: ["--yes Skip the interactive confirmation, for scripts/CI."],
328
+ },
329
+ {
330
+ usage: "ahood snap share <id> [--json]",
331
+ summary: "Mint (or return the existing) shareable link for a snap.",
332
+ desc: "Mint or return the existing shareable link for a snap. Idempotent -- safe to run more than once.",
333
+ flags: ["--json Emit {share_url} instead of just the bare URL."],
334
+ },
335
+ {
336
+ usage: "ahood snap unshare <id> [--yes]",
337
+ summary: "Revoke a snap's share link (the snap itself is untouched; prompts for confirmation unless --yes is passed).",
338
+ desc: "Revoke a snap's share link -- the snap itself is untouched, and re-running `ahood snap share` mints a new " +
339
+ "link. Prompts for a typed \"yes\" unless --yes is passed, since anyone using the old link loses access " +
340
+ "immediately.",
341
+ flags: ["--yes Skip the interactive confirmation, for scripts/CI."],
342
+ },
343
+ {
344
+ usage: "ahood snap tags <id> [tag1,tag2,...] [--json]",
345
+ summary: "Replace a snap's tags (omit the tag list, or pass an empty one, to clear all tags).",
346
+ desc: "Replace the full set of tags on a snap -- this is not a merge, so any existing tags not listed are " +
347
+ "dropped. Omitting the tag list entirely, or passing an empty string, clears all tags. No confirmation " +
348
+ "prompt: unlike remove/unshare this only touches metadata, never the snap's content or shareability.",
349
+ flags: ["--json Emit {id, tags} instead of a plain-text summary."],
350
+ examples: ["ahood snap tags snap_123 deploy,bugfix", 'ahood snap tags snap_123 ""'],
351
+ },
352
+ ];
267
353
  // Exported so other consumers of the command list (e.g. shell completion) can
268
354
  // surface aliases without needing their own copy of this map. Aliases apply
269
355
  // at the skill-verb level today (e.g. "show" for "view"); nothing at the
@@ -276,6 +362,7 @@ export const COMMAND_ALIASES = { show: "view" };
276
362
  const ENTITY_COMMANDS_HELP = {
277
363
  skill: SKILL_COMMANDS_HELP,
278
364
  group: GROUP_COMMANDS_HELP,
365
+ snap: SNAP_COMMANDS_HELP,
279
366
  };
280
367
  // Two-token lookup for an entity verb (findCommandHelp("skill", "search"),
281
368
  // findCommandHelp("group", "create")) or a single-token lookup for a
@@ -283,6 +370,16 @@ const ENTITY_COMMANDS_HELP = {
283
370
  export function findCommandHelp(command, subcommand) {
284
371
  const entityList = ENTITY_COMMANDS_HELP[command];
285
372
  if (entityList && subcommand !== undefined) {
373
+ // Try the literal verb first -- COMMAND_ALIASES is a single global map
374
+ // ("show" -> "view", for skill's alias), but a different entity can
375
+ // have its own real verb that happens to share the alias's name (e.g.
376
+ // `ahood snap show`, a real verb, not an alias for anything). Resolving
377
+ // through COMMAND_ALIASES unconditionally would send that lookup to a
378
+ // nonexistent "ahood snap view" entry instead of the literal "ahood
379
+ // snap show" one that's actually in this list.
380
+ const literal = entityList.find((c) => c.usage.startsWith(`ahood ${command} ${subcommand} `) || c.usage === `ahood ${command} ${subcommand}`);
381
+ if (literal)
382
+ return literal;
286
383
  const resolved = COMMAND_ALIASES[subcommand] ?? subcommand;
287
384
  return entityList.find((c) => c.usage.startsWith(`ahood ${command} ${resolved} `) || c.usage === `ahood ${command} ${resolved}`);
288
385
  }
@@ -403,18 +500,34 @@ export function formatGroupHelp() {
403
500
  "Run `ahood group <command> --help` for a single command's flags and examples.",
404
501
  ].join("\n");
405
502
  }
503
+ // `ahood snap --help` -- the group-level listing for every snap verb.
504
+ export function formatSnapHelp() {
505
+ const lines = formatCommandTable(SNAP_COMMANDS_HELP.map((c) => ({ usage: usageWithAliases(c), summary: c.summary })));
506
+ return [
507
+ "ahood snap -- capture and search private, session-scoped notes",
508
+ "",
509
+ "Commands:",
510
+ ...lines,
511
+ "",
512
+ "Run `ahood snap <command> --help` for a single command's flags and examples.",
513
+ ].join("\n");
514
+ }
406
515
  // `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.
516
+ // full, plus a single summary line each pointing at `ahood skill --help`,
517
+ // `ahood group --help`, and `ahood snap --help` for their (much longer)
518
+ // entity command lists.
409
519
  export function formatHelp() {
410
520
  const skillGroupUsage = "ahood skill <command>";
411
521
  const skillGroupSummary = "Search, install, and publish skills -- run `ahood skill --help` for the full list.";
412
522
  const groupGroupUsage = "ahood group <command>";
413
523
  const groupGroupSummary = "Create private groups and share skills with them -- run `ahood group --help` for the full list.";
524
+ const snapGroupUsage = "ahood snap <command>";
525
+ const snapGroupSummary = "Capture and search private, session-scoped notes -- run `ahood snap --help` for the full list.";
414
526
  const lines = formatCommandTable([
415
527
  ...TOP_LEVEL_COMMANDS_HELP.map((c) => ({ usage: usageWithAliases(c), summary: c.summary })),
416
528
  { usage: skillGroupUsage, summary: skillGroupSummary },
417
529
  { usage: groupGroupUsage, summary: groupGroupSummary },
530
+ { usage: snapGroupUsage, summary: snapGroupSummary },
418
531
  ]);
419
532
  return [
420
533
  "ahood -- CLI for the ahood skills registry (https://ahood.vercel.app)",
@@ -430,6 +543,7 @@ export function formatHelp() {
430
543
  "Run `ahood <command> --help` (or `ahood help <command>`) for a single command's flags and examples.",
431
544
  "Run `ahood skill --help` for the full list of skill commands.",
432
545
  "Run `ahood group --help` for the full list of group commands.",
546
+ "Run `ahood snap --help` for the full list of snap commands.",
433
547
  "Run `ahood --version` to print the installed CLI version.",
434
548
  "",
435
549
  "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, tagsSnap, } 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,23 @@ 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
+ tags: tagsSnap,
84
+ };
71
85
  // 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.
86
+ // same reasoning `gh auth login` isn't `gh account login`) plus the `skill`,
87
+ // `group`, and `snap` entity groups. Future entities get their own entry
88
+ // here alongside these.
75
89
  const COMMANDS = {
76
90
  login: () => login(),
77
91
  logout: () => logout(),
@@ -81,6 +95,7 @@ const COMMANDS = {
81
95
  mcp,
82
96
  skill: dispatchSkill,
83
97
  group: dispatchGroup,
98
+ snap: dispatchSnap,
84
99
  };
85
100
  // Commands whose handler owns its own --help handling (at both the group
86
101
  // level, e.g. `ahood skill --help` / `ahood group --help`, and the per-verb
@@ -88,8 +103,9 @@ const COMMANDS = {
88
103
  // instead of the generic top-level interception in main(). Despite the
89
104
  // name, this Set has nothing to do with the "Groups" feature -- "group"
90
105
  // 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"]);
106
+ // sense "skill" already used before "group" (the entity) existed. "snap"
107
+ // joins this set for the exact same reason (ahood-cli#107).
108
+ const GROUP_COMMANDS = new Set(["skill", "group", "snap"]);
93
109
  function levenshtein(a, b) {
94
110
  const dp = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
95
111
  for (let i = 0; i <= a.length; i++)
@@ -124,6 +140,9 @@ function closestSkillCommand(input) {
124
140
  function closestGroupVerb(input) {
125
141
  return closestOf(input, Object.keys(GROUP_VERBS));
126
142
  }
143
+ function closestSnapVerb(input) {
144
+ return closestOf(input, Object.keys(SNAP_VERBS));
145
+ }
127
146
  // `ahood skill <verb> [...]` -- owns its own help handling at both the
128
147
  // group level (`ahood skill` / `ahood skill --help`) and the per-verb level
129
148
  // (`ahood skill <verb> --help`), since neither should fall through to
@@ -175,6 +194,32 @@ async function dispatchGroup(args) {
175
194
  }
176
195
  await handler(rest);
177
196
  }
197
+ // `ahood snap <verb> [...]` -- owns its own help handling at both the
198
+ // group level (`ahood snap` / `ahood snap --help`) and the per-verb level
199
+ // (`ahood snap <verb> --help`), exactly mirroring dispatchSkill()/
200
+ // dispatchGroup() above.
201
+ async function dispatchSnap(args) {
202
+ const [sub, ...rest] = args;
203
+ if (!sub || sub === "--help" || sub === "-h") {
204
+ console.log(formatSnapHelp());
205
+ return;
206
+ }
207
+ const handler = SNAP_VERBS[sub];
208
+ if (!handler) {
209
+ console.error(`Unknown snap command: ${sub}`);
210
+ const suggestion = closestSnapVerb(sub);
211
+ if (suggestion)
212
+ console.error(`Did you mean '${suggestion}'?`);
213
+ console.error("Run `ahood snap --help` for a list of commands.");
214
+ process.exit(2);
215
+ }
216
+ if (rest.includes("--help") || rest.includes("-h")) {
217
+ const entry = findCommandHelp("snap", sub);
218
+ console.log(entry ? formatCommandHelp(entry) : formatSnapHelp());
219
+ return;
220
+ }
221
+ await handler(rest);
222
+ }
178
223
  async function main() {
179
224
  const [command, ...args] = process.argv.slice(2);
180
225
  if (command === "--version" || command === "-v") {
@@ -195,6 +240,12 @@ async function main() {
195
240
  console.log(entry ? formatCommandHelp(entry) : formatGroupHelp());
196
241
  return;
197
242
  }
243
+ if (sub === "snap") {
244
+ const verb = args[1];
245
+ const entry = verb ? findCommandHelp("snap", verb) : undefined;
246
+ console.log(entry ? formatCommandHelp(entry) : formatSnapHelp());
247
+ return;
248
+ }
198
249
  const entry = sub ? findCommandHelp(sub) : undefined;
199
250
  console.log(entry ? formatCommandHelp(entry) : formatHelp());
200
251
  return;
@@ -223,8 +274,10 @@ async function main() {
223
274
  console.error("Run `ahood help` for a list of commands.");
224
275
  process.exit(2);
225
276
  }
226
- // Group commands (currently just "skill") own their own --help handling
227
- // at both the group and per-verb level -- don't intercept here.
277
+ // Entities with their own sub-dispatch ("skill", "group", "snap" --
278
+ // GROUP_COMMANDS despite the name, unrelated to the "group" entity itself)
279
+ // own their own --help handling at both the entity and per-verb level --
280
+ // don't intercept here.
228
281
  if (!GROUP_COMMANDS.has(command) && (args.includes("--help") || args.includes("-h"))) {
229
282
  const entry = findCommandHelp(command);
230
283
  console.log(entry ? formatCommandHelp(entry) : formatHelp());
@@ -257,4 +310,4 @@ if (isEntrypoint) {
257
310
  main();
258
311
  }
259
312
  // Exported for tests only -- the CLI itself only ever calls main() above.
260
- export { main, dispatchSkill, dispatchGroup, COMMANDS, SKILL_COMMANDS, GROUP_VERBS };
313
+ 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.1",
4
4
  "description": "CLI for ahood, a registry for installing and publishing Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/AlexKay28/ahood-cli",
@@ -26,7 +26,7 @@
26
26
  "dist"
27
27
  ],
28
28
  "scripts": {
29
- "build": "rm -rf dist && tsc -p tsconfig.json",
29
+ "build": "rm -rf dist && tsc -p tsconfig.json && chmod +x dist/index.js",
30
30
  "prepublishOnly": "npm run build",
31
31
  "test": "vitest run",
32
32
  "sync-readme": "node scripts/sync-readme.mjs"