@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/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,25 @@ 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> [--tags tag1,tag2] [--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). |
|
|
253
|
+
| `ahood snap tags <id> [tag1,tag2,...] [--json]` | Replace a snap's tags (omit the tag list, or pass an empty one, to clear all tags). |
|
|
241
254
|
<!-- COMMANDS_TABLE_END -->
|
|
242
255
|
|
|
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.
|
|
256
|
+
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
257
|
|
|
245
258
|
## Exit codes
|
|
246
259
|
|
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
|
|
@@ -319,7 +325,9 @@ function summarizeConflictingEntry(entry) {
|
|
|
319
325
|
// raw TypeError, and would let `"mcpServers": []` pass the collision check
|
|
320
326
|
// silently (an array's numeric-index write is then dropped entirely by
|
|
321
327
|
// JSON.stringify), reporting a successful install that wrote nothing.
|
|
322
|
-
|
|
328
|
+
// Exported so remove.ts/update.ts can read the same validated shape when
|
|
329
|
+
// deciding whether it's safe to touch an mcp entry (ahood-cli#169).
|
|
330
|
+
export function readMcpConfig() {
|
|
323
331
|
if (!existsSync(MCP_CONFIG_PATH))
|
|
324
332
|
return { mcpServers: {} };
|
|
325
333
|
let parsed;
|
|
@@ -355,18 +363,24 @@ function assertNoCollision(fileContents, owner, skill) {
|
|
|
355
363
|
`Remove it manually first if you want to reinstall ${owner}/${skill}.`);
|
|
356
364
|
}
|
|
357
365
|
}
|
|
358
|
-
|
|
366
|
+
// Extracts server.json, validates its shape, and resolves every secret
|
|
367
|
+
// environment variable it declares (prompting only for ones not already
|
|
368
|
+
// set in the shell) -- the part of installing an mcp entry that's identical
|
|
369
|
+
// whether this is a fresh `add` or an `update` moving an existing pin
|
|
370
|
+
// forward. Exported so update.ts's real mcp-update path (ahood-cli#169)
|
|
371
|
+
// re-resolves secrets the same way a fresh install does, instead of
|
|
372
|
+
// duplicating this parsing/prompting logic.
|
|
373
|
+
export async function resolveMcpServerConfig(buffer) {
|
|
359
374
|
const content = await extractSingleFileContent(buffer, "server.json");
|
|
360
375
|
const manifest = JSON.parse(content.toString("utf-8"));
|
|
361
376
|
// Validate the manifest's shape (unsupported registry_type/runtime_hint,
|
|
362
|
-
// or neither a single 'packages' nor a single 'remotes' entry)
|
|
363
|
-
// for
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
377
|
+
// or neither a single 'packages' nor a single 'remotes' entry) BEFORE
|
|
378
|
+
// prompting for any secret -- an install/update that's going to be
|
|
379
|
+
// refused anyway must not first cost the user a masked secret prompt.
|
|
380
|
+
// buildMcpServerConfig is called here with an empty env purely to run its
|
|
381
|
+
// validation/throw; the result is discarded and rebuilt below once
|
|
382
|
+
// secretEnv is known.
|
|
368
383
|
buildMcpServerConfig(manifest, {});
|
|
369
|
-
assertNoCollision(readMcpConfig(), owner, skill);
|
|
370
384
|
const envVars = manifest.packages?.[0]?.environment_variables ?? [];
|
|
371
385
|
const secretEnv = {};
|
|
372
386
|
for (const variable of envVars) {
|
|
@@ -383,6 +397,29 @@ async function installMcpEntry(owner, skill, meta, buffer) {
|
|
|
383
397
|
: await promptSecret(`${sanitizeForTerminal(variable.name)} (${sanitizeForTerminal(variable.description)}): `);
|
|
384
398
|
}
|
|
385
399
|
const serverConfig = buildMcpServerConfig(manifest, secretEnv);
|
|
400
|
+
return { manifest, serverConfig };
|
|
401
|
+
}
|
|
402
|
+
// Fingerprint of exactly what ahood wrote into .mcp.json's mcpServers.<skill>
|
|
403
|
+
// entry, stored in the lockfile alongside the version/checksum pin. Lets
|
|
404
|
+
// remove/update (ahood-cli#169) tell "the on-disk entry is still exactly
|
|
405
|
+
// what I last installed" from "something (the user, another tool) has since
|
|
406
|
+
// hand-edited it" -- without this, a real (not just warned-about) removal
|
|
407
|
+
// or update risks silently discarding an intentional local edit, the same
|
|
408
|
+
// class of risk assertNoCollision below exists to prevent on a *fresh*
|
|
409
|
+
// install. JSON.stringify's key order matches insertion order for a plain
|
|
410
|
+
// object, and JSON.parse's key order matches source-text order -- so this
|
|
411
|
+
// is stable between the object this process just built and the same object
|
|
412
|
+
// read back from disk later, as long as nothing reorders keys in between.
|
|
413
|
+
export function hashMcpServerConfig(config) {
|
|
414
|
+
return createHash("sha256").update(JSON.stringify(config)).digest("hex");
|
|
415
|
+
}
|
|
416
|
+
async function installMcpEntry(owner, skill, meta, buffer) {
|
|
417
|
+
// Check for an existing .mcp.json collision BEFORE resolving secrets --
|
|
418
|
+
// matches resolveMcpServerConfig's own "don't cost the user a prompt for
|
|
419
|
+
// something that's going to be refused anyway" reasoning, just for the
|
|
420
|
+
// collision check specifically rather than the manifest-shape check.
|
|
421
|
+
assertNoCollision(readMcpConfig(), owner, skill);
|
|
422
|
+
const { serverConfig } = await resolveMcpServerConfig(buffer);
|
|
386
423
|
// Read-modify-write under an advisory lock, mirroring lockfile.ts's own
|
|
387
424
|
// writeLockfileEntry: .mcp.json sits right next to the lockfile and can
|
|
388
425
|
// hold other servers' secrets, so it gets the same protection against a
|
|
@@ -398,9 +435,122 @@ async function installMcpEntry(owner, skill, meta, buffer) {
|
|
|
398
435
|
fileContents.mcpServers[skill] = serverConfig;
|
|
399
436
|
writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents);
|
|
400
437
|
});
|
|
401
|
-
|
|
438
|
+
const mcpKey = `${owner}/${skill}`;
|
|
439
|
+
try {
|
|
440
|
+
writeLockfileEntryVerifyingChecksum(LOCKFILE_PATH, mcpKey, {
|
|
441
|
+
version: meta.version,
|
|
442
|
+
checksum_sha256: meta.checksum_sha256,
|
|
443
|
+
mcp_config_hash: hashMcpServerConfig(serverConfig),
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
if (!(error instanceof LockfileChecksumConflictError))
|
|
448
|
+
throw error;
|
|
449
|
+
// Roll back the .mcp.json merge above under the same lock pattern --
|
|
450
|
+
// re-read+delete rather than assuming nothing else changed it since.
|
|
451
|
+
withLock(MCP_CONFIG_PATH, () => {
|
|
452
|
+
const fileContents = readMcpConfig();
|
|
453
|
+
delete fileContents.mcpServers[skill];
|
|
454
|
+
writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents);
|
|
455
|
+
});
|
|
456
|
+
throw new Error(checksumConflictMessage(mcpKey, meta, error.existing));
|
|
457
|
+
}
|
|
402
458
|
console.log(`Installed ${owner}/${skill}@${meta.version} into ${MCP_CONFIG_PATH} as "${skill}"`);
|
|
403
|
-
|
|
459
|
+
// Only the npm+npx package path ever carries secrets into `env` (headers
|
|
460
|
+
// on a remote entry are static per v1's scope, per buildMcpServerConfig) --
|
|
461
|
+
// checking the final serverConfig's own `env` key (rather than threading
|
|
462
|
+
// secretEnv out of resolveMcpServerConfig separately) means this warning
|
|
463
|
+
// can never drift from what was actually written to disk.
|
|
464
|
+
if (serverConfig.env && Object.keys(serverConfig.env).length > 0) {
|
|
465
|
+
console.warn(`WARNING: ${MCP_CONFIG_PATH} now contains one or more secret values in plaintext -- do not commit it to version control.`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
// Re-runs the mcp install flow for an already-installed entry, moving its
|
|
469
|
+
// lockfile pin forward -- update.ts's real mcp-update path (ahood-cli#169).
|
|
470
|
+
// Unlike installMcpEntry, "the entry already exists" is expected here (it's
|
|
471
|
+
// the very entry being updated), so the safety check is inverted: rather
|
|
472
|
+
// than refusing because something's already there, this refuses UNLESS the
|
|
473
|
+
// on-disk entry's fingerprint still matches what was recorded at the last
|
|
474
|
+
// install/update -- same posture as assertNoCollision, applied to "is this
|
|
475
|
+
// still mine to touch" instead of "is this mine to create". If the entry is
|
|
476
|
+
// missing entirely, there's nothing to protect -- write fresh (self-heals a
|
|
477
|
+
// lockfile pin whose .mcp.json entry was deleted by hand without going
|
|
478
|
+
// through `ahood skill remove`). If it's present but doesn't match (or no
|
|
479
|
+
// fingerprint was ever recorded, e.g. an entry installed before this field
|
|
480
|
+
// existed), refuse rather than silently overwrite a possibly-intentional
|
|
481
|
+
// local edit.
|
|
482
|
+
export async function updateMcpEntry(owner, skill, meta, currentEntry) {
|
|
483
|
+
const key = `${owner}/${skill}`;
|
|
484
|
+
const recordedHash = currentEntry?.mcp_config_hash;
|
|
485
|
+
const fileContents = readMcpConfig();
|
|
486
|
+
const mcpServers = fileContents.mcpServers;
|
|
487
|
+
const existingOnDisk = Object.prototype.hasOwnProperty.call(mcpServers, skill) ? mcpServers[skill] : undefined;
|
|
488
|
+
// Both refusal messages below point at manually editing .mcp.json, not
|
|
489
|
+
// `ahood skill remove` -- remove refuses to delete an entry under
|
|
490
|
+
// exactly these same two conditions (no recorded fingerprint, or a
|
|
491
|
+
// fingerprint mismatch), so telling the user to "remove then add" would
|
|
492
|
+
// send them into a sequence where remove leaves the entry in place *and*
|
|
493
|
+
// clears the lockfile pin, and the follow-up add then fails on the very
|
|
494
|
+
// same entry as a collision -- pin gone, credential still live, no CLI
|
|
495
|
+
// path forward. Editing .mcp.json by hand and then running `add` is the
|
|
496
|
+
// only sequence that actually resolves the state.
|
|
497
|
+
if (existingOnDisk !== undefined) {
|
|
498
|
+
if (recordedHash === undefined) {
|
|
499
|
+
throw new Error(`Cannot verify ${key}'s .mcp.json entry matches what ahood last installed (no recorded fingerprint) -- delete the "${skill}" entry from mcpServers in .mcp.json by hand, then run \`ahood skill add ${key}\` to reinstall and enable automatic updates going forward.`);
|
|
500
|
+
}
|
|
501
|
+
if (hashMcpServerConfig(existingOnDisk) !== recordedHash) {
|
|
502
|
+
throw new Error(`${key}'s .mcp.json entry appears to have been modified since install -- refusing to overwrite it. Delete the "${skill}" entry from mcpServers in .mcp.json by hand, then run \`ahood skill add ${key}\` to reinstall.`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
const buffer = await downloadVerifiedArchive(owner, skill, meta);
|
|
506
|
+
const { serverConfig } = await resolveMcpServerConfig(buffer);
|
|
507
|
+
// Read-modify-write under an advisory lock, mirroring installMcpEntry's
|
|
508
|
+
// own pattern. Re-checks the fingerprint here too (not just above) since
|
|
509
|
+
// arbitrary time may have passed since the pre-check -- the download, and
|
|
510
|
+
// any secret prompt inside resolveMcpServerConfig -- during which another
|
|
511
|
+
// process could have changed the entry.
|
|
512
|
+
let previousOnDisk;
|
|
513
|
+
withLock(MCP_CONFIG_PATH, () => {
|
|
514
|
+
const fresh = readMcpConfig();
|
|
515
|
+
const freshServers = fresh.mcpServers;
|
|
516
|
+
const freshExisting = Object.prototype.hasOwnProperty.call(freshServers, skill) ? freshServers[skill] : undefined;
|
|
517
|
+
if (freshExisting !== undefined && (recordedHash === undefined || hashMcpServerConfig(freshExisting) !== recordedHash)) {
|
|
518
|
+
throw new Error(`${key}'s .mcp.json entry changed while updating -- refusing to overwrite it. Re-run \`ahood skill update ${key}\` if this was unexpected.`);
|
|
519
|
+
}
|
|
520
|
+
previousOnDisk = freshExisting;
|
|
521
|
+
freshServers[skill] = serverConfig;
|
|
522
|
+
writeJsonFileAtomic(MCP_CONFIG_PATH, fresh);
|
|
523
|
+
});
|
|
524
|
+
try {
|
|
525
|
+
writeLockfileEntryVerifyingChecksum(LOCKFILE_PATH, key, {
|
|
526
|
+
version: meta.version,
|
|
527
|
+
checksum_sha256: meta.checksum_sha256,
|
|
528
|
+
mcp_config_hash: hashMcpServerConfig(serverConfig),
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
if (!(error instanceof LockfileChecksumConflictError))
|
|
533
|
+
throw error;
|
|
534
|
+
// Roll back the .mcp.json overwrite above under the same lock pattern,
|
|
535
|
+
// restoring the entry to what it was before this update touched it --
|
|
536
|
+
// not deleting it, unlike installMcpEntry's rollback: unless this was
|
|
537
|
+
// the self-heal-a-missing-entry case (previousOnDisk undefined), there
|
|
538
|
+
// WAS a working entry here before this update began.
|
|
539
|
+
withLock(MCP_CONFIG_PATH, () => {
|
|
540
|
+
const fileContents = readMcpConfig();
|
|
541
|
+
const mcpServers = fileContents.mcpServers;
|
|
542
|
+
if (previousOnDisk === undefined) {
|
|
543
|
+
delete mcpServers[skill];
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
mcpServers[skill] = previousOnDisk;
|
|
547
|
+
}
|
|
548
|
+
writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents);
|
|
549
|
+
});
|
|
550
|
+
throw new Error(checksumConflictMessage(key, meta, error.existing));
|
|
551
|
+
}
|
|
552
|
+
console.log(`Updated ${key} to ${meta.version}`);
|
|
553
|
+
if (serverConfig.env && Object.keys(serverConfig.env).length > 0) {
|
|
404
554
|
console.warn(`WARNING: ${MCP_CONFIG_PATH} now contains one or more secret values in plaintext -- do not commit it to version control.`);
|
|
405
555
|
}
|
|
406
556
|
}
|
|
@@ -412,8 +562,14 @@ async function installMcpEntry(owner, skill, meta, buffer) {
|
|
|
412
562
|
export async function downloadVerifiedArchive(owner, skill, meta) {
|
|
413
563
|
const downloadRes = await apiFetch(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/download?version=${encodeURIComponent(meta.version)}`, { headers: { "X-Ahood-Source": "cli" }, redirect: "follow" });
|
|
414
564
|
if (!downloadRes.ok) {
|
|
415
|
-
|
|
416
|
-
|
|
565
|
+
// Same host as every other apiJson call, so this must throw ApiError
|
|
566
|
+
// (not a plain Error) to get the right exitCodeFor mapping (401/404/5xx),
|
|
567
|
+
// and route the body through sanitizeErrorMessage -- an unsanitized WAF/
|
|
568
|
+
// proxy HTML page would otherwise dump straight to the terminal here,
|
|
569
|
+
// exactly what that function exists to prevent elsewhere (ahood-cli#102).
|
|
570
|
+
const rawBody = await downloadRes.text().catch(() => "");
|
|
571
|
+
const body = rawBody ? sanitizeErrorMessage(rawBody) : "";
|
|
572
|
+
throw new ApiError(downloadRes.status, `Download failed with status ${downloadRes.status}${body ? `: ${body}` : ""}`);
|
|
417
573
|
}
|
|
418
574
|
const buffer = await readBoundedBody(downloadRes, MAX_DOWNLOAD_BYTES);
|
|
419
575
|
const actualChecksum = createHash("sha256").update(buffer).digest("hex");
|
|
@@ -433,10 +589,15 @@ export async function add(args) {
|
|
|
433
589
|
// with; if a later fetch of the "same" version disagrees, either the
|
|
434
590
|
// published artifact changed after being pinned or the registry response
|
|
435
591
|
// was tampered with in transit -- either way this is not something to
|
|
436
|
-
// silently accept and overwrite.
|
|
592
|
+
// silently accept and overwrite. This early, UNLOCKED read is a fast-fail
|
|
593
|
+
// optimization only (skip an entire download for the common, non-
|
|
594
|
+
// concurrent case) -- it provides no real guarantee under concurrent
|
|
595
|
+
// installs. The actual enforcement is the locked, read-check-write
|
|
596
|
+
// writeLockfileEntryVerifyingChecksum call at each write site below
|
|
597
|
+
// (ahood-cli#101).
|
|
437
598
|
const existingEntry = readLockfile(LOCKFILE_PATH)[key];
|
|
438
599
|
if (existingEntry && existingEntry.version === meta.version && existingEntry.checksum_sha256 !== meta.checksum_sha256) {
|
|
439
|
-
throw new Error(
|
|
600
|
+
throw new Error(checksumConflictMessage(key, meta, existingEntry));
|
|
440
601
|
}
|
|
441
602
|
if (meta.yanked_at) {
|
|
442
603
|
console.warn(`WARNING: ${key}@${meta.version} has been yanked. Installing anyway.`);
|
|
@@ -458,7 +619,15 @@ export async function add(args) {
|
|
|
458
619
|
mkdirSync(AGENTS_ROOT, { recursive: true });
|
|
459
620
|
const destPath = agentPath(owner, skill);
|
|
460
621
|
writeFileSync(destPath, content);
|
|
461
|
-
|
|
622
|
+
try {
|
|
623
|
+
writeLockfileEntryVerifyingChecksum(LOCKFILE_PATH, key, { version: meta.version, checksum_sha256: meta.checksum_sha256 });
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
if (!(error instanceof LockfileChecksumConflictError))
|
|
627
|
+
throw error;
|
|
628
|
+
rmSync(destPath, { force: true });
|
|
629
|
+
throw new Error(checksumConflictMessage(key, meta, error.existing));
|
|
630
|
+
}
|
|
462
631
|
console.log(`Installed ${key}@${meta.version} to ${destPath}`);
|
|
463
632
|
return;
|
|
464
633
|
}
|
|
@@ -466,12 +635,21 @@ export async function add(args) {
|
|
|
466
635
|
await installMcpEntry(owner, skill, meta, buffer);
|
|
467
636
|
return;
|
|
468
637
|
}
|
|
469
|
-
//
|
|
638
|
+
// Flat, owner@skill-joined directory name so Claude Code's own one-level-deep
|
|
639
|
+
// project-skill scan actually discovers it (see skillDir's comment in spec.ts).
|
|
470
640
|
const destDir = skillDir(owner, skill);
|
|
471
641
|
await extractFreshVersion(buffer, destDir);
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
642
|
+
try {
|
|
643
|
+
writeLockfileEntryVerifyingChecksum(LOCKFILE_PATH, key, {
|
|
644
|
+
version: meta.version,
|
|
645
|
+
checksum_sha256: meta.checksum_sha256,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
catch (error) {
|
|
649
|
+
if (!(error instanceof LockfileChecksumConflictError))
|
|
650
|
+
throw error;
|
|
651
|
+
rmSync(destDir, { recursive: true, force: true });
|
|
652
|
+
throw new Error(checksumConflictMessage(key, meta, error.existing));
|
|
653
|
+
}
|
|
476
654
|
console.log(`Installed ${key}@${meta.version} to ${destDir}`);
|
|
477
655
|
}
|
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
|
}
|