@ahood/cli 0.8.0 → 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
@@ -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,24 @@ 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
+ export async function resolveMcpServerConfig(buffer) {
365
374
  const content = await extractSingleFileContent(buffer, "server.json");
366
375
  const manifest = JSON.parse(content.toString("utf-8"));
367
376
  // 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.
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.
374
383
  buildMcpServerConfig(manifest, {});
375
- assertNoCollision(readMcpConfig(), owner, skill);
376
384
  const envVars = manifest.packages?.[0]?.environment_variables ?? [];
377
385
  const secretEnv = {};
378
386
  for (const variable of envVars) {
@@ -389,6 +397,29 @@ async function installMcpEntry(owner, skill, meta, buffer) {
389
397
  : await promptSecret(`${sanitizeForTerminal(variable.name)} (${sanitizeForTerminal(variable.description)}): `);
390
398
  }
391
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);
392
423
  // Read-modify-write under an advisory lock, mirroring lockfile.ts's own
393
424
  // writeLockfileEntry: .mcp.json sits right next to the lockfile and can
394
425
  // hold other servers' secrets, so it gets the same protection against a
@@ -406,7 +437,11 @@ async function installMcpEntry(owner, skill, meta, buffer) {
406
437
  });
407
438
  const mcpKey = `${owner}/${skill}`;
408
439
  try {
409
- writeLockfileEntryVerifyingChecksum(LOCKFILE_PATH, mcpKey, { version: meta.version, checksum_sha256: meta.checksum_sha256 });
440
+ writeLockfileEntryVerifyingChecksum(LOCKFILE_PATH, mcpKey, {
441
+ version: meta.version,
442
+ checksum_sha256: meta.checksum_sha256,
443
+ mcp_config_hash: hashMcpServerConfig(serverConfig),
444
+ });
410
445
  }
411
446
  catch (error) {
412
447
  if (!(error instanceof LockfileChecksumConflictError))
@@ -421,7 +456,101 @@ async function installMcpEntry(owner, skill, meta, buffer) {
421
456
  throw new Error(checksumConflictMessage(mcpKey, meta, error.existing));
422
457
  }
423
458
  console.log(`Installed ${owner}/${skill}@${meta.version} into ${MCP_CONFIG_PATH} as "${skill}"`);
424
- 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) {
425
554
  console.warn(`WARNING: ${MCP_CONFIG_PATH} now contains one or more secret values in plaintext -- do not commit it to version control.`);
426
555
  }
427
556
  }
@@ -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,40 @@ 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
+ const id = positionals[0];
216
+ if (!id)
217
+ throw new UsageError(TAGS_USAGE);
218
+ // PATCH replaces the full tag set (not a merge) -- omitting the tags
219
+ // argument, or passing an empty string, both clear every tag, matching
220
+ // the "pass [] to clear" contract of the endpoint itself. Joined, not
221
+ // just positionals[1] -- an unquoted "tag1, tag2" arrives as multiple
222
+ // positional tokens, and taking only the first one silently dropped the
223
+ // rest with no error, the same bug createSnap's own content-joining
224
+ // above already fixed.
225
+ const tagsArg = positionals.slice(1).join(" ");
226
+ const tags = tagsArg
227
+ ? tagsArg
228
+ .split(",")
229
+ .map((t) => t.trim())
230
+ .filter(Boolean)
231
+ : [];
232
+ // No confirm() gate, unlike remove/unshare -- this only replaces metadata
233
+ // (tags), never the snap's content or its shareability, and re-running
234
+ // `tags` with the old set restores it exactly. Per ahood-cli#114's spec.
235
+ const updated = await apiJson(`/api/v1/snaps/${encodeURIComponent(id)}`, {
236
+ method: "PATCH",
237
+ headers: { "Content-Type": "application/json" },
238
+ body: JSON.stringify({ tags }),
239
+ });
240
+ if (jsonOutput) {
241
+ console.log(JSON.stringify(updated));
242
+ return;
243
+ }
244
+ // ?? [] -- see printSnaps' identical degrade-on-null guard above
245
+ // (ahood-cli#106): a degraded response shouldn't crash on undefined.length.
246
+ const updatedTags = updated.tags ?? [];
247
+ console.log(updatedTags.length > 0 ? `Tags for ${updated.id}: ${updatedTags.join(", ")}` : `Cleared tags for ${updated.id}.`);
248
+ }
@@ -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.1",
4
4
  "description": "CLI for ahood, a registry for installing and publishing Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/AlexKay28/ahood-cli",
@@ -26,7 +26,7 @@
26
26
  "dist"
27
27
  ],
28
28
  "scripts": {
29
- "build": "rm -rf dist && tsc -p tsconfig.json",
29
+ "build": "rm -rf dist && tsc -p tsconfig.json && chmod +x dist/index.js",
30
30
  "prepublishOnly": "npm run build",
31
31
  "test": "vitest run",
32
32
  "sync-readme": "node scripts/sync-readme.mjs"