@ahood/cli 0.8.7 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -252,7 +252,7 @@ Sharing is additive -- it doesn't change `alice/pdf-tools`'s own public/private
252
252
  | `ahood snap remove <id> [--yes]` | Permanently delete a snap (prompts for confirmation unless --yes is passed). |
253
253
  | `ahood snap share <id> [--json]` | Mint (or return the existing) shareable link for a snap. |
254
254
  | `ahood snap unshare <id> [--yes]` | Revoke a snap's share link (the snap itself is untouched; prompts for confirmation unless --yes is passed). |
255
- | `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). |
255
+ | `ahood snap tags <id> [tag ...] [--clear] [--json]` | Print a snap's tags, or replace them with the tags given. |
256
256
  <!-- COMMANDS_TABLE_END -->
257
257
 
258
258
  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.
@@ -7,6 +7,7 @@ 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
8
  import { readLockfile, withLock, writeJsonFileAtomic, writeLockfileEntryVerifyingChecksum, LockfileChecksumConflictError, } from "../lockfile.js";
9
9
  import { promptSecret } from "../secret-prompt.js";
10
+ import { sanitizeForTerminal } from "../terminal-safe.js";
10
11
  import { UsageError } from "../usage-error.js";
11
12
  const USAGE = "Usage: ahood skill add <owner>/<skill>[@version]";
12
13
  const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024; // 50 MB compressed
@@ -237,25 +238,6 @@ export async function extractSingleFileContent(buffer, entryName) {
237
238
  throw new Error(`${entryName} not found in the downloaded archive`);
238
239
  return found;
239
240
  }
240
- // Every manifest field used below -- and every tar entry name extractTarGz
241
- // quotes back in an error above -- reaches the terminal (an error message, or
242
- // -- worse -- the text printed immediately before a masked secret prompt)
243
- // but comes verbatim from a third-party-published archive: server-side
244
- // validation only checks these are strings, not that they're free of
245
- // control/escape characters. A malicious description could otherwise smuggle
246
- // a terminal control sequence (cursor movement, line-clear) that repaints
247
- // what the user sees at the exact moment they're about to type a credential.
248
- // Strips C0/C1 control characters (including ESC, \x1b) and caps length so a
249
- // single field can't also flood the terminal.
250
- //
251
- // Takes `unknown` rather than `string` because every caller's "string" is a
252
- // declared type over an unchecked `as ServerManifest` cast of downloaded JSON,
253
- // not a guarantee: calling .replace() directly on a `registry_type: 123` would
254
- // turn a diagnosable message into the raw TypeError ahood-cli#121 removed from
255
- // this same path (ahood-cli#122).
256
- function sanitizeForTerminal(text) {
257
- return String(text).replace(/[\x00-\x1f\x7f-\x9f]/g, " ").slice(0, 200);
258
- }
259
241
  // Only npm+npx is supported in v1 (docs/superpowers/specs/2026-09-03-mcp-server-artifacts-design.md) --
260
242
  // server-side publish validation (lib/publish/parse-server-manifest.ts) already
261
243
  // rejects anything else, so this check is defense-in-depth against a
@@ -480,10 +462,10 @@ export async function resolveMcpServerConfig(buffer, options = {}) {
480
462
  throw new Error(`${sanitizeForTerminal(variable.name)} is required by ${sanitizeForTerminal(manifest.name)} but is not set -- export ${sanitizeForTerminal(variable.name)} and re-run.`);
481
463
  }
482
464
  }
483
- // Required-ness needs no separate check on the secret path: every branch
484
- // below either resolves a value or throws, so a required secret with
485
- // nothing to fall back on already fails loudly (the non-TTY guard) or is
486
- // supplied at the prompt (ahood-cli#120).
465
+ // Required-ness is consulted on the secret path only in the one branch
466
+ // where a secret can still end up unresolved: the unattended update guard
467
+ // below. Every other branch either resolves a value or prompts for one, so
468
+ // an `is_required` check there would change nothing (ahood-cli#120).
487
469
  const secretNames = [];
488
470
  for (const variable of envVars) {
489
471
  if (!variable.is_secret)
@@ -511,6 +493,21 @@ export async function resolveMcpServerConfig(buffer, options = {}) {
511
493
  continue;
512
494
  }
513
495
  if (!allowNonTtyPrompt && !process.stdin.isTTY) {
496
+ // Optional means optional here too, exactly as it does on the
497
+ // non-secret pass above (ahood-cli#126). This guard used to fire for
498
+ // ANY unresolved secret, so a single artifact declaring an optional
499
+ // token that nothing had exported aborted the whole `ahood skill
500
+ // update` walk -- and said the variable "is required" while the
501
+ // manifest that declared it said otherwise. Announced rather than
502
+ // silent because skipping and carrying forward leave different entries
503
+ // in .mcp.json and nothing else distinguishes them; the notice is
504
+ // confined to this branch, so an interactive `add` (which still prompts
505
+ // for optional secrets) and a routine update whose credential carries
506
+ // forward both stay quiet.
507
+ if (!variable.is_required) {
508
+ console.warn(`${sanitizeForTerminal(variable.name)} is optional and is not set -- updating ${sanitizeForTerminal(manifest.name)} without it.`);
509
+ continue;
510
+ }
514
511
  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.`);
515
512
  }
516
513
  env[variable.name] = await promptSecret(`${sanitizeForTerminal(variable.name)} (${sanitizeForTerminal(variable.description)}): `);
@@ -602,6 +599,22 @@ function readMcpEntryEnv(entry) {
602
599
  }
603
600
  return result;
604
601
  }
602
+ // Every install path below undoes its own on-disk write when the lockfile
603
+ // write that would have pinned it fails. This runs that undo so that a
604
+ // failure IN the undo can never replace the original error: a rollback fails
605
+ // for the same reasons the write did (the lock still contended, the disk
606
+ // still full), and letting that escape would hand the user an EACCES or a
607
+ // lock timeout in place of the checksum-conflict message that actually
608
+ // explains what happened (ahood-cli#132). The warning is the consolation
609
+ // prize -- it has to name what was left behind, since nothing else will.
610
+ function rollbackOrWarn(rollback, warning) {
611
+ try {
612
+ rollback();
613
+ }
614
+ catch {
615
+ console.warn(warning);
616
+ }
617
+ }
605
618
  async function installMcpEntry(owner, skill, meta, buffer) {
606
619
  // Check for an existing .mcp.json collision BEFORE resolving secrets --
607
620
  // matches resolveMcpServerConfig's own "don't cost the user a prompt for
@@ -633,16 +646,43 @@ async function installMcpEntry(owner, skill, meta, buffer) {
633
646
  });
634
647
  }
635
648
  catch (error) {
636
- if (!(error instanceof LockfileChecksumConflictError))
637
- throw error;
638
649
  // Roll back the .mcp.json merge above under the same lock pattern --
639
650
  // re-read+delete rather than assuming nothing else changed it since.
640
- withLock(MCP_CONFIG_PATH, () => {
641
- const fileContents = readMcpConfig();
642
- delete fileContents.mcpServers[skill];
643
- writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents);
644
- });
645
- throw new Error(checksumConflictMessage(mcpKey, meta, error.existing));
651
+ //
652
+ // Runs for EVERY lockfile failure, not just a checksum conflict
653
+ // (ahood-cli#132) -- the same widening updateMcpEntry below already got,
654
+ // for the same reason. A rethrow-before-rollback here left the entry
655
+ // this function just merged in -- including, on the npm+npx path, the
656
+ // plaintext secret the user had just typed at the prompt -- sitting in
657
+ // .mcp.json with no lockfile pin at all. `remove` then bails at its
658
+ // `!dirExisted && !agentExisted && !hadLockfileEntry` check ("was not
659
+ // installed -- nothing to remove") before it ever reaches its .mcp.json
660
+ // cleanup, and a retried `add` refuses forever at assertNoCollision:
661
+ // the credential is removable only by hand-editing .mcp.json, and
662
+ // nothing tells the user it is there. Reachable from nothing worse than
663
+ // withLock's 5s timeout against a concurrent ahood, or an EACCES/ENOSPC
664
+ // on the write.
665
+ //
666
+ // Deletes, where updateMcpEntry's rollback restores: assertNoCollision
667
+ // has already established there was no entry under this key before this
668
+ // install wrote one, so there is nothing to put back. Deletes it
669
+ // unconditionally rather than re-fingerprinting it first the way
670
+ // remove.ts does -- remove is deciding about an entry of unknown age
671
+ // that may hold an intentional local edit, while this one was written
672
+ // by this same process moments ago and cannot be anyone else's work.
673
+ // Re-verifying here would only reintroduce the stranded-secret dead end
674
+ // above for whatever raced us.
675
+ rollbackOrWarn(() => {
676
+ withLock(MCP_CONFIG_PATH, () => {
677
+ const fileContents = readMcpConfig();
678
+ delete fileContents.mcpServers[skill];
679
+ writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents);
680
+ });
681
+ }, `WARNING: could not roll back ${MCP_CONFIG_PATH} after ${mcpKey}'s install failed -- its mcpServers."${skill}" entry (which may contain a secret you entered) may still be present while ${LOCKFILE_PATH} has no pin for it. Delete that entry by hand before re-running \`ahood skill add ${mcpKey}\`.`);
682
+ if (error instanceof LockfileChecksumConflictError) {
683
+ throw new Error(checksumConflictMessage(mcpKey, meta, error.existing));
684
+ }
685
+ throw error;
646
686
  }
647
687
  console.log(`Installed ${owner}/${skill}@${meta.version} into ${MCP_CONFIG_PATH} as "${skill}"`);
648
688
  // Only the npm+npx package path ever carries secrets into `env` (headers
@@ -732,7 +772,12 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
732
772
  // `remove` permanently refuse -- the exact dead end the comments above
733
773
  // exist to prevent, reachable from nothing worse than withLock's 5s
734
774
  // timeout against a concurrent ahood, or an EACCES/ENOSPC on the write.
735
- try {
775
+ //
776
+ // The rollback itself can fail for the same reason the write did (lock
777
+ // still contended, disk still full), so it goes through rollbackOrWarn:
778
+ // the original error below is the more actionable one, and masking it
779
+ // with this one would hide why the update failed in the first place.
780
+ rollbackOrWarn(() => {
736
781
  withLock(MCP_CONFIG_PATH, () => {
737
782
  const fileContents = readMcpConfig();
738
783
  const mcpServers = fileContents.mcpServers;
@@ -744,14 +789,7 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
744
789
  }
745
790
  writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents);
746
791
  });
747
- }
748
- catch {
749
- // The rollback can fail for the same reason the write did (lock still
750
- // contended, disk still full). Warn rather than throw: the original
751
- // error below is the more actionable one, and masking it with this
752
- // one would hide why the update failed in the first place.
753
- 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.`);
754
- }
792
+ }, `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.`);
755
793
  if (error instanceof LockfileChecksumConflictError) {
756
794
  throw new Error(checksumConflictMessage(key, meta, error.existing));
757
795
  }
@@ -829,10 +867,18 @@ export async function add(args) {
829
867
  writeLockfileEntryVerifyingChecksum(LOCKFILE_PATH, key, { version: meta.version, checksum_sha256: meta.checksum_sha256 });
830
868
  }
831
869
  catch (error) {
832
- if (!(error instanceof LockfileChecksumConflictError))
833
- throw error;
834
- rmSync(destPath, { force: true });
835
- throw new Error(checksumConflictMessage(key, meta, error.existing));
870
+ // Widened past the checksum-conflict case with installMcpEntry's own
871
+ // rollback (ahood-cli#132): an agent file left behind by a lock
872
+ // timeout is less dangerous than a stranded mcp secret -- `remove`
873
+ // finds it via agentExisted even with no pin -- but "a failed install
874
+ // installs nothing" is the invariant worth having at all three sites,
875
+ // and the previous version's content is gone either way, overwritten
876
+ // by the writeFileSync above before the pin was ever attempted.
877
+ rollbackOrWarn(() => rmSync(destPath, { force: true }), `WARNING: could not remove ${destPath} after ${key}'s install failed -- it may be left behind with no ${LOCKFILE_PATH} pin. Run \`ahood skill remove ${key}\` to clean it up.`);
878
+ if (error instanceof LockfileChecksumConflictError) {
879
+ throw new Error(checksumConflictMessage(key, meta, error.existing));
880
+ }
881
+ throw error;
836
882
  }
837
883
  console.log(`Installed ${key}@${meta.version} to ${destPath}`);
838
884
  return;
@@ -852,10 +898,14 @@ export async function add(args) {
852
898
  });
853
899
  }
854
900
  catch (error) {
855
- if (!(error instanceof LockfileChecksumConflictError))
856
- throw error;
857
- rmSync(destDir, { recursive: true, force: true });
858
- throw new Error(checksumConflictMessage(key, meta, error.existing));
901
+ // Same widening as the agent path above (ahood-cli#132). extractFreshVersion
902
+ // has already cleared any previous version out of this directory, so the
903
+ // only thing this deletes is what this failed install just put there.
904
+ rollbackOrWarn(() => rmSync(destDir, { recursive: true, force: true }), `WARNING: could not remove ${destDir} after ${key}'s install failed -- it may be left behind with no ${LOCKFILE_PATH} pin. Run \`ahood skill remove ${key}\` to clean it up.`);
905
+ if (error instanceof LockfileChecksumConflictError) {
906
+ throw new Error(checksumConflictMessage(key, meta, error.existing));
907
+ }
908
+ throw error;
859
909
  }
860
910
  console.log(`Installed ${key}@${meta.version} to ${destDir}`);
861
911
  }
@@ -1,5 +1,6 @@
1
1
  import { apiJson } from "../http.js";
2
2
  import { parseOwnerSkill } from "../spec.js";
3
+ import { sanitizeDocumentForTerminal, sanitizeForTerminal } from "../terminal-safe.js";
3
4
  import { UsageError } from "../usage-error.js";
4
5
  const USAGE = "Usage: ahood skill read <owner>/<skill> [--json]";
5
6
  export async function readSkillMd(owner, skill) {
@@ -12,8 +13,16 @@ export async function readSkillMd(owner, skill) {
12
13
  // be silent about handing back a yanked version's content. Written to
13
14
  // stderr, not stdout -- safe for MCP callers too (only stdout is reserved
14
15
  // for the JSON-RPC stream).
16
+ //
17
+ // yanked_reason is publisher-controlled free text interpolated into a
18
+ // CLI-authored line, which is exactly sanitizeForTerminal's case (and
19
+ // exactly what add.ts does with manifest fields) -- not the document case
20
+ // below, so it keeps the flatten-newlines/200-char defaults rather than
21
+ // sanitizeDocumentForTerminal. Unconditional, not TTY-gated: nothing
22
+ // round-trips a warning, so there is no byte-exactness to preserve here
23
+ // (ahood-cli#133).
15
24
  if (detail.skill_versions.yanked_at) {
16
- console.warn(`WARNING: ${detail.owner}/${detail.slug}@${detail.skill_versions.version} has been yanked${detail.skill_versions.yanked_reason ? `: ${detail.skill_versions.yanked_reason}` : "."}`);
25
+ console.warn(`WARNING: ${detail.owner}/${detail.slug}@${detail.skill_versions.version} has been yanked${detail.skill_versions.yanked_reason ? `: ${sanitizeForTerminal(detail.skill_versions.yanked_reason)}` : "."}`);
17
26
  }
18
27
  const content = detail.skill_versions.skill_md_content;
19
28
  if (!content) {
@@ -52,5 +61,22 @@ export async function read(args) {
52
61
  // a real, reported round-trip mismatch between "ahood skill read" and the
53
62
  // source file, even though the registry itself stores/serves the content
54
63
  // byte-exact (ahood-cli#90).
64
+ //
65
+ // That byte-exactness is a promise made to a *pipe*, though, and #90 is the
66
+ // only reason the raw write exists. A human looking at a terminal gets no
67
+ // value from it and pays for it with every control sequence the publisher
68
+ // chose to embed, because skill_md_content is publisher-controlled free text
69
+ // and `skill read` is the command the CLI advertises for inspecting a skill
70
+ // *before* installing it -- the cautious path. An embedded OSC 52
71
+ // ("\x1b]52;c;<base64>\x07") silently overwrites the reader's system
72
+ // clipboard on xterm/kitty/wezterm/foot; cheaper variants retitle the window
73
+ // (OSC 0), clear the screen and forge "verified safe" output (ESC[2J ESC[H),
74
+ // or switch charsets (ESC(0) and corrupt every later line in the session. So
75
+ // strip on the TTY path only, leaving the redirected path the exact bytes
76
+ // #90 guaranteed (ahood-cli#133).
77
+ if (process.stdout.isTTY) {
78
+ process.stdout.write(sanitizeDocumentForTerminal(content));
79
+ return;
80
+ }
55
81
  process.stdout.write(content);
56
82
  }
@@ -1,4 +1,4 @@
1
- import { existsSync, rmSync, unlinkSync } from "node:fs";
1
+ import { existsSync, rmSync } from "node:fs";
2
2
  import { confirm } from "../confirm.js";
3
3
  import { readLockfile, removeLockfileEntry, withLock, writeJsonFileAtomic } from "../lockfile.js";
4
4
  import { LOCKFILE_PATH, parseOwnerSkill, skillDir, agentPath, MCP_CONFIG_PATH } from "../spec.js";
@@ -22,6 +22,14 @@ export async function remove(args) {
22
22
  const agentExisted = existsSync(agentFile);
23
23
  const lockEntry = readLockfile(LOCKFILE_PATH)[key];
24
24
  const hadLockfileEntry = lockEntry !== undefined;
25
+ // A pin whose install left nothing on disk is the defining shape of an
26
+ // mcp-kind install: its only footprint is this pin plus a live .mcp.json
27
+ // entry. Kept as evidence for the unreadable-.mcp.json warning at the
28
+ // bottom because, unlike mcp_config_hash, it exists for entries installed
29
+ // before that field did (lockfile.ts: "absent for any mcp entry installed
30
+ // before this field existed") and it is computed HERE, above the .mcp.json
31
+ // read -- so it survives the case where that read throws (ahood-cli#143).
32
+ const hadNoOnDiskFootprint = !dirExisted && !agentExisted && hadLockfileEntry;
25
33
  if (!dirExisted && !agentExisted && !hadLockfileEntry) {
26
34
  console.error(`${key} was not installed -- nothing to remove.`);
27
35
  process.exitCode = 1;
@@ -36,16 +44,32 @@ export async function remove(args) {
36
44
  console.log("Aborted.");
37
45
  return;
38
46
  }
47
+ // Every artifact the pin points at is deleted BEFORE the pin itself, which
48
+ // is cleared exactly once at the end of this function. The two interruption
49
+ // states are not symmetric, which is the whole reason for the ordering: a
50
+ // pin left behind after its artifact is gone is a dangling reference that a
51
+ // re-run of this same command finds (hadLockfileEntry) and clears, while an
52
+ // artifact left behind after its pin is gone is invisible to list/update
53
+ // forever -- and for an mcp-kind install that artifact is a live .mcp.json
54
+ // entry holding a resolved secret (ahood-cli#142). The rule is applied
55
+ // uniformly rather than per-kind: the directory and agent file are lower
56
+ // stakes (no secret, and a re-run's own existsSync rediscovers them), but
57
+ // ordering them by stakes would just make the invariant harder to keep.
58
+ //
59
+ // `force: true` on both, rather than trusting the existsSync taken at the
60
+ // top: an unbounded confirm() prompt sits between that check and here, so
61
+ // the path may legitimately be gone by now. Without it, unlinkSync threw a
62
+ // raw ENOENT that propagated out and skipped the pin clear below, leaving
63
+ // the pin behind for a file that no longer existed (ahood-cli#142).
39
64
  if (dirExisted)
40
65
  rmSync(dir, { recursive: true, force: true });
41
66
  if (agentExisted)
42
- unlinkSync(agentFile);
43
- removeLockfileEntry(LOCKFILE_PATH, key);
67
+ rmSync(agentFile, { force: true });
44
68
  // An mcp-kind install has no directory or agent file on disk -- its only
45
- // footprint here is the lockfile entry just cleared above and a live
46
- // entry in .mcp.json (which add.ts's installMcpEntry merged in, possibly
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
69
+ // footprint is the lockfile entry (cleared below, once this block has run)
70
+ // and a live entry in .mcp.json (which add.ts's installMcpEntry merged in,
71
+ // possibly holding a resolved secret in its `env`). Real removal (delete
72
+ // that one key) only happens when the on-disk entry's fingerprint matches
49
73
  // what was recorded at install/update time -- same posture as add.ts's
50
74
  // own assertNoCollision: never blind-write/delete something this process
51
75
  // didn't verify it still owns. Without SOME check here, "Removed" would
@@ -77,9 +101,9 @@ export async function remove(args) {
77
101
  // path. But leaving every flag false here meant the unreadable case
78
102
  // printed a bare "Removed" with the entry (and its secret) still live,
79
103
  // the very false-assurance bug the block above exists to prevent
80
- // (ahood-cli#115). The lockfile's mcp_config_hash, read at the top of
81
- // this function before the pin was cleared, is direct evidence that
82
- // ahood DID install an entry into this file, so record why the read
104
+ // (ahood-cli#115). Both the lockfile's mcp_config_hash and
105
+ // hadNoOnDiskFootprint, read at the top of this function, are evidence
106
+ // that ahood DID install an entry into this file, so record why the read
83
107
  // failed and warn off that instead of off a shape we can't inspect.
84
108
  mcpConfigUnreadable = error instanceof Error ? error.message : String(error);
85
109
  }
@@ -114,7 +138,26 @@ export async function remove(args) {
114
138
  // removedMcpEntry stays false -- falls through to the warning below.
115
139
  }
116
140
  }
117
- if (mcpConfigUnreadable !== undefined && lockEntry?.mcp_config_hash !== undefined) {
141
+ // Last, per the ordering note above: everything this pin could point at has
142
+ // now either been deleted or been diagnosed and warned about below, so the
143
+ // pin has no surviving work left to surface (ahood-cli#142).
144
+ removeLockfileEntry(LOCKFILE_PATH, key);
145
+ if (mcpConfigUnreadable !== undefined && (lockEntry?.mcp_config_hash !== undefined || hadNoOnDiskFootprint)) {
146
+ // Two independent pieces of evidence, because mcp_config_hash alone is
147
+ // not sufficient: it is absent for any mcp entry installed before that
148
+ // field existed, and for such a legacy pin the unreadable read also
149
+ // leaves mcpEntryPresent false -- so the `else if` below could not fire
150
+ // either and #115's fix silently did not apply, printing the bare
151
+ // "Removed" with the entry and its credential still live (ahood-cli#143).
152
+ // hadNoOnDiskFootprint closes that hole without needing the missing
153
+ // field. It cannot be narrowed further from the lockfile alone (LockEntry
154
+ // records no `kind`), so a skill whose directory was deleted by hand
155
+ // before this ran looks identical to a legacy mcp pin and warns too --
156
+ // deliberately, since the only thing that could tell them apart is the
157
+ // .mcp.json read that just failed, and a hedged sentence is the cheaper
158
+ // error of the two. A plain skill install still has its directory, so
159
+ // that far more common shape stays silent as ahood-cli#115 requires.
160
+ //
118
161
  // Its own wording rather than the branch below's: "remove it manually"
119
162
  // isn't actionable while the file doesn't parse, and we can't say the
120
163
  // entry is definitely still there -- only that one was installed and
@@ -1,15 +1,15 @@
1
1
  import { apiJson } from "../http.js";
2
- import { flagValue, parseSearchQuery } from "../flags.js";
2
+ import { flagValue, parseSearchQuery, unrecognizedArgs, unrecognizedIndices } 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> [--tags tag1,tag2] (or pipe content on stdin)";
5
+ const CREATE_USAGE = "Usage: ahood snap create <content> [--tags tag1,tag2] (or pipe content on stdin; put -- before content that starts with --)";
6
6
  const LIST_USAGE = "Usage: ahood snap list [--json] [--limit <n>] [--tags tag1,tag2]";
7
7
  const SEARCH_USAGE = "Usage: ahood snap search <query> [--json] [--limit <n>] [--tags tag1,tag2]";
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
+ const TAGS_USAGE = 'Usage: ahood snap tags <id> [tag ...] [--clear] [--json] (with no tag list, prints the snap\'s current tags; pass --clear or "" to remove them all)';
13
13
  // There's no existing "read stdin to completion" helper in this codebase to
14
14
  // reuse -- confirm.ts and secret-prompt.ts both only ever read a single
15
15
  // line via readline, never an arbitrary-length paste/pipe. Standard Node
@@ -60,15 +60,61 @@ function printSnaps(jsonOutput, snaps, emptyMessage) {
60
60
  }
61
61
  }
62
62
  export async function createSnap(args) {
63
- const jsonOutput = args.includes("--json");
64
- const tagsArg = flagValue(args, "--tags");
63
+ // POSIX end-of-options: everything after a bare "--" is content, verbatim,
64
+ // never a flag. Snap content is freeform, so a note that legitimately begins
65
+ // with "--" would otherwise be unexpressible once the unknown-flag check
66
+ // below lands -- `--tags=value` is exactly that escape hatch for the flag
67
+ // (flags.ts), and content had none (ahood-cli#134).
68
+ //
69
+ // Scoped to `create` rather than made group-wide on purpose: it's the only
70
+ // snap verb whose argument is freeform text. `show`/`remove`/`share`/
71
+ // `unshare` take an id, `tags` a tag list and `search` a query -- all
72
+ // constrained enough that a leading "--" is always a mistake there, so "--"
73
+ // would buy them nothing -- and `search`'s parsing lives in the shared
74
+ // parseSearchQuery, which `skill search` also uses.
75
+ const endOfOptions = args.indexOf("--");
76
+ const flagArgs = endOfOptions === -1 ? args : args.slice(0, endOfOptions);
77
+ const literalArgs = endOfOptions === -1 ? [] : args.slice(endOfOptions + 1);
78
+ const jsonOutput = flagArgs.includes("--json");
79
+ const tagsArg = flagValue(flagArgs, "--tags");
80
+ // Whatever this command doesn't consume. --tags and its value (both
81
+ // "--tags x" and "--tags=x" forms) are stripped the same way --json is, so
82
+ // they never leak into the joined content.
83
+ const leftover = unrecognizedIndices(flagArgs, ["--json"], ["--tags"]);
84
+ // Every leftover used to be kept and folded into the note body, because
85
+ // content is freeform and a blanket "--" rejection would break a note that
86
+ // starts with a dash. That made a typo silently rewrite what got stored, at
87
+ // exit 0: `snap create "my note" --tag deploy` (singular) posted content
88
+ // "my note --tag deploy" with no tags at all, and `--limit 5` likewise
89
+ // (ahood-cli#134). With "--" above providing the escape hatch, refuse them
90
+ // instead -- the same rejection `snap tags` already does below.
91
+ const unknownFlag = leftover.map((i) => flagArgs[i]).find((a) => a.startsWith("--"));
92
+ if (unknownFlag)
93
+ throw new UsageError(`Unknown flag: ${unknownFlag}\n${CREATE_USAGE}`);
94
+ // The other half of ahood-cli#134, and the part no "--" check catches:
95
+ // `snap create "note" --tags deploy bugfix` posted content "note bugfix",
96
+ // because only the token immediately after --tags is consumed and the second
97
+ // word fell through into the body. A bare token is indistinguishable from a
98
+ // note word on its own -- `--tags deploy Debugged the CI` is a legitimate
99
+ // flags-first invocation -- so the signal isn't the token, it's that content
100
+ // ends up on BOTH sides of a flag. Nobody writes a note with a flag wedged
101
+ // mid-sentence; erroring there refuses the typo without outlawing either
102
+ // ordering. Not silently retagging "bugfix" either: guessing wrong steals a
103
+ // word out of the note, which is the same corruption in the other direction.
104
+ const gap = leftover.findIndex((at, n) => n > 0 && at !== leftover[n - 1] + 1);
105
+ if (gap !== -1) {
106
+ const stray = flagArgs[leftover[gap]];
107
+ const consumed = flagArgs[leftover[gap - 1] + 1];
108
+ throw new UsageError(`Note content is split across ${consumed}: "${stray}" comes after it and would be folded into the note. ` +
109
+ `Quote the whole note, or move every flag after it` +
110
+ (consumed === "--tags" ? `; pass multiple tags as one comma-separated value (--tags a,b)` : "") +
111
+ `.\n${CREATE_USAGE}`);
112
+ }
65
113
  // Joined, not just args[0] -- an unquoted multi-word note (e.g. `ahood
66
114
  // snap create Debugged the flaky CI step`) arrives as multiple positional
67
115
  // tokens, and taking only the first one silently dropped the rest with no
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");
116
+ // error. Mirrors searchSnaps' own query-joining below.
117
+ const positionals = [...leftover.map((i) => flagArgs[i]), ...literalArgs];
72
118
  const positional = positionals.length > 0 ? positionals.join(" ") : undefined;
73
119
  let content;
74
120
  if (positional !== undefined) {
@@ -114,6 +160,22 @@ export async function listSnaps(args) {
114
160
  const jsonOutput = args.includes("--json");
115
161
  const limitStr = flagValue(args, "--limit");
116
162
  const tagsStr = flagValue(args, "--tags");
163
+ // Reject whatever this command doesn't accept instead of issuing an
164
+ // unfiltered request and presenting the result as the filtered set:
165
+ // `snap list --tag ci` (singular typo) used to send no tags param at all and
166
+ // print every snap, while the sibling `snap search --tag ci` refused the
167
+ // identical typo -- so a user filtering for "deploy" and seeing three snaps
168
+ // couldn't tell them from the three most recent (ahood-cli#135). That's the
169
+ // same silent-broadening this command's verbatim --tags pass-through below
170
+ // was chosen to avoid; it was defended at the small end and left open at the
171
+ // large one. `snap list` takes no positional argument either, and nothing
172
+ // upstream gives a stray token a meaning (index.ts's dispatchSnap consumes
173
+ // only the verb, and intercepts --help/-h before the handler runs), so an
174
+ // extra positional is a mistake too rather than something to ignore.
175
+ const extra = unrecognizedArgs(args, ["--json"], ["--limit", "--tags"])[0];
176
+ if (extra !== undefined) {
177
+ throw new UsageError(`${extra.startsWith("--") ? "Unknown flag" : "Unexpected argument"}: ${extra}\n${LIST_USAGE}`);
178
+ }
117
179
  validateLimit(limitStr, LIST_USAGE);
118
180
  const qs = new URLSearchParams();
119
181
  if (limitStr !== undefined)
@@ -226,9 +288,38 @@ export async function unshareSnap(args) {
226
288
  await apiJson(`/api/v1/snaps/${encodeURIComponent(id)}/share`, { method: "DELETE" });
227
289
  console.log(`Unshared snap ${id}.`);
228
290
  }
291
+ // Shared by both of tagsSnap's paths so reading a snap's tags and the echo
292
+ // after replacing them print the identical line -- the read is meant to be the
293
+ // way a user checks what a write did, which only works if the two agree.
294
+ //
295
+ // Each tag is printed quoted. Unquoted, `Tags for X: deploy bugfix` is
296
+ // character-for-character what ONE tag "deploy bugfix" and TWO tags "deploy"
297
+ // and "bugfix" both printed, so a tag list that had silently collapsed into a
298
+ // single multi-word tag looked exactly like the correct result and the mistake
299
+ // left no trace on screen (ahood-cli#137). JSON.stringify rather than manual
300
+ // quoting so a tag that itself contains a quote or a newline stays unambiguous.
301
+ function printTags(jsonOutput, id, tags, emptyMessage) {
302
+ // ?? [] -- see printSnaps' identical degrade-on-null guard above
303
+ // (ahood-cli#106): a degraded response shouldn't crash on undefined.length.
304
+ // Normalized before the --json branch too, so the emitted shape is the same
305
+ // {id, tags: []} whether the server said [] or null.
306
+ const list = tags ?? [];
307
+ if (jsonOutput) {
308
+ console.log(JSON.stringify({ id, tags: list }));
309
+ return;
310
+ }
311
+ console.log(list.length > 0 ? `Tags for ${id}: ${list.map((t) => JSON.stringify(t)).join(", ")}` : emptyMessage);
312
+ }
229
313
  export async function tagsSnap(args) {
230
314
  const jsonOutput = args.includes("--json");
231
- const positionals = args.filter((a) => a !== "--json");
315
+ const clear = args.includes("--clear");
316
+ // Folded onto flags.ts's shared stripping helper, which this command was the
317
+ // last caller still hand-rolling its own copy of (see unrecognizedArgs'
318
+ // comment, ahood-cli#135/#136). The `args.filter(a => a !== "--json")` it
319
+ // replaces knew about exactly one flag, so --clear below would have meant
320
+ // growing a third variant of the same rules -- the duplication that let
321
+ // `snap list` ship with no unknown-flag check at all.
322
+ const positionals = unrecognizedArgs(args, ["--json", "--clear"], []);
232
323
  // Reject stray "--" flags rather than folding them into the tag list
233
324
  // (parseSearchQuery in flags.ts does the same). Without this, the natural
234
325
  // mistake `snap tags <id> --tags a,b` -- natural because `snap create`
@@ -240,34 +331,48 @@ export async function tagsSnap(args) {
240
331
  const id = positionals[0];
241
332
  if (!id)
242
333
  throw new UsageError(TAGS_USAGE);
243
- // PATCH replaces the full tag set (not a merge) -- omitting the tags
244
- // argument, or passing an empty string, both clear every tag, matching
245
- // the "pass [] to clear" contract of the endpoint itself. Joined, not
246
- // just positionals[1] -- an unquoted "tag1, tag2" arrives as multiple
247
- // positional tokens, and taking only the first one silently dropped the
248
- // rest with no error, the same bug createSnap's own content-joining
249
- // above already fixed.
250
- const tagsArg = positionals.slice(1).join(" ");
251
- const tags = tagsArg
252
- ? tagsArg
253
- .split(",")
254
- .map((t) => t.trim())
255
- .filter(Boolean)
256
- : [];
257
- // No confirm() gate, unlike remove/unshare -- this only replaces metadata
258
- // (tags), never the snap's content or its shareability, and re-running
259
- // `tags` with the old set restores it exactly. Per ahood-cli#114's spec.
334
+ const tagArgs = positionals.slice(1);
335
+ if (clear && tagArgs.length > 0) {
336
+ throw new UsageError(`--clear takes no tag list, but got "${tagArgs[0]}". Pass --clear to remove every tag, or the tags to set -- not both.\n${TAGS_USAGE}`);
337
+ }
338
+ // The bare `snap tags <id>` form READS instead of clearing (ahood-cli#138).
339
+ // It used to PATCH {"tags":[]} unconfirmed, and it is the form a user reaches
340
+ // for to ASK what a snap's tags are -- `git tag`, `docker tag` and `hg tags`
341
+ // all read on their bare form, and nothing else in this CLI showed one snap's
342
+ // tags (`snap show` prints content only, `snap list` truncates across all
343
+ // snaps), so the answer was destroyed by the act of asking. Destroying a tag
344
+ // set now takes saying so: --clear, or the documented explicit "" form below.
345
+ // That's why there's still no confirm() gate here, unlike remove/unshare --
346
+ // the accident it would have guarded against can no longer be typed by
347
+ // accident, and #114's original reasoning (metadata only, and re-running
348
+ // `tags` with the old set restores it exactly) holds once the user can
349
+ // actually find out what the old set was.
350
+ if (!clear && tagArgs.length === 0) {
351
+ const snap = await apiJson(`/api/v1/snaps/${encodeURIComponent(id)}`);
352
+ printTags(jsonOutput, snap.id, snap.tags, `${snap.id} has no tags.`);
353
+ return;
354
+ }
355
+ // Each positional is parsed on its own, then concatenated -- NOT joined into
356
+ // one string first. The join existed to rescue an unquoted `tags <id>
357
+ // "deploy, bugfix"`, which the shell hands over as two argv entries and which
358
+ // taking only positionals[1] silently truncated (ahood-cli#114). But joining
359
+ // cannot tell that apart from a user typing space separators, so `tags <id>
360
+ // deploy bugfix` became the single tag "deploy bugfix" (ahood-cli#137).
361
+ // Splitting per token serves both: ["deploy,", "bugfix"] and ["deploy",
362
+ // "bugfix"] each yield ["deploy", "bugfix"], while a tag that genuinely
363
+ // contains a space stays expressible by quoting it as one argument -- which
364
+ // treating whitespace as a separator would have taken away.
365
+ const tags = tagArgs.flatMap((arg) => arg
366
+ .split(",")
367
+ .map((t) => t.trim())
368
+ .filter(Boolean));
369
+ // PATCH replaces the full tag set (not a merge) -- passing --clear, or an
370
+ // empty string, sends [] and clears every tag, matching the "pass [] to
371
+ // clear" contract of the endpoint itself.
260
372
  const updated = await apiJson(`/api/v1/snaps/${encodeURIComponent(id)}`, {
261
373
  method: "PATCH",
262
374
  headers: { "Content-Type": "application/json" },
263
375
  body: JSON.stringify({ tags }),
264
376
  });
265
- if (jsonOutput) {
266
- console.log(JSON.stringify(updated));
267
- return;
268
- }
269
- // ?? [] -- see printSnaps' identical degrade-on-null guard above
270
- // (ahood-cli#106): a degraded response shouldn't crash on undefined.length.
271
- const updatedTags = updated.tags ?? [];
272
- console.log(updatedTags.length > 0 ? `Tags for ${updated.id}: ${updatedTags.join(", ")}` : `Cleared tags for ${updated.id}.`);
377
+ printTags(jsonOutput, updated.id, updated.tags, `Cleared tags for ${updated.id}.`);
273
378
  }