@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 CHANGED
@@ -16,7 +16,7 @@ alice/pdf-tools Merge, split, and compress PDFs from the command line
16
16
  bob/pdf-form-filler Fill PDF form fields from a JSON or CSV data source
17
17
 
18
18
  $ ahood skill add alice/pdf-tools
19
- Installed alice/pdf-tools@1.4.0 to .claude/skills/alice/pdf-tools
19
+ Installed alice/pdf-tools@1.4.0 to .claude/skills/alice@pdf-tools
20
20
  ```
21
21
 
22
22
  ---
@@ -85,7 +85,7 @@ Paths `ahood` reads and writes in your project, none of which need to be gitigno
85
85
 
86
86
  | Path | What's in it |
87
87
  | --- | --- |
88
- | `.claude/skills/<owner>/<skill>/` | Installed skill files. |
88
+ | `.claude/skills/<owner>@<skill>/` | Installed skill files. |
89
89
  | `.claude/agents/<owner>@<skill>.md` | An installed agent, as a single file (`@` owner/skill separator, since Claude Code's own subagent loader scans `.claude/agents/*.md` flat, non-recursively). |
90
90
  | `.claude/skills.lock.json` | Exact installed version + checksum per skill or agent, written by `add`/`update`, read by every install to verify integrity. |
91
91
 
@@ -116,7 +116,7 @@ description: Merge, split, and compress PDFs from the command line
116
116
  ...
117
117
 
118
118
  $ ahood skill add alice/pdf-tools@1.4.0
119
- Installed alice/pdf-tools@1.4.0 to .claude/skills/alice/pdf-tools
119
+ Installed alice/pdf-tools@1.4.0 to .claude/skills/alice@pdf-tools
120
120
  ```
121
121
  </details>
122
122
 
@@ -216,7 +216,7 @@ Sharing is additive -- it doesn't change `alice/pdf-tools`'s own public/private
216
216
  | `ahood skill add <owner>/<skill>[@version]` | Install a skill into .claude/skills/, pinned in the lockfile. |
217
217
  | `ahood skill update [<owner>/<skill> ...] [--dry-run] [--json]` | Move the lockfile pin(s) forward to the latest version, for one skill or all installed skills at once. |
218
218
  | `ahood skill outdated [<owner>/<skill> ...] [--json]` | Read-only staleness check comparing current and latest versions (with changelog) for installed skills. |
219
- | `ahood skill remove <owner>/<skill>` | Uninstall and unpin a skill (local only). |
219
+ | `ahood skill remove <owner>/<skill> [--yes]` | Uninstall and unpin a skill (local only, prompts for confirmation unless --yes is passed). |
220
220
  | `ahood skill edit <owner>/<skill> [--tagline] [--tags] [--license] [--visibility] [--homepage] [--repository]` | Update a skill you own, changing only the flags you pass. |
221
221
  | `ahood skill unpublish <owner>/<skill>[@version] [--yes]` | Delete a skill from the registry for every consumer, or yank a single version, not just your local install (prompts for confirmation unless --yes is passed). |
222
222
  | `ahood skill star <owner>/<skill>` | Star a skill. |
@@ -235,12 +235,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>` | Remove a member from a group you own. |
239
- | `ahood group leave <group>` | Leave a group you belong to. |
238
+ | `ahood group remove-member <group> <username> [--yes]` | Remove a member from a group you own (prompts for confirmation unless --yes is passed). |
239
+ | `ahood group leave <group> [--yes]` | Leave a group you belong to (prompts for confirmation unless --yes is passed). |
240
240
  | `ahood group delete <group> [--yes]` | Permanently delete a group you own (prompts for confirmation unless --yes is passed). |
241
+
242
+ ### Snap
243
+
244
+ | Command | What it does |
245
+ | --- | --- |
246
+ | `ahood snap create <content> [--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
 
@@ -3,15 +3,21 @@ import { mkdirSync, renameSync, rmSync, rmdirSync, writeFileSync, readFileSync,
3
3
  import { basename, dirname, join, resolve, sep } from "node:path";
4
4
  import * as tarStream from "tar-stream";
5
5
  import { gunzipSync } from "node:zlib";
6
- import { apiFetch, apiJson } from "../http.js";
6
+ import { apiFetch, apiJson, ApiError, sanitizeErrorMessage } from "../http.js";
7
7
  import { LOCKFILE_PATH, parseOwnerSkillVersion, skillDir, agentPath, AGENTS_ROOT, MCP_CONFIG_PATH } from "../spec.js";
8
- import { readLockfile, writeLockfileEntry, withLock, writeJsonFileAtomic } from "../lockfile.js";
8
+ import { readLockfile, withLock, writeJsonFileAtomic, writeLockfileEntryVerifyingChecksum, LockfileChecksumConflictError, } from "../lockfile.js";
9
9
  import { promptSecret } from "../secret-prompt.js";
10
10
  import { UsageError } from "../usage-error.js";
11
11
  const USAGE = "Usage: ahood skill add <owner>/<skill>[@version]";
12
12
  const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024; // 50 MB compressed
13
13
  const MAX_EXTRACTED_BYTES = 200 * 1024 * 1024; // 200 MB decompressed
14
14
  const MAX_ENTRY_COUNT = 10_000;
15
+ // Shared wording for the checksum-pin tamper-detection guard, used both by
16
+ // the early unlocked fast-fail check in add() and by the real,
17
+ // concurrency-safe enforcement at each write site below (ahood-cli#101).
18
+ function checksumConflictMessage(key, meta, existing) {
19
+ return `Refusing to install ${key}@${meta.version}: its checksum (${meta.checksum_sha256}) does not match the one already pinned in the lockfile (${existing.checksum_sha256}) for this exact version. If you trust this change, remove ${key}'s lockfile entry first.`;
20
+ }
15
21
  // GET /api/v1/skills/{owner}/{skill}/versions/{version} matches the version
16
22
  // string with an exact .eq() (verified live against this branch's route) --
17
23
  // there is no "latest" literal in skill_versions.version, so requesting
@@ -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
- function readMcpConfig() {
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
- async function installMcpEntry(owner, skill, meta, buffer) {
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) and check
363
- // for an existing .mcp.json collision BEFORE prompting for any secret --
364
- // an install that's going to be refused anyway must not first cost the
365
- // user a masked secret prompt. buildMcpServerConfig is called here with an
366
- // empty env purely to run its validation/throw; the result is discarded
367
- // and rebuilt below once secretEnv is known.
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
- writeLockfileEntry(LOCKFILE_PATH, `${owner}/${skill}`, { version: meta.version, checksum_sha256: meta.checksum_sha256 });
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
- if (Object.keys(secretEnv).length > 0) {
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
- const body = await downloadRes.text().catch(() => "");
416
- throw new Error(`Download failed with status ${downloadRes.status}${body ? `: ${body}` : ""}`);
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(`Refusing to install ${key}@${meta.version}: its checksum (${meta.checksum_sha256}) does not match the one already pinned in the lockfile (${existingEntry.checksum_sha256}) for this exact version. If you trust this change, remove ${key}'s lockfile entry first.`);
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
- writeLockfileEntry(LOCKFILE_PATH, key, { version: meta.version, checksum_sha256: meta.checksum_sha256 });
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
- // Owner-namespaced on disk, mirroring npm's node_modules/@scope/package.
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
- writeLockfileEntry(LOCKFILE_PATH, key, {
473
- version: meta.version,
474
- checksum_sha256: meta.checksum_sha256,
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
  }
@@ -8,8 +8,8 @@ const LIST_USAGE = "Usage: ahood group list [--json]";
8
8
  const MEMBERS_USAGE = "Usage: ahood group members <group> [--json]";
9
9
  const INVITE_LINK_USAGE = "Usage: ahood group invite-link <group> [--json]";
10
10
  const JOIN_USAGE = "Usage: ahood group join <invite-url-or-token>";
11
- const REMOVE_MEMBER_USAGE = "Usage: ahood group remove-member <group> <username>";
12
- const LEAVE_USAGE = "Usage: ahood group leave <group>";
11
+ const REMOVE_MEMBER_USAGE = "Usage: ahood group remove-member <group> <username> [--yes]";
12
+ const LEAVE_USAGE = "Usage: ahood group leave <group> [--yes]";
13
13
  const DELETE_USAGE = "Usage: ahood group delete <group> [--yes]";
14
14
  // Creates a group (caller becomes owner). Follows edit.ts's convention of
15
15
  // requiring the positional argument first, since --description takes a
@@ -115,10 +115,22 @@ export async function joinGroup(args) {
115
115
  // "remove yourself"). No flags today, so plain positional filtering is
116
116
  // unambiguous.
117
117
  export async function removeMember(args) {
118
+ const yes = args.includes("--yes");
118
119
  const positional = args.filter((a) => !a.startsWith("--"));
119
120
  const [slug, username] = positional;
120
121
  if (!slug || !username)
121
122
  throw new UsageError(REMOVE_MEMBER_USAGE);
123
+ // Mirrors deleteGroup's confirm-before-destroy pattern (CLAUDE.md:
124
+ // "Destructive commands ... prompt for confirmation unless --yes is
125
+ // passed") -- a typo'd username here instantly removes the wrong member
126
+ // with no other command to undo it (ahood-cli#99).
127
+ const confirmed = yes
128
+ ? true
129
+ : await confirm(`Remove ${username} from ${slug}? Type "yes" to confirm: `);
130
+ if (!confirmed) {
131
+ console.log("Aborted.");
132
+ return;
133
+ }
122
134
  await apiJson(`/api/v1/groups/${encodeURIComponent(slug)}/members/${encodeURIComponent(username)}`, { method: "DELETE" });
123
135
  console.log(`Removed ${username} from ${slug}.`);
124
136
  }
@@ -134,9 +146,18 @@ async function getOwnUsername() {
134
146
  // handles both cases identically (DELETE .../members/{username} with your
135
147
  // own username removes you, same as an owner removing someone else).
136
148
  export async function leaveGroup(args) {
149
+ const yes = args.includes("--yes");
137
150
  const slug = args.find((a) => !a.startsWith("--"));
138
151
  if (!slug)
139
152
  throw new UsageError(LEAVE_USAGE);
153
+ // Same confirm-before-destroy gate as removeMember -- an accidentally
154
+ // re-run `leave` drops you from a private group with no other command to
155
+ // undo it (ahood-cli#99).
156
+ const confirmed = yes ? true : await confirm(`Leave ${slug}? Type "yes" to confirm: `);
157
+ if (!confirmed) {
158
+ console.log("Aborted.");
159
+ return;
160
+ }
140
161
  const username = await getOwnUsername();
141
162
  await apiJson(`/api/v1/groups/${encodeURIComponent(slug)}/members/${encodeURIComponent(username)}`, { method: "DELETE" });
142
163
  console.log(`Left ${slug}.`);
@@ -29,7 +29,7 @@ function normalizeToSlug(name) {
29
29
  // documented Claude Code skill format: YAML frontmatter delimited by `---`
30
30
  // lines, with a `name` and a `description` field -- the same shape every
31
31
  // real SKILL.md in this ecosystem uses (see e.g. any installed skill under
32
- // .claude/skills/<owner>/<skill>/SKILL.md). Keeping to exactly those two
32
+ // .claude/skills/<owner>@<skill>/SKILL.md). Keeping to exactly those two
33
33
  // fields, rather than inventing extra ones, means nothing here needs
34
34
  // updating just because publish.ts's own validation happens to be looser
35
35
  // today.
@@ -15,6 +15,10 @@ export async function listSkills(args = []) {
15
15
  return;
16
16
  }
17
17
  for (const skill of skills) {
18
- console.log(`${skill.profiles.username}/${skill.slug} (${skill.visibility}) - ${skill.name}${skill.tagline ? `: ${skill.tagline}` : ""} (${skill.downloads_count} downloads, ${skill.stars_count} stars)`);
18
+ // profiles comes from a server-side join that can plausibly be null for
19
+ // an individual row -- degrade that one row instead of crashing the
20
+ // whole command (ahood-cli#106).
21
+ const username = skill.profiles?.username ?? "(unknown)";
22
+ console.log(`${username}/${skill.slug} (${skill.visibility}) - ${skill.name}${skill.tagline ? `: ${skill.tagline}` : ""} (${skill.downloads_count} downloads, ${skill.stars_count} stars)`);
19
23
  }
20
24
  }
@@ -21,7 +21,6 @@ export async function login() {
21
21
  while (Date.now() < deadline) {
22
22
  await sleep(2000);
23
23
  let res;
24
- let body;
25
24
  try {
26
25
  // Poll against the CONFIGURED API host (getApiUrl()), not a URL derived
27
26
  // from the server-supplied verification_url -- that field is only ever
@@ -29,15 +28,33 @@ export async function login() {
29
28
  // or redirected registry response can't point this at a host that
30
29
  // then harvests the device code and hands back an attacker's token.
31
30
  res = await apiFetchWithTimeout(`/api/v1/auth/cli/device/${encodeURIComponent(code)}`);
31
+ }
32
+ catch (error) {
33
+ // A THROWN fetch (DNS blip, dropped socket) is transient by nature, and
34
+ // this loop runs for up to ten minutes while a human walks to their
35
+ // browser -- one bad network moment must not kill a login that is
36
+ // about to succeed. The deadline is untouched, so this cannot loop
37
+ // forever.
38
+ console.error(`Polling failed (${error instanceof Error ? error.message : String(error)}); retrying...`);
39
+ continue;
40
+ }
41
+ // Checked BEFORE res.json(): these are terminal statuses regardless of
42
+ // body shape, so a non-JSON body on them (e.g. a proxy/WAF error page --
43
+ // the same threat class sanitizeErrorMessage guards against elsewhere)
44
+ // must fail fast here instead of throwing inside the try below and being
45
+ // swallowed as "transient, retry" for up to ten minutes (ahood-cli#104).
46
+ if (res.status === 410 || res.status === 404) {
47
+ throw new Error("This login was cancelled or expired. Run `ahood login` again.");
48
+ }
49
+ let body;
50
+ try {
32
51
  body = (await res.json());
33
52
  }
34
53
  catch (error) {
35
- // A THROWN fetch (DNS blip, dropped socket, a body that isn't JSON) is
36
- // transient by nature, and this loop runs for up to ten minutes while a
37
- // human walks to their browser -- one bad network moment must not kill
38
- // a login that is about to succeed. HTTP *statuses* are still decided
39
- // below; only the transport failure is retried. The deadline is
40
- // untouched, so this cannot loop forever.
54
+ // A genuinely malformed/non-JSON body on a non-terminal status (e.g. a
55
+ // transient 502 while still pending) is the same kind of transient
56
+ // blip a thrown fetch above is -- retry rather than fail the whole
57
+ // login.
41
58
  console.error(`Polling failed (${error instanceof Error ? error.message : String(error)}); retrying...`);
42
59
  continue;
43
60
  }
@@ -46,9 +63,6 @@ export async function login() {
46
63
  console.log("Logged in.");
47
64
  return;
48
65
  }
49
- if (res.status === 410 || res.status === 404) {
50
- throw new Error("This login was cancelled or expired. Run `ahood login` again.");
51
- }
52
66
  // status === "pending" -- keep polling.
53
67
  }
54
68
  throw new Error("Login timed out. Run `ahood login` again.");
@@ -42,6 +42,10 @@ const USAGE = "Usage: ahood skill publish <owner>/<skill>@<version> [--path <dir
42
42
  // .npmrc / .yarnrc.yml / .netrc / .pypirc -- package-manager/network auth
43
43
  // tokens (npm_..., ghp_...) that don't match the server
44
44
  // scanner's regexes either.
45
+ // .envrc -- direnv's own file, commonly holding `export`ed secrets,
46
+ // exact-matched (not a `.env` prefix match -- "rc" makes it a
47
+ // distinct name, not a `.env.*` variant) since it doesn't
48
+ // match the server scanner's regexes either (ahood-cli#97).
45
49
  // id_rsa / id_ed25519 / id_ecdsa / id_dsa (+ .pub) / .ssh -- SSH keys.
46
50
  // .aws / .docker -- cloud and registry credentials.
47
51
  // .DS_Store -- noise.
@@ -53,6 +57,7 @@ const EXCLUDED_NAMES = new Set([
53
57
  ".yarnrc.yml",
54
58
  ".netrc",
55
59
  ".pypirc",
60
+ ".envrc",
56
61
  ".ssh",
57
62
  ".aws",
58
63
  ".docker",
@@ -46,5 +46,11 @@ export async function read(args) {
46
46
  // Plain mode prints the raw content verbatim -- no formatting/labels/
47
47
  // trailing decoration -- since the whole point is fast/pipeable access to
48
48
  // the exact prompt text (e.g. piping into a file or another tool).
49
- console.log(content);
49
+ // process.stdout.write, not console.log: console.log unconditionally
50
+ // appends its own "\n", so a well-formed SKILL.md that already ends in one
51
+ // (the normal case) came out with a second, spurious trailing blank line --
52
+ // a real, reported round-trip mismatch between "ahood skill read" and the
53
+ // source file, even though the registry itself stores/serves the content
54
+ // byte-exact (ahood-cli#90).
55
+ process.stdout.write(content);
50
56
  }