@ahood/cli 0.8.2 → 0.8.4

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.`);
@@ -435,13 +451,50 @@ export async function resolveMcpServerConfig(buffer, options = {}) {
435
451
  // hand-edited it" -- without this, a real (not just warned-about) removal
436
452
  // or update risks silently discarding an intentional local edit, the same
437
453
  // class of risk assertNoCollision below exists to prevent on a *fresh*
438
- // install. JSON.stringify's key order matches insertion order for a plain
439
- // object, and JSON.parse's key order matches source-text order -- so this
440
- // is stable between the object this process just built and the same object
441
- // read back from disk later, as long as nothing reorders keys in between.
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.
442
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) {
443
485
  return createHash("sha256").update(JSON.stringify(config)).digest("hex");
444
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
+ }
445
498
  // Pulls the `env` map out of an .mcp.json entry read off disk, so an update
446
499
  // can carry its secrets forward (see resolveMcpServerConfig's `existingEnv`).
447
500
  // Written defensively -- the entry is `unknown` here, and only a string value
@@ -546,7 +599,7 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
546
599
  if (recordedHash === undefined) {
547
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.`);
548
601
  }
549
- if (hashMcpServerConfig(existingOnDisk) !== recordedHash) {
602
+ if (!matchesMcpConfigHash(existingOnDisk, recordedHash)) {
550
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.`);
551
604
  }
552
605
  }
@@ -568,7 +621,7 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
568
621
  const fresh = readMcpConfig();
569
622
  const freshServers = fresh.mcpServers;
570
623
  const freshExisting = Object.prototype.hasOwnProperty.call(freshServers, skill) ? freshServers[skill] : undefined;
571
- if (freshExisting !== undefined && (recordedHash === undefined || hashMcpServerConfig(freshExisting) !== recordedHash)) {
624
+ if (freshExisting !== undefined && (recordedHash === undefined || !matchesMcpConfigHash(freshExisting, recordedHash))) {
572
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.`);
573
626
  }
574
627
  previousOnDisk = freshExisting;
@@ -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.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahood/cli",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
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",