@ahood/cli 0.7.2 → 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 +19 -7
- package/dist/commands/add.js +62 -13
- 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 +23 -21
- package/dist/commands/search.js +7 -9
- package/dist/commands/snap.js +191 -0
- package/dist/flags.js +18 -0
- package/dist/help.js +105 -11
- package/dist/http.js +10 -1
- package/dist/index.js +61 -9
- package/dist/lockfile.js +96 -3
- package/dist/spec.js +26 -15
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
239
|
-
| `ahood group leave <group
|
|
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
|
|
package/dist/commands/add.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
416
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
}
|
package/dist/commands/group.js
CHANGED
|
@@ -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}.`);
|
package/dist/commands/init.js
CHANGED
|
@@ -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
|
|
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.
|
package/dist/commands/list.js
CHANGED
|
@@ -15,6 +15,10 @@ export async function listSkills(args = []) {
|
|
|
15
15
|
return;
|
|
16
16
|
}
|
|
17
17
|
for (const skill of skills) {
|
|
18
|
-
|
|
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
|
}
|
package/dist/commands/login.js
CHANGED
|
@@ -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
|
|
36
|
-
// transient
|
|
37
|
-
//
|
|
38
|
-
//
|
|
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.");
|
package/dist/commands/publish.js
CHANGED
|
@@ -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",
|
package/dist/commands/read.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/commands/remove.js
CHANGED
|
@@ -1,42 +1,44 @@
|
|
|
1
|
-
import { existsSync,
|
|
2
|
-
import {
|
|
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/ --
|
|
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
|
-
|
|
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
|
package/dist/commands/search.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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, } 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 `
|
|
74
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
227
|
-
//
|
|
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,
|
|
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