@ahood/cli 0.8.2 → 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.
- package/dist/commands/add.js +60 -7
- package/dist/commands/remove.js +37 -11
- package/package.json +1 -1
package/dist/commands/add.js
CHANGED
|
@@ -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(
|
|
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.
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
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 (
|
|
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 ||
|
|
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;
|
package/dist/commands/remove.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
76
|
-
// on -- add.ts's readMcpConfig is the strict validator for that
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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