@ahood/cli 0.8.1 → 0.8.3

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.
@@ -330,9 +330,25 @@ function summarizeConflictingEntry(entry) {
330
330
  export function readMcpConfig() {
331
331
  if (!existsSync(MCP_CONFIG_PATH))
332
332
  return { mcpServers: {} };
333
+ // Read and parse are separate try blocks so an I/O failure isn't reported
334
+ // as a syntax error: EACCES/EISDIR/EMFILE used to surface as "is not valid
335
+ // JSON" about a file that often parses fine, sending the user hunting for a
336
+ // syntax error when the fix is chmod or removing a stray directory
337
+ // (ahood-cli#117). No "-- fix or remove it" advice on this branch: the
338
+ // remedy depends on the errno, Node's own message already names the code
339
+ // and the path, and remove.ts deliberately quotes only the text before
340
+ // " -- " (ahood-cli#115).
341
+ let raw;
342
+ try {
343
+ raw = readFileSync(MCP_CONFIG_PATH, "utf-8");
344
+ }
345
+ catch (error) {
346
+ const reason = error instanceof Error ? error.message : String(error);
347
+ throw new Error(`${MCP_CONFIG_PATH} exists but could not be read: ${reason}`);
348
+ }
333
349
  let parsed;
334
350
  try {
335
- parsed = JSON.parse(readFileSync(MCP_CONFIG_PATH, "utf-8"));
351
+ parsed = JSON.parse(raw);
336
352
  }
337
353
  catch {
338
354
  throw new Error(`${MCP_CONFIG_PATH} exists but is not valid JSON -- fix or remove it before running this command.`);
@@ -370,7 +386,23 @@ function assertNoCollision(fileContents, owner, skill) {
370
386
  // forward. Exported so update.ts's real mcp-update path (ahood-cli#169)
371
387
  // re-resolves secrets the same way a fresh install does, instead of
372
388
  // duplicating this parsing/prompting logic.
373
- export async function resolveMcpServerConfig(buffer) {
389
+ // `existingEnv` carries forward the secret values already sitting in the
390
+ // entry being updated, so a routine `ahood skill update` doesn't re-prompt
391
+ // for a credential the user has already supplied. Only ever passed by
392
+ // updateMcpEntry, and only from an entry whose fingerprint it has just
393
+ // verified -- i.e. proven to be ahood's own unmodified entry, not something
394
+ // hand-edited or written by another tool.
395
+ //
396
+ // `allowNonTtyPrompt` (default true, preserving `echo secret | ahood skill
397
+ // add ...` for a fresh install the user explicitly asked for) is false on
398
+ // the update path: `ahood skill update` with no arguments walks EVERY locked
399
+ // entry, so a prompt there is never something the caller aimed at. Left
400
+ // interactive, an unattended update would hang on an idle pipe with no
401
+ // timeout -- against this CLI's "must never hang" rule -- or, worse, silently
402
+ // bank the first line of unrelated piped input (`echo y | ahood skill
403
+ // update`) as the credential and fingerprint over it.
404
+ export async function resolveMcpServerConfig(buffer, options = {}) {
405
+ const { existingEnv, allowNonTtyPrompt = true } = options;
374
406
  const content = await extractSingleFileContent(buffer, "server.json");
375
407
  const manifest = JSON.parse(content.toString("utf-8"));
376
408
  // Validate the manifest's shape (unsupported registry_type/runtime_hint,
@@ -392,9 +424,22 @@ export async function resolveMcpServerConfig(buffer) {
392
424
  // (per the design spec), which uses `if (process.env.AHOOD_TOKEN)`
393
425
  // specifically so an empty-string env var falls through to the real
394
426
  // source instead of silently installing an empty secret.
395
- secretEnv[variable.name] = fromEnv
396
- ? fromEnv
397
- : await promptSecret(`${sanitizeForTerminal(variable.name)} (${sanitizeForTerminal(variable.description)}): `);
427
+ if (fromEnv) {
428
+ secretEnv[variable.name] = fromEnv;
429
+ continue;
430
+ }
431
+ // Ordered after the env var deliberately: exporting the variable stays
432
+ // the way to rotate a credential during an update, rather than being
433
+ // shadowed by the stale value already on disk.
434
+ const carriedForward = existingEnv?.[variable.name];
435
+ if (carriedForward) {
436
+ secretEnv[variable.name] = carriedForward;
437
+ continue;
438
+ }
439
+ if (!allowNonTtyPrompt && !process.stdin.isTTY) {
440
+ 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.`);
441
+ }
442
+ secretEnv[variable.name] = await promptSecret(`${sanitizeForTerminal(variable.name)} (${sanitizeForTerminal(variable.description)}): `);
398
443
  }
399
444
  const serverConfig = buildMcpServerConfig(manifest, secretEnv);
400
445
  return { manifest, serverConfig };
@@ -406,13 +451,69 @@ export async function resolveMcpServerConfig(buffer) {
406
451
  // hand-edited it" -- without this, a real (not just warned-about) removal
407
452
  // or update risks silently discarding an intentional local edit, the same
408
453
  // 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.
454
+ // install. Hashes a canonical (recursively key-sorted) serialization rather
455
+ // than raw JSON.stringify output, because .mcp.json is shared with other MCP
456
+ // clients by design (see CLAUDE.md) and so gets rewritten by things that are
457
+ // not ahood -- `jq -S`, a format-on-save plugin, another client's serializer,
458
+ // a user retyping the block -- any of which can reorder keys without changing
459
+ // what the entry means. Fingerprinting insertion order turned every such
460
+ // reformat into a permanent refusal from both `update` and `remove`,
461
+ // recoverable only by hand-deleting the entry and re-entering its secret
462
+ // (ahood-cli#116). Arrays keep their order: `args` is order-significant.
413
463
  export function hashMcpServerConfig(config) {
464
+ return createHash("sha256").update(JSON.stringify(canonicalizeForHash(config))).digest("hex");
465
+ }
466
+ function canonicalizeForHash(value) {
467
+ if (Array.isArray(value))
468
+ return value.map(canonicalizeForHash);
469
+ if (typeof value !== "object" || value === null)
470
+ return value;
471
+ const sorted = {};
472
+ for (const key of Object.keys(value).sort()) {
473
+ sorted[key] = canonicalizeForHash(value[key]);
474
+ }
475
+ return sorted;
476
+ }
477
+ // LEGACY (ahood-cli#116): the pre-canonicalization fingerprint format. Every
478
+ // mcp entry pinned by an older CLI carries one of these, so changing the
479
+ // algorithm without a fallback would invalidate all of them at once --
480
+ // inflicting the exact permanent-refusal failure #116 is about on every
481
+ // existing user, on upgrade. Only ever consulted as a second chance after the
482
+ // canonical hash misses; drop this function and matchesMcpConfigHash's
483
+ // fallback branch a few releases on.
484
+ function hashMcpServerConfigLegacy(config) {
414
485
  return createHash("sha256").update(JSON.stringify(config)).digest("hex");
415
486
  }
487
+ // The single "is this on-disk entry still what we recorded?" decision, shared
488
+ // by every comparison site (updateMcpEntry's pre-check and its re-check under
489
+ // lock, remove's read and its re-check under lock). Centralized deliberately:
490
+ // a legacy fallback open-coded at four call sites is a fallback that ends up
491
+ // at three of them. A legacy match needs no separate signal to callers --
492
+ // update rewrites the pin with a canonical hash as part of its normal
493
+ // lockfile write, so such an entry self-heals silently on the next version
494
+ // bump, and remove is deleting the entry anyway.
495
+ export function matchesMcpConfigHash(entry, recordedHash) {
496
+ return hashMcpServerConfig(entry) === recordedHash || hashMcpServerConfigLegacy(entry) === recordedHash;
497
+ }
498
+ // Pulls the `env` map out of an .mcp.json entry read off disk, so an update
499
+ // can carry its secrets forward (see resolveMcpServerConfig's `existingEnv`).
500
+ // Written defensively -- the entry is `unknown` here, and only a string value
501
+ // is usable as a credential -- even though the caller has already fingerprint-
502
+ // verified it, so that a shape this function can't read degrades into a
503
+ // prompt rather than a crash.
504
+ function readMcpEntryEnv(entry) {
505
+ if (typeof entry !== "object" || entry === null)
506
+ return undefined;
507
+ const env = entry.env;
508
+ if (typeof env !== "object" || env === null)
509
+ return undefined;
510
+ const result = {};
511
+ for (const [name, value] of Object.entries(env)) {
512
+ if (typeof value === "string")
513
+ result[name] = value;
514
+ }
515
+ return result;
516
+ }
416
517
  async function installMcpEntry(owner, skill, meta, buffer) {
417
518
  // Check for an existing .mcp.json collision BEFORE resolving secrets --
418
519
  // matches resolveMcpServerConfig's own "don't cost the user a prompt for
@@ -498,12 +599,18 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
498
599
  if (recordedHash === undefined) {
499
600
  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
601
  }
501
- if (hashMcpServerConfig(existingOnDisk) !== recordedHash) {
602
+ if (!matchesMcpConfigHash(existingOnDisk, recordedHash)) {
502
603
  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
604
  }
504
605
  }
505
606
  const buffer = await downloadVerifiedArchive(owner, skill, meta);
506
- const { serverConfig } = await resolveMcpServerConfig(buffer);
607
+ // Reuse the secrets already in the entry rather than re-prompting for
608
+ // them. Safe to trust specifically because the fingerprint check above has
609
+ // just proven this is ahood's own unmodified entry; in the self-heal case
610
+ // (no entry on disk at all) there's nothing to carry forward and a genuine
611
+ // prompt is correct.
612
+ const existingEnv = readMcpEntryEnv(existingOnDisk);
613
+ const { serverConfig } = await resolveMcpServerConfig(buffer, { existingEnv, allowNonTtyPrompt: false });
507
614
  // Read-modify-write under an advisory lock, mirroring installMcpEntry's
508
615
  // own pattern. Re-checks the fingerprint here too (not just above) since
509
616
  // arbitrary time may have passed since the pre-check -- the download, and
@@ -514,7 +621,7 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
514
621
  const fresh = readMcpConfig();
515
622
  const freshServers = fresh.mcpServers;
516
623
  const freshExisting = Object.prototype.hasOwnProperty.call(freshServers, skill) ? freshServers[skill] : undefined;
517
- if (freshExisting !== undefined && (recordedHash === undefined || hashMcpServerConfig(freshExisting) !== recordedHash)) {
624
+ if (freshExisting !== undefined && (recordedHash === undefined || !matchesMcpConfigHash(freshExisting, recordedHash))) {
518
625
  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
626
  }
520
627
  previousOnDisk = freshExisting;
@@ -529,25 +636,43 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
529
636
  });
530
637
  }
531
638
  catch (error) {
532
- if (!(error instanceof LockfileChecksumConflictError))
533
- throw error;
534
639
  // Roll back the .mcp.json overwrite above under the same lock pattern,
535
640
  // restoring the entry to what it was before this update touched it --
536
641
  // not deleting it, unlike installMcpEntry's rollback: unless this was
537
642
  // the self-heal-a-missing-entry case (previousOnDisk undefined), there
538
643
  // 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));
644
+ //
645
+ // Runs for EVERY lockfile failure, not just a checksum conflict. A
646
+ // rethrow-before-rollback here left .mcp.json holding the new config
647
+ // while the lockfile kept the old version and old mcp_config_hash, after
648
+ // which the fingerprint never matches again and both `update` and
649
+ // `remove` permanently refuse -- the exact dead end the comments above
650
+ // exist to prevent, reachable from nothing worse than withLock's 5s
651
+ // timeout against a concurrent ahood, or an EACCES/ENOSPC on the write.
652
+ try {
653
+ withLock(MCP_CONFIG_PATH, () => {
654
+ const fileContents = readMcpConfig();
655
+ const mcpServers = fileContents.mcpServers;
656
+ if (previousOnDisk === undefined) {
657
+ delete mcpServers[skill];
658
+ }
659
+ else {
660
+ mcpServers[skill] = previousOnDisk;
661
+ }
662
+ writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents);
663
+ });
664
+ }
665
+ catch {
666
+ // The rollback can fail for the same reason the write did (lock still
667
+ // contended, disk still full). Warn rather than throw: the original
668
+ // error below is the more actionable one, and masking it with this
669
+ // one would hide why the update failed in the first place.
670
+ 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.`);
671
+ }
672
+ if (error instanceof LockfileChecksumConflictError) {
673
+ throw new Error(checksumConflictMessage(key, meta, error.existing));
674
+ }
675
+ throw error;
551
676
  }
552
677
  console.log(`Updated ${key} to ${meta.version}`);
553
678
  if (serverConfig.env && Object.keys(serverConfig.env).length > 0) {
@@ -2,7 +2,7 @@ import { existsSync, rmSync, unlinkSync } 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";
5
- import { readMcpConfig, hashMcpServerConfig } from "./add.js";
5
+ import { readMcpConfig, matchesMcpConfigHash } from "./add.js";
6
6
  import { UsageError } from "../usage-error.js";
7
7
  const USAGE = "Usage: ahood skill remove <owner>/<skill> [--yes]";
8
8
  export async function remove(args) {
@@ -58,6 +58,7 @@ export async function remove(args) {
58
58
  let removedMcpEntry = false;
59
59
  let mcpEntryModified = false;
60
60
  let mcpEntryPresent = false;
61
+ let mcpConfigUnreadable;
61
62
  let recordedHash;
62
63
  try {
63
64
  const fileContents = readMcpConfig();
@@ -65,20 +66,28 @@ export async function remove(args) {
65
66
  mcpEntryPresent = Object.prototype.hasOwnProperty.call(mcpServers, skill);
66
67
  if (mcpEntryPresent) {
67
68
  recordedHash = lockEntry?.mcp_config_hash;
68
- const currentHash = hashMcpServerConfig(mcpServers[skill]);
69
69
  if (recordedHash !== undefined) {
70
- mcpEntryModified = recordedHash !== currentHash;
70
+ mcpEntryModified = !matchesMcpConfigHash(mcpServers[skill], recordedHash);
71
71
  }
72
72
  }
73
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.
74
+ catch (error) {
75
+ // A malformed .mcp.json still isn't this command's problem to fix or
76
+ // crash on -- add.ts's readMcpConfig is the strict validator for that
77
+ // path. But leaving every flag false here meant the unreadable case
78
+ // printed a bare "Removed" with the entry (and its secret) still live,
79
+ // 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
83
+ // failed and warn off that instead of off a shape we can't inspect.
84
+ mcpConfigUnreadable = error instanceof Error ? error.message : String(error);
80
85
  }
81
86
  if (mcpEntryPresent && recordedHash !== undefined && !mcpEntryModified) {
87
+ // Rebound to a const so the `!== undefined` narrowing above survives into
88
+ // the closure below -- `recordedHash` is a `let`, which TypeScript widens
89
+ // back to `string | undefined` inside a callback.
90
+ const verifiedHash = recordedHash;
82
91
  try {
83
92
  // Re-check under lock rather than trusting the read above -- another
84
93
  // process could have changed or removed the entry in between,
@@ -94,7 +103,7 @@ export async function remove(args) {
94
103
  const fresh = readMcpConfig();
95
104
  const freshServers = fresh.mcpServers;
96
105
  if (Object.prototype.hasOwnProperty.call(freshServers, skill) &&
97
- hashMcpServerConfig(freshServers[skill]) === recordedHash) {
106
+ matchesMcpConfigHash(freshServers[skill], verifiedHash)) {
98
107
  delete freshServers[skill];
99
108
  writeJsonFileAtomic(MCP_CONFIG_PATH, fresh);
100
109
  removedMcpEntry = true;
@@ -105,7 +114,24 @@ export async function remove(args) {
105
114
  // removedMcpEntry stays false -- falls through to the warning below.
106
115
  }
107
116
  }
108
- if (mcpEntryPresent && !removedMcpEntry) {
117
+ if (mcpConfigUnreadable !== undefined && lockEntry?.mcp_config_hash !== undefined) {
118
+ // Its own wording rather than the branch below's: "remove it manually"
119
+ // isn't actionable while the file doesn't parse, and we can't say the
120
+ // entry is definitely still there -- only that one was installed and
121
+ // we couldn't check (ahood-cli#115).
122
+ // Only readMcpConfig's diagnosis, not the "-- fix or remove it before
123
+ // running this command" advice two of its three messages carry: "remove
124
+ // it" means deleting .mcp.json, which is shared with other MCP clients
125
+ // (see CLAUDE.md), so repeating it here would offer to destroy other
126
+ // servers' config in the same breath as this warning's own, correctly
127
+ // scoped advice (ahood-cli#115).
128
+ const diagnosis = mcpConfigUnreadable.split(" -- ")[0];
129
+ const reason = diagnosis.endsWith(".") ? diagnosis : `${diagnosis}.`;
130
+ console.warn(`WARNING: ${key} may still have an entry in ${MCP_CONFIG_PATH} (which may contain secrets you entered), ` +
131
+ `but ahood could not check: ${reason}` +
132
+ ` Fix the file, then delete the "${skill}" entry from mcpServers by hand.`);
133
+ }
134
+ else if (mcpEntryPresent && !removedMcpEntry) {
109
135
  console.warn(`WARNING: ${key} still has an entry in ${MCP_CONFIG_PATH} (which may contain secrets you entered)` +
110
136
  (mcpEntryModified ? " -- it appears to have been modified since install" : "") +
111
137
  ` -- remove it manually.`);
@@ -212,6 +212,14 @@ export async function unshareSnap(args) {
212
212
  export async function tagsSnap(args) {
213
213
  const jsonOutput = args.includes("--json");
214
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}`);
215
223
  const id = positionals[0];
216
224
  if (!id)
217
225
  throw new UsageError(TAGS_USAGE);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahood/cli",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
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",