@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/README.md +20 -7
- package/dist/commands/add.js +201 -23
- package/dist/commands/group.js +23 -2
- package/dist/commands/init.js +1 -1
- package/dist/commands/list.js +5 -1
- package/dist/commands/login.js +24 -10
- package/dist/commands/publish.js +5 -0
- package/dist/commands/read.js +7 -1
- package/dist/commands/remove.js +87 -42
- package/dist/commands/search.js +7 -9
- package/dist/commands/snap.js +248 -0
- package/dist/commands/update.js +19 -10
- package/dist/flags.js +18 -0
- package/dist/help.js +126 -12
- package/dist/http.js +10 -1
- package/dist/index.js +62 -9
- package/dist/lockfile.js +96 -3
- package/dist/spec.js +26 -15
- package/package.json +2 -2
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
|
|
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
|
|
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 {
|
|
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 `
|
|
74
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
227
|
-
//
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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),
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
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.
|
|
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"
|