@ahood/cli 0.8.0 → 0.8.2

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
@@ -243,13 +243,14 @@ Sharing is additive -- it doesn't change `alice/pdf-tools`'s own public/private
243
243
 
244
244
  | Command | What it does |
245
245
  | --- | --- |
246
- | `ahood snap create <content> [--json]` | Capture a new private snap, from an argument or piped stdin. |
246
+ | `ahood snap create <content> [--tags tag1,tag2] [--json]` | Capture a new private snap, from an argument or piped stdin. |
247
247
  | `ahood snap list [--json] [--limit <n>]` | List your own snaps, most recent first. |
248
248
  | `ahood snap search <query> [--json] [--limit <n>]` | Search your own snaps by content. |
249
249
  | `ahood snap show <id> [--json]` | Print a single snap's full content. |
250
250
  | `ahood snap remove <id> [--yes]` | Permanently delete a snap (prompts for confirmation unless --yes is passed). |
251
251
  | `ahood snap share <id> [--json]` | Mint (or return the existing) shareable link for a snap. |
252
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). |
253
254
  <!-- COMMANDS_TABLE_END -->
254
255
 
255
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.
@@ -325,7 +325,9 @@ function summarizeConflictingEntry(entry) {
325
325
  // raw TypeError, and would let `"mcpServers": []` pass the collision check
326
326
  // silently (an array's numeric-index write is then dropped entirely by
327
327
  // JSON.stringify), reporting a successful install that wrote nothing.
328
- 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() {
329
331
  if (!existsSync(MCP_CONFIG_PATH))
330
332
  return { mcpServers: {} };
331
333
  let parsed;
@@ -361,18 +363,40 @@ function assertNoCollision(fileContents, owner, skill) {
361
363
  `Remove it manually first if you want to reinstall ${owner}/${skill}.`);
362
364
  }
363
365
  }
364
- 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
+ // `existingEnv` carries forward the secret values already sitting in the
374
+ // entry being updated, so a routine `ahood skill update` doesn't re-prompt
375
+ // for a credential the user has already supplied. Only ever passed by
376
+ // updateMcpEntry, and only from an entry whose fingerprint it has just
377
+ // verified -- i.e. proven to be ahood's own unmodified entry, not something
378
+ // hand-edited or written by another tool.
379
+ //
380
+ // `allowNonTtyPrompt` (default true, preserving `echo secret | ahood skill
381
+ // add ...` for a fresh install the user explicitly asked for) is false on
382
+ // the update path: `ahood skill update` with no arguments walks EVERY locked
383
+ // entry, so a prompt there is never something the caller aimed at. Left
384
+ // interactive, an unattended update would hang on an idle pipe with no
385
+ // timeout -- against this CLI's "must never hang" rule -- or, worse, silently
386
+ // bank the first line of unrelated piped input (`echo y | ahood skill
387
+ // update`) as the credential and fingerprint over it.
388
+ export async function resolveMcpServerConfig(buffer, options = {}) {
389
+ const { existingEnv, allowNonTtyPrompt = true } = options;
365
390
  const content = await extractSingleFileContent(buffer, "server.json");
366
391
  const manifest = JSON.parse(content.toString("utf-8"));
367
392
  // Validate the manifest's shape (unsupported registry_type/runtime_hint,
368
- // or neither a single 'packages' nor a single 'remotes' entry) and check
369
- // for an existing .mcp.json collision BEFORE prompting for any secret --
370
- // an install that's going to be refused anyway must not first cost the
371
- // user a masked secret prompt. buildMcpServerConfig is called here with an
372
- // empty env purely to run its validation/throw; the result is discarded
373
- // and rebuilt below once secretEnv is known.
393
+ // or neither a single 'packages' nor a single 'remotes' entry) BEFORE
394
+ // prompting for any secret -- an install/update that's going to be
395
+ // refused anyway must not first cost the user a masked secret prompt.
396
+ // buildMcpServerConfig is called here with an empty env purely to run its
397
+ // validation/throw; the result is discarded and rebuilt below once
398
+ // secretEnv is known.
374
399
  buildMcpServerConfig(manifest, {});
375
- assertNoCollision(readMcpConfig(), owner, skill);
376
400
  const envVars = manifest.packages?.[0]?.environment_variables ?? [];
377
401
  const secretEnv = {};
378
402
  for (const variable of envVars) {
@@ -384,11 +408,66 @@ async function installMcpEntry(owner, skill, meta, buffer) {
384
408
  // (per the design spec), which uses `if (process.env.AHOOD_TOKEN)`
385
409
  // specifically so an empty-string env var falls through to the real
386
410
  // source instead of silently installing an empty secret.
387
- secretEnv[variable.name] = fromEnv
388
- ? fromEnv
389
- : await promptSecret(`${sanitizeForTerminal(variable.name)} (${sanitizeForTerminal(variable.description)}): `);
411
+ if (fromEnv) {
412
+ secretEnv[variable.name] = fromEnv;
413
+ continue;
414
+ }
415
+ // Ordered after the env var deliberately: exporting the variable stays
416
+ // the way to rotate a credential during an update, rather than being
417
+ // shadowed by the stale value already on disk.
418
+ const carriedForward = existingEnv?.[variable.name];
419
+ if (carriedForward) {
420
+ secretEnv[variable.name] = carriedForward;
421
+ continue;
422
+ }
423
+ if (!allowNonTtyPrompt && !process.stdin.isTTY) {
424
+ throw new Error(`${sanitizeForTerminal(variable.name)} is required by ${sanitizeForTerminal(manifest.name)} but is not set, and there is no terminal to prompt on -- export ${sanitizeForTerminal(variable.name)} and re-run.`);
425
+ }
426
+ secretEnv[variable.name] = await promptSecret(`${sanitizeForTerminal(variable.name)} (${sanitizeForTerminal(variable.description)}): `);
390
427
  }
391
428
  const serverConfig = buildMcpServerConfig(manifest, secretEnv);
429
+ return { manifest, serverConfig };
430
+ }
431
+ // Fingerprint of exactly what ahood wrote into .mcp.json's mcpServers.<skill>
432
+ // entry, stored in the lockfile alongside the version/checksum pin. Lets
433
+ // remove/update (ahood-cli#169) tell "the on-disk entry is still exactly
434
+ // what I last installed" from "something (the user, another tool) has since
435
+ // hand-edited it" -- without this, a real (not just warned-about) removal
436
+ // or update risks silently discarding an intentional local edit, the same
437
+ // class of risk assertNoCollision below exists to prevent on a *fresh*
438
+ // install. JSON.stringify's key order matches insertion order for a plain
439
+ // object, and JSON.parse's key order matches source-text order -- so this
440
+ // is stable between the object this process just built and the same object
441
+ // read back from disk later, as long as nothing reorders keys in between.
442
+ export function hashMcpServerConfig(config) {
443
+ return createHash("sha256").update(JSON.stringify(config)).digest("hex");
444
+ }
445
+ // Pulls the `env` map out of an .mcp.json entry read off disk, so an update
446
+ // can carry its secrets forward (see resolveMcpServerConfig's `existingEnv`).
447
+ // Written defensively -- the entry is `unknown` here, and only a string value
448
+ // is usable as a credential -- even though the caller has already fingerprint-
449
+ // verified it, so that a shape this function can't read degrades into a
450
+ // prompt rather than a crash.
451
+ function readMcpEntryEnv(entry) {
452
+ if (typeof entry !== "object" || entry === null)
453
+ return undefined;
454
+ const env = entry.env;
455
+ if (typeof env !== "object" || env === null)
456
+ return undefined;
457
+ const result = {};
458
+ for (const [name, value] of Object.entries(env)) {
459
+ if (typeof value === "string")
460
+ result[name] = value;
461
+ }
462
+ return result;
463
+ }
464
+ async function installMcpEntry(owner, skill, meta, buffer) {
465
+ // Check for an existing .mcp.json collision BEFORE resolving secrets --
466
+ // matches resolveMcpServerConfig's own "don't cost the user a prompt for
467
+ // something that's going to be refused anyway" reasoning, just for the
468
+ // collision check specifically rather than the manifest-shape check.
469
+ assertNoCollision(readMcpConfig(), owner, skill);
470
+ const { serverConfig } = await resolveMcpServerConfig(buffer);
392
471
  // Read-modify-write under an advisory lock, mirroring lockfile.ts's own
393
472
  // writeLockfileEntry: .mcp.json sits right next to the lockfile and can
394
473
  // hold other servers' secrets, so it gets the same protection against a
@@ -406,7 +485,11 @@ async function installMcpEntry(owner, skill, meta, buffer) {
406
485
  });
407
486
  const mcpKey = `${owner}/${skill}`;
408
487
  try {
409
- writeLockfileEntryVerifyingChecksum(LOCKFILE_PATH, mcpKey, { version: meta.version, checksum_sha256: meta.checksum_sha256 });
488
+ writeLockfileEntryVerifyingChecksum(LOCKFILE_PATH, mcpKey, {
489
+ version: meta.version,
490
+ checksum_sha256: meta.checksum_sha256,
491
+ mcp_config_hash: hashMcpServerConfig(serverConfig),
492
+ });
410
493
  }
411
494
  catch (error) {
412
495
  if (!(error instanceof LockfileChecksumConflictError))
@@ -421,7 +504,125 @@ async function installMcpEntry(owner, skill, meta, buffer) {
421
504
  throw new Error(checksumConflictMessage(mcpKey, meta, error.existing));
422
505
  }
423
506
  console.log(`Installed ${owner}/${skill}@${meta.version} into ${MCP_CONFIG_PATH} as "${skill}"`);
424
- if (Object.keys(secretEnv).length > 0) {
507
+ // Only the npm+npx package path ever carries secrets into `env` (headers
508
+ // on a remote entry are static per v1's scope, per buildMcpServerConfig) --
509
+ // checking the final serverConfig's own `env` key (rather than threading
510
+ // secretEnv out of resolveMcpServerConfig separately) means this warning
511
+ // can never drift from what was actually written to disk.
512
+ if (serverConfig.env && Object.keys(serverConfig.env).length > 0) {
513
+ console.warn(`WARNING: ${MCP_CONFIG_PATH} now contains one or more secret values in plaintext -- do not commit it to version control.`);
514
+ }
515
+ }
516
+ // Re-runs the mcp install flow for an already-installed entry, moving its
517
+ // lockfile pin forward -- update.ts's real mcp-update path (ahood-cli#169).
518
+ // Unlike installMcpEntry, "the entry already exists" is expected here (it's
519
+ // the very entry being updated), so the safety check is inverted: rather
520
+ // than refusing because something's already there, this refuses UNLESS the
521
+ // on-disk entry's fingerprint still matches what was recorded at the last
522
+ // install/update -- same posture as assertNoCollision, applied to "is this
523
+ // still mine to touch" instead of "is this mine to create". If the entry is
524
+ // missing entirely, there's nothing to protect -- write fresh (self-heals a
525
+ // lockfile pin whose .mcp.json entry was deleted by hand without going
526
+ // through `ahood skill remove`). If it's present but doesn't match (or no
527
+ // fingerprint was ever recorded, e.g. an entry installed before this field
528
+ // existed), refuse rather than silently overwrite a possibly-intentional
529
+ // local edit.
530
+ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
531
+ const key = `${owner}/${skill}`;
532
+ const recordedHash = currentEntry?.mcp_config_hash;
533
+ const fileContents = readMcpConfig();
534
+ const mcpServers = fileContents.mcpServers;
535
+ const existingOnDisk = Object.prototype.hasOwnProperty.call(mcpServers, skill) ? mcpServers[skill] : undefined;
536
+ // Both refusal messages below point at manually editing .mcp.json, not
537
+ // `ahood skill remove` -- remove refuses to delete an entry under
538
+ // exactly these same two conditions (no recorded fingerprint, or a
539
+ // fingerprint mismatch), so telling the user to "remove then add" would
540
+ // send them into a sequence where remove leaves the entry in place *and*
541
+ // clears the lockfile pin, and the follow-up add then fails on the very
542
+ // same entry as a collision -- pin gone, credential still live, no CLI
543
+ // path forward. Editing .mcp.json by hand and then running `add` is the
544
+ // only sequence that actually resolves the state.
545
+ if (existingOnDisk !== undefined) {
546
+ if (recordedHash === undefined) {
547
+ 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.`);
548
+ }
549
+ if (hashMcpServerConfig(existingOnDisk) !== recordedHash) {
550
+ 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.`);
551
+ }
552
+ }
553
+ const buffer = await downloadVerifiedArchive(owner, skill, meta);
554
+ // Reuse the secrets already in the entry rather than re-prompting for
555
+ // them. Safe to trust specifically because the fingerprint check above has
556
+ // just proven this is ahood's own unmodified entry; in the self-heal case
557
+ // (no entry on disk at all) there's nothing to carry forward and a genuine
558
+ // prompt is correct.
559
+ const existingEnv = readMcpEntryEnv(existingOnDisk);
560
+ const { serverConfig } = await resolveMcpServerConfig(buffer, { existingEnv, allowNonTtyPrompt: false });
561
+ // Read-modify-write under an advisory lock, mirroring installMcpEntry's
562
+ // own pattern. Re-checks the fingerprint here too (not just above) since
563
+ // arbitrary time may have passed since the pre-check -- the download, and
564
+ // any secret prompt inside resolveMcpServerConfig -- during which another
565
+ // process could have changed the entry.
566
+ let previousOnDisk;
567
+ withLock(MCP_CONFIG_PATH, () => {
568
+ const fresh = readMcpConfig();
569
+ const freshServers = fresh.mcpServers;
570
+ const freshExisting = Object.prototype.hasOwnProperty.call(freshServers, skill) ? freshServers[skill] : undefined;
571
+ if (freshExisting !== undefined && (recordedHash === undefined || hashMcpServerConfig(freshExisting) !== recordedHash)) {
572
+ 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.`);
573
+ }
574
+ previousOnDisk = freshExisting;
575
+ freshServers[skill] = serverConfig;
576
+ writeJsonFileAtomic(MCP_CONFIG_PATH, fresh);
577
+ });
578
+ try {
579
+ writeLockfileEntryVerifyingChecksum(LOCKFILE_PATH, key, {
580
+ version: meta.version,
581
+ checksum_sha256: meta.checksum_sha256,
582
+ mcp_config_hash: hashMcpServerConfig(serverConfig),
583
+ });
584
+ }
585
+ catch (error) {
586
+ // Roll back the .mcp.json overwrite above under the same lock pattern,
587
+ // restoring the entry to what it was before this update touched it --
588
+ // not deleting it, unlike installMcpEntry's rollback: unless this was
589
+ // the self-heal-a-missing-entry case (previousOnDisk undefined), there
590
+ // WAS a working entry here before this update began.
591
+ //
592
+ // Runs for EVERY lockfile failure, not just a checksum conflict. A
593
+ // rethrow-before-rollback here left .mcp.json holding the new config
594
+ // while the lockfile kept the old version and old mcp_config_hash, after
595
+ // which the fingerprint never matches again and both `update` and
596
+ // `remove` permanently refuse -- the exact dead end the comments above
597
+ // exist to prevent, reachable from nothing worse than withLock's 5s
598
+ // timeout against a concurrent ahood, or an EACCES/ENOSPC on the write.
599
+ try {
600
+ withLock(MCP_CONFIG_PATH, () => {
601
+ const fileContents = readMcpConfig();
602
+ const mcpServers = fileContents.mcpServers;
603
+ if (previousOnDisk === undefined) {
604
+ delete mcpServers[skill];
605
+ }
606
+ else {
607
+ mcpServers[skill] = previousOnDisk;
608
+ }
609
+ writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents);
610
+ });
611
+ }
612
+ catch {
613
+ // The rollback can fail for the same reason the write did (lock still
614
+ // contended, disk still full). Warn rather than throw: the original
615
+ // error below is the more actionable one, and masking it with this
616
+ // one would hide why the update failed in the first place.
617
+ console.warn(`WARNING: could not roll back ${MCP_CONFIG_PATH} after ${key}'s update failed -- its mcpServers."${skill}" entry may be on the new version while ${LOCKFILE_PATH} still pins the old one. Delete that entry by hand and run \`ahood skill add ${key}\` to reinstall.`);
618
+ }
619
+ if (error instanceof LockfileChecksumConflictError) {
620
+ throw new Error(checksumConflictMessage(key, meta, error.existing));
621
+ }
622
+ throw error;
623
+ }
624
+ console.log(`Updated ${key} to ${meta.version}`);
625
+ if (serverConfig.env && Object.keys(serverConfig.env).length > 0) {
425
626
  console.warn(`WARNING: ${MCP_CONFIG_PATH} now contains one or more secret values in plaintext -- do not commit it to version control.`);
426
627
  }
427
628
  }
@@ -1,7 +1,8 @@
1
- import { existsSync, readFileSync, rmSync, unlinkSync } from "node:fs";
1
+ import { existsSync, rmSync, unlinkSync } from "node:fs";
2
2
  import { confirm } from "../confirm.js";
3
- import { readLockfile, removeLockfileEntry } from "../lockfile.js";
3
+ import { readLockfile, removeLockfileEntry, withLock, writeJsonFileAtomic } from "../lockfile.js";
4
4
  import { LOCKFILE_PATH, parseOwnerSkill, skillDir, agentPath, MCP_CONFIG_PATH } from "../spec.js";
5
+ import { readMcpConfig, hashMcpServerConfig } from "./add.js";
5
6
  import { UsageError } from "../usage-error.js";
6
7
  const USAGE = "Usage: ahood skill remove <owner>/<skill> [--yes]";
7
8
  export async function remove(args) {
@@ -19,7 +20,8 @@ export async function remove(args) {
19
20
  // never a directory under .claude/skills/ -- distinct from the dir check above.
20
21
  const agentFile = agentPath(owner, skill);
21
22
  const agentExisted = existsSync(agentFile);
22
- const hadLockfileEntry = key in readLockfile(LOCKFILE_PATH);
23
+ const lockEntry = readLockfile(LOCKFILE_PATH)[key];
24
+ const hadLockfileEntry = lockEntry !== undefined;
23
25
  if (!dirExisted && !agentExisted && !hadLockfileEntry) {
24
26
  console.error(`${key} was not installed -- nothing to remove.`);
25
27
  process.exitCode = 1;
@@ -42,30 +44,71 @@ export async function remove(args) {
42
44
  // An mcp-kind install has no directory or agent file on disk -- its only
43
45
  // footprint here is the lockfile entry just cleared above and a live
44
46
  // entry in .mcp.json (which add.ts's installMcpEntry merged in, possibly
45
- // holding a resolved secret in its `env`). remove() doesn't attempt a real
46
- // mcp removal, but staying silent about that entry repeats the exact
47
- // false-assurance bug already fixed once for agent installs above: a user
48
- // who removes an mcp artifact because they no longer trust it would be
49
- // told it's gone while the MCP server (and its credential) still runs on
50
- // the next Claude Code start -- and since the lockfile pin is now cleared,
51
- // nothing will ever surface this again via `list`/`update`. So: warn
52
- // instead of pretending it's gone.
53
- if (existsSync(MCP_CONFIG_PATH)) {
54
- try {
55
- const parsed = JSON.parse(readFileSync(MCP_CONFIG_PATH, "utf-8"));
56
- const mcpServers = parsed?.mcpServers;
57
- if (mcpServers &&
58
- typeof mcpServers === "object" &&
59
- !Array.isArray(mcpServers) &&
60
- Object.prototype.hasOwnProperty.call(mcpServers, skill)) {
61
- console.warn(`WARNING: ${key} still has an entry in ${MCP_CONFIG_PATH} (which may contain secrets you entered) -- remove it manually.`);
47
+ // holding a resolved secret in its `env`). Real removal (delete that one
48
+ // key) only happens when the on-disk entry's fingerprint still matches
49
+ // what was recorded at install/update time -- same posture as add.ts's
50
+ // own assertNoCollision: never blind-write/delete something this process
51
+ // didn't verify it still owns. Without SOME check here, "Removed" would
52
+ // repeat the exact false-assurance bug already fixed once for agent
53
+ // installs above (a user told it's gone while the MCP server, and its
54
+ // credential, still runs on the next Claude Code start) -- but a hand-
55
+ // edited entry (fingerprint mismatch, or no fingerprint recorded at all,
56
+ // e.g. an mcp entry installed before this field existed) is left in
57
+ // place and warned about instead, exactly as before this fix.
58
+ let removedMcpEntry = false;
59
+ let mcpEntryModified = false;
60
+ let mcpEntryPresent = false;
61
+ let recordedHash;
62
+ try {
63
+ const fileContents = readMcpConfig();
64
+ const mcpServers = fileContents.mcpServers;
65
+ mcpEntryPresent = Object.prototype.hasOwnProperty.call(mcpServers, skill);
66
+ if (mcpEntryPresent) {
67
+ recordedHash = lockEntry?.mcp_config_hash;
68
+ const currentHash = hashMcpServerConfig(mcpServers[skill]);
69
+ if (recordedHash !== undefined) {
70
+ mcpEntryModified = recordedHash !== currentHash;
62
71
  }
63
72
  }
73
+ }
74
+ catch {
75
+ // A malformed .mcp.json isn't this command's problem to fix or crash
76
+ // on -- add.ts's readMcpConfig is the strict validator for that path.
77
+ // mcpEntryPresent stays false, so there's nothing to warn about either:
78
+ // if we can't even read the file, we can't know whether this skill has
79
+ // an entry in it.
80
+ }
81
+ if (mcpEntryPresent && recordedHash !== undefined && !mcpEntryModified) {
82
+ try {
83
+ // Re-check under lock rather than trusting the read above -- another
84
+ // process could have changed or removed the entry in between,
85
+ // mirroring add.ts's own re-check-under-lock before it writes. This
86
+ // try/catch is deliberately separate from the read above: a failure
87
+ // HERE (a lock-acquisition timeout, an EACCES/ENOSPC/etc. write
88
+ // failure) must still fall through to the warning below rather than
89
+ // being silently swallowed -- letting it share the read's catch block
90
+ // was the exact bug that let a failed delete print a bare "Removed"
91
+ // with the credential still live (the false-assurance case this
92
+ // whole mechanism exists to prevent).
93
+ withLock(MCP_CONFIG_PATH, () => {
94
+ const fresh = readMcpConfig();
95
+ const freshServers = fresh.mcpServers;
96
+ if (Object.prototype.hasOwnProperty.call(freshServers, skill) &&
97
+ hashMcpServerConfig(freshServers[skill]) === recordedHash) {
98
+ delete freshServers[skill];
99
+ writeJsonFileAtomic(MCP_CONFIG_PATH, fresh);
100
+ removedMcpEntry = true;
101
+ }
102
+ });
103
+ }
64
104
  catch {
65
- // A malformed .mcp.json isn't this command's problem to fix or crash
66
- // on -- add.ts's readMcpConfig is the strict validator for that path.
67
- // Skip the warning rather than throw here.
105
+ // removedMcpEntry stays false -- falls through to the warning below.
68
106
  }
69
107
  }
70
- console.log(`Removed ${key}`);
108
+ if (mcpEntryPresent && !removedMcpEntry) {
109
+ console.warn(`WARNING: ${key} still has an entry in ${MCP_CONFIG_PATH} (which may contain secrets you entered)` +
110
+ (mcpEntryModified ? " -- it appears to have been modified since install" : "") +
111
+ ` -- remove it manually.`);
112
+ }
113
+ console.log(removedMcpEntry ? `Removed ${key} (including its ${MCP_CONFIG_PATH} entry)` : `Removed ${key}`);
71
114
  }
@@ -2,13 +2,14 @@ import { apiJson } from "../http.js";
2
2
  import { flagValue, parseSearchQuery } from "../flags.js";
3
3
  import { confirm } from "../confirm.js";
4
4
  import { UsageError } from "../usage-error.js";
5
- const CREATE_USAGE = "Usage: ahood snap create <content> (or pipe content on stdin)";
5
+ const CREATE_USAGE = "Usage: ahood snap create <content> [--tags tag1,tag2] (or pipe content on stdin)";
6
6
  const LIST_USAGE = "Usage: ahood snap list [--json] [--limit <n>]";
7
7
  const SEARCH_USAGE = "Usage: ahood snap search <query> [--json] [--limit <n>]";
8
8
  const SHOW_USAGE = "Usage: ahood snap show <id> [--json]";
9
9
  const REMOVE_USAGE = "Usage: ahood snap remove <id> [--yes]";
10
10
  const SHARE_USAGE = "Usage: ahood snap share <id> [--json]";
11
11
  const UNSHARE_USAGE = "Usage: ahood snap unshare <id> [--yes]";
12
+ const TAGS_USAGE = "Usage: ahood snap tags <id> [tag1,tag2,...] [--json] (omit or pass an empty value to clear all tags)";
12
13
  // There's no existing "read stdin to completion" helper in this codebase to
13
14
  // reuse -- confirm.ts and secret-prompt.ts both only ever read a single
14
15
  // line via readline, never an arbitrary-length paste/pipe. Standard Node
@@ -50,16 +51,24 @@ function printSnaps(jsonOutput, snaps, emptyMessage) {
50
51
  // breaks the "one line per snap" contract.
51
52
  const flat = snap.content_preview.replace(/\s+/g, " ").trim();
52
53
  const preview = flat.length > 60 ? `${flat.slice(0, 60)}...` : flat;
53
- console.log(`${snap.id} - ${preview} (${snap.created_at})${snap.shared ? " (shared)" : ""}`);
54
+ // ?? [] -- see listSnaps/searchSnaps' identical degrade-on-null guard
55
+ // below; a snap predating the tags column, or a degraded response,
56
+ // should just show no tags rather than crashing on undefined.length.
57
+ const tags = snap.tags ?? [];
58
+ const tagsSuffix = tags.length > 0 ? ` [${tags.join(", ")}]` : "";
59
+ console.log(`${snap.id} - ${preview} (${snap.created_at})${snap.shared ? " (shared)" : ""}${tagsSuffix}`);
54
60
  }
55
61
  }
56
62
  export async function createSnap(args) {
57
63
  const jsonOutput = args.includes("--json");
64
+ const tagsArg = flagValue(args, "--tags");
58
65
  // Joined, not just args[0] -- an unquoted multi-word note (e.g. `ahood
59
66
  // snap create Debugged the flaky CI step`) arrives as multiple positional
60
67
  // 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");
68
+ // error. Mirrors searchSnaps' own query-joining below. --tags and its
69
+ // value (both "--tags x" and "--tags=x" forms) are stripped the same way
70
+ // --json is, so they never leak into the joined content.
71
+ const positionals = args.filter((a, i) => a !== "--json" && a !== "--tags" && !a.startsWith("--tags=") && args[i - 1] !== "--tags");
63
72
  const positional = positionals.length > 0 ? positionals.join(" ") : undefined;
64
73
  let content;
65
74
  if (positional !== undefined) {
@@ -79,10 +88,21 @@ export async function createSnap(args) {
79
88
  }
80
89
  if (!content.trim())
81
90
  throw new UsageError(CREATE_USAGE);
91
+ // Same comma-split convention as `ahood skill publish --tags`/`edit
92
+ // --tags`. Omitted entirely when --tags wasn't passed at all, so the
93
+ // request body matches the "omitting tags is equivalent to []" contract
94
+ // exactly instead of always sending an (empty) tags array.
95
+ const body = { content };
96
+ if (tagsArg !== undefined) {
97
+ body.tags = tagsArg
98
+ .split(",")
99
+ .map((t) => t.trim())
100
+ .filter(Boolean);
101
+ }
82
102
  const created = await apiJson("/api/v1/snaps", {
83
103
  method: "POST",
84
104
  headers: { "Content-Type": "application/json" },
85
- body: JSON.stringify({ content }),
105
+ body: JSON.stringify(body),
86
106
  });
87
107
  if (jsonOutput) {
88
108
  console.log(JSON.stringify(created));
@@ -189,3 +209,48 @@ export async function unshareSnap(args) {
189
209
  await apiJson(`/api/v1/snaps/${encodeURIComponent(id)}/share`, { method: "DELETE" });
190
210
  console.log(`Unshared snap ${id}.`);
191
211
  }
212
+ export async function tagsSnap(args) {
213
+ const jsonOutput = args.includes("--json");
214
+ const positionals = args.filter((a) => a !== "--json");
215
+ // Reject stray "--" flags rather than folding them into the tag list
216
+ // (parseSearchQuery in flags.ts does the same). Without this, the natural
217
+ // mistake `snap tags <id> --tags a,b` -- natural because `snap create`
218
+ // really does spell it `--tags a,b` -- silently PATCHed the tag set to
219
+ // ["--tags a", "b"], and `snap tags --yes <id>` used "--yes" as the id.
220
+ const unknownFlag = positionals.find((a) => a.startsWith("--"));
221
+ if (unknownFlag)
222
+ throw new UsageError(`Unknown flag: ${unknownFlag}\n${TAGS_USAGE}`);
223
+ const id = positionals[0];
224
+ if (!id)
225
+ throw new UsageError(TAGS_USAGE);
226
+ // PATCH replaces the full tag set (not a merge) -- omitting the tags
227
+ // argument, or passing an empty string, both clear every tag, matching
228
+ // the "pass [] to clear" contract of the endpoint itself. Joined, not
229
+ // just positionals[1] -- an unquoted "tag1, tag2" arrives as multiple
230
+ // positional tokens, and taking only the first one silently dropped the
231
+ // rest with no error, the same bug createSnap's own content-joining
232
+ // above already fixed.
233
+ const tagsArg = positionals.slice(1).join(" ");
234
+ const tags = tagsArg
235
+ ? tagsArg
236
+ .split(",")
237
+ .map((t) => t.trim())
238
+ .filter(Boolean)
239
+ : [];
240
+ // No confirm() gate, unlike remove/unshare -- this only replaces metadata
241
+ // (tags), never the snap's content or its shareability, and re-running
242
+ // `tags` with the old set restores it exactly. Per ahood-cli#114's spec.
243
+ const updated = await apiJson(`/api/v1/snaps/${encodeURIComponent(id)}`, {
244
+ method: "PATCH",
245
+ headers: { "Content-Type": "application/json" },
246
+ body: JSON.stringify({ tags }),
247
+ });
248
+ if (jsonOutput) {
249
+ console.log(JSON.stringify(updated));
250
+ return;
251
+ }
252
+ // ?? [] -- see printSnaps' identical degrade-on-null guard above
253
+ // (ahood-cli#106): a degraded response shouldn't crash on undefined.length.
254
+ const updatedTags = updated.tags ?? [];
255
+ console.log(updatedTags.length > 0 ? `Tags for ${updated.id}: ${updatedTags.join(", ")}` : `Cleared tags for ${updated.id}.`);
256
+ }
@@ -1,6 +1,6 @@
1
1
  import { LOCKFILE_PATH, parseOwnerSkill } from "../spec.js";
2
2
  import { readLockfile } from "../lockfile.js";
3
- import { add, fetchVersionMeta } from "./add.js";
3
+ import { add, fetchVersionMeta, updateMcpEntry } from "./add.js";
4
4
  import { UsageError } from "../usage-error.js";
5
5
  const USAGE = "Usage: ahood skill update [<owner>/<skill> ...] [--dry-run] [--json]";
6
6
  async function previewSkill(ownerSlashSkill, currentVersion) {
@@ -99,18 +99,27 @@ export async function update(args) {
99
99
  try {
100
100
  // add() always hits the .mcp.json collision check for an mcp-kind
101
101
  // entry that's already installed (that's the very definition of
102
- // "already installed" for that kind), so calling it unconditionally
103
- // here would make `ahood skill update` with no arguments permanently
104
- // report a failure and exit 1 for every user who has ever installed
105
- // an mcp artifact, even when nothing needs updating. fetchVersionMeta
106
- // already resolves `kind` as part of the normal "latest" lookup add()
107
- // itself does, so resolving it here first lets this skip cleanly
108
- // instead of updating and hitting that guaranteed error. This is a
109
- // skip-and-report-cleanly fix, not real mcp-update support.
102
+ // "already installed" for that kind), so it can never be called
103
+ // unconditionally here the way it is for skill/agent kinds below --
104
+ // fetchVersionMeta resolves `kind` as part of the normal "latest"
105
+ // lookup add() itself does, so resolving it here first lets an
106
+ // mcp-kind entry route to updateMcpEntry (ahood-cli#169's real
107
+ // mcp-update support) instead.
110
108
  const { owner, skill } = parseOwnerSkill(ownerSlashSkill, USAGE);
111
109
  const meta = await fetchVersionMeta(owner, skill, "latest");
112
110
  if (meta.kind === "mcp") {
113
- console.warn(`Skipping ${ownerSlashSkill}: mcp artifacts aren't updatable via this command yet.`);
111
+ const currentEntry = lockfile[ownerSlashSkill];
112
+ if (currentEntry && currentEntry.version === meta.version) {
113
+ // Matches skill/agent update's own "nothing to do" case for a
114
+ // pin that's already at latest -- not a warning (nothing's
115
+ // wrong), and, unlike skill/agent (which always blindly
116
+ // re-extracts even when nothing changed), skipped here
117
+ // specifically so an up-to-date mcp entry with a secret in its
118
+ // env never re-triggers a masked prompt for no reason.
119
+ console.log(`${ownerSlashSkill} is already up to date (v${meta.version}).`);
120
+ continue;
121
+ }
122
+ await updateMcpEntry(owner, skill, meta, currentEntry);
114
123
  continue;
115
124
  }
116
125
  await add([ownerSlashSkill]); // no @version -- resolves to latest again
package/dist/help.js CHANGED
@@ -100,7 +100,9 @@ export const SKILL_COMMANDS_HELP = [
100
100
  {
101
101
  usage: "ahood skill update [<owner>/<skill> ...] [--dry-run] [--json]",
102
102
  summary: "Move the lockfile pin(s) forward to the latest version, for one skill or all installed skills at once.",
103
- desc: "Move the lockfile pin(s) forward to the latest version. With no argument, updates every installed skill; one failure doesn't stop the rest.",
103
+ desc: "Move the lockfile pin(s) forward to the latest version. With no argument, updates every installed skill; one failure doesn't stop the rest. " +
104
+ "An mcp-kind entry is updated in place (re-resolving any secret environment variables) as long as its .mcp.json entry still matches what " +
105
+ "ahood last installed there -- a hand-edited or unverifiable entry is refused rather than silently overwritten.",
104
106
  flags: [
105
107
  "--dry-run Preview current vs. latest version (and the changelog for anything behind) without installing anything.",
106
108
  "--json With --dry-run, emit structured preview objects instead of a formatted table.",
@@ -119,7 +121,9 @@ export const SKILL_COMMANDS_HELP = [
119
121
  {
120
122
  usage: "ahood skill remove <owner>/<skill> [--yes]",
121
123
  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.",
124
+ desc: "Uninstall and unpin (local only). Prompts for confirmation unless --yes is passed. For an mcp-kind install, " +
125
+ "also deletes its .mcp.json entry as long as it still matches what ahood last installed there -- a hand-edited " +
126
+ "or unverifiable entry is left in place and warned about instead of being silently touched.",
123
127
  },
124
128
  {
125
129
  usage: "ahood skill edit <owner>/<skill> [--tagline] [--tags] [--license] [--visibility] [--homepage] [--repository]",
@@ -275,18 +279,25 @@ export const GROUP_COMMANDS_HELP = [
275
279
  // never public otherwise.
276
280
  export const SNAP_COMMANDS_HELP = [
277
281
  {
278
- usage: "ahood snap create <content> [--json]",
282
+ usage: "ahood snap create <content> [--tags tag1,tag2] [--json]",
279
283
  summary: "Capture a new private snap, from an argument or piped stdin.",
280
284
  desc: "Capture a new private snap. Pass the content as an argument, or omit it and pipe content on stdin " +
281
285
  "(e.g. `echo \"...\" | ahood snap create`, or a piped heredoc) -- useful for capturing a whole session's " +
282
286
  "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'],
287
+ flags: [
288
+ "--tags <comma,separated> Attach tags to the new snap.",
289
+ "--json Emit {id, created_at} instead of just the bare id.",
290
+ ],
291
+ examples: [
292
+ 'ahood snap create "Debugged the flaky CI step, root cause was a race in the cache key." --tags deploy,bugfix',
293
+ 'echo "..." | ahood snap create',
294
+ ],
285
295
  },
286
296
  {
287
297
  usage: "ahood snap list [--json] [--limit <n>]",
288
298
  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.",
299
+ desc: "List your own snaps: id, a truncated content preview, created_at, a (shared) marker if shared, and " +
300
+ "any tags in brackets.",
290
301
  flags: [
291
302
  "--json Emit the raw snap objects instead of formatted lines.",
292
303
  "--limit <n> Cap the number of results.",
@@ -329,6 +340,15 @@ export const SNAP_COMMANDS_HELP = [
329
340
  "immediately.",
330
341
  flags: ["--yes Skip the interactive confirmation, for scripts/CI."],
331
342
  },
343
+ {
344
+ usage: "ahood snap tags <id> [tag1,tag2,...] [--json]",
345
+ summary: "Replace a snap's tags (omit the tag list, or pass an empty one, to clear all tags).",
346
+ desc: "Replace the full set of tags on a snap -- this is not a merge, so any existing tags not listed are " +
347
+ "dropped. Omitting the tag list entirely, or passing an empty string, clears all tags. No confirmation " +
348
+ "prompt: unlike remove/unshare this only touches metadata, never the snap's content or shareability.",
349
+ flags: ["--json Emit {id, tags} instead of a plain-text summary."],
350
+ examples: ["ahood snap tags snap_123 deploy,bugfix", 'ahood snap tags snap_123 ""'],
351
+ },
332
352
  ];
333
353
  // Exported so other consumers of the command list (e.g. shell completion) can
334
354
  // surface aliases without needing their own copy of this map. Aliases apply
package/dist/index.js CHANGED
@@ -22,7 +22,7 @@ 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 { createSnap, listSnaps, searchSnaps, showSnap, removeSnap, shareSnap, unshareSnap, } from "./commands/snap.js";
25
+ import { createSnap, listSnaps, searchSnaps, showSnap, removeSnap, shareSnap, unshareSnap, tagsSnap, } from "./commands/snap.js";
26
26
  import { formatHelp, formatSkillHelp, formatGroupHelp, formatSnapHelp, formatCommandHelp, findCommandHelp } from "./help.js";
27
27
  import { ApiError } from "./http.js";
28
28
  import { exitCodeFor } from "./exit-code.js";
@@ -80,6 +80,7 @@ const SNAP_VERBS = {
80
80
  remove: removeSnap,
81
81
  share: shareSnap,
82
82
  unshare: unshareSnap,
83
+ tags: tagsSnap,
83
84
  };
84
85
  // Top level is now just account/auth-scoped commands (not entity-specific --
85
86
  // same reasoning `gh auth login` isn't `gh account login`) plus the `skill`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahood/cli",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "CLI for ahood, a registry for installing and publishing Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/AlexKay28/ahood-cli",
@@ -26,7 +26,7 @@
26
26
  "dist"
27
27
  ],
28
28
  "scripts": {
29
- "build": "rm -rf dist && tsc -p tsconfig.json",
29
+ "build": "rm -rf dist && tsc -p tsconfig.json && chmod +x dist/index.js",
30
30
  "prepublishOnly": "npm run build",
31
31
  "test": "vitest run",
32
32
  "sync-readme": "node scripts/sync-readme.mjs"