@yugabytedb/meko-mcp 1.0.6 → 1.1.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.
Files changed (38) hide show
  1. package/README.md +189 -36
  2. package/assets/hooks-handlers/lib/capture.js +177 -78
  3. package/assets/hooks-handlers/lib/package.json +1 -0
  4. package/assets/hooks-handlers/test_single_conversation.sh +1 -1
  5. package/assets/skills/meko-mcp-tools/SKILL.md +167 -53
  6. package/assets/skills/meko-mcp-tools/references/_sections.md +1 -1
  7. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +84 -22
  8. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +89 -56
  9. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +15 -7
  10. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +17 -9
  11. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +94 -5
  12. package/assets/skills/meko-mcp-tools/references/tools-overview.md +52 -37
  13. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +108 -37
  14. package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +6 -10
  15. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +30 -18
  16. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +55 -69
  17. package/bin/create-meko-setup.mjs +152 -26
  18. package/lib/clients/aliases.mjs +30 -0
  19. package/lib/clients/claude-code.mjs +384 -69
  20. package/lib/clients/claude-desktop.mjs +43 -8
  21. package/lib/clients/codex.mjs +977 -0
  22. package/lib/clients/common.mjs +142 -1
  23. package/lib/clients/cursor.mjs +73 -5
  24. package/lib/clients/index.mjs +2 -0
  25. package/lib/config.mjs +30 -0
  26. package/lib/detect.mjs +52 -44
  27. package/lib/installer.mjs +9 -2
  28. package/lib/mcp-transport.mjs +63 -4
  29. package/lib/migrate/adapters/memory-md.mjs +13 -6
  30. package/lib/migrate/datapack.mjs +167 -0
  31. package/lib/migrate/id.mjs +127 -9
  32. package/lib/migrate/mcp-client.mjs +1 -1
  33. package/lib/migrate/orchestrator.mjs +68 -10
  34. package/lib/migrate.mjs +346 -3
  35. package/lib/prompt.mjs +50 -76
  36. package/lib/validate.mjs +314 -10
  37. package/lib/version-check.mjs +178 -0
  38. package/package.json +2 -1
@@ -1,3 +1,7 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
1
5
  import { askYesNo } from "../prompt.mjs";
2
6
  import {
3
7
  getClaudeCodeSettingsPath,
@@ -8,17 +12,36 @@ import {
8
12
  removeKeys,
9
13
  writeJsonFile,
10
14
  } from "../config.mjs";
11
- import { detectClaude, detectExistingMeko } from "../detect.mjs";
12
- import { redactKey, run } from "./common.mjs";
15
+ import { detectClaude, detectExistingMeko, getClaudeCodeConfigPath } from "../detect.mjs";
16
+ import {
17
+ redactKey,
18
+ resolveHooksRoot,
19
+ run,
20
+ shellQuote,
21
+ writeInstallerMarker,
22
+ } from "./common.mjs";
13
23
 
14
24
  const CLAUDE_INSTALL_URL =
15
25
  "https://docs.anthropic.com/en/docs/claude-code/getting-started";
16
26
 
17
- // Hook scripts produced by earlier installer versions that may have had inline
18
- // MEKO_MCP_URL=... / MEKO_API_KEY=... env assignments baked into the command
19
- // string. Used by stripLiteralUrlFromHooks to recognize and clean them up.
27
+ // Filenames of the Meko-owned hook scripts. The meko-agent-skills plugin
28
+ // registers these at runtime via ${CLAUDE_PLUGIN_ROOT} (skills/hooks/hooks.json),
29
+ // so any entry still present in ~/.claude/settings.json that references one of
30
+ // these filenames is stale — either a pre-plugin absolute-path install that
31
+ // accumulated across install locations (issue #60), or a legacy env-prefixed
32
+ // command rewritten from MEKO-20. pruneMekoHookEntries uses this list to
33
+ // identify and remove those stale entries.
20
34
  const MEKO_HOOK_SCRIPTS = ["session-start.sh", "pre-compact.sh", "session-end.sh"];
21
35
 
36
+ // Qualified `plugin@marketplace` form. CLI 2.1.x's `plugin update` and
37
+ // `plugin uninstall` require this exact shape — the bare name fails with
38
+ // `Plugin "meko-agent-skills" not found` even when the plugin is installed
39
+ // (issues #59 / #63). `plugin install` fuzzy-resolves the bare name but we
40
+ // keep the shape consistent so every call site agrees. The marketplace happens
41
+ // to share the plugin's name (see extraKnownMarketplaces in settings.json).
42
+ const MEKO_PLUGIN_REF = "meko-agent-skills@meko-agent-skills";
43
+ const MEKO_MARKETPLACE_NAME = "meko-agent-skills";
44
+
22
45
  // Meko MCP tools to union into `permissions.allow` so Claude Code resolves
23
46
  // them without the deferred-tools ToolSearch round-trip (issue #5). The set
24
47
  // is intentionally tight — only tools the agent calls proactively per
@@ -41,11 +64,24 @@ const MEKO_ALWAYS_AVAILABLE_TOOLS = [
41
64
  ];
42
65
 
43
66
  /**
44
- * @param {{ getSettingsPath?: () => string }} [options]
67
+ * @param {{
68
+ * getSettingsPath?: () => string,
69
+ * getConfigPath?: () => string,
70
+ * getMekoCaptureDir?: () => string,
71
+ * runCommand?: (cmd: string) => string,
72
+ * }} [options]
45
73
  * @returns {object}
46
74
  */
47
75
  export function createClaudeCodeAdapter(options = {}) {
48
76
  const resolveSettingsPath = options.getSettingsPath ?? getClaudeCodeSettingsPath;
77
+ const resolveConfigPath = options.getConfigPath ?? getClaudeCodeConfigPath;
78
+ const resolveMekoCaptureDir =
79
+ options.getMekoCaptureDir ??
80
+ (() => join(homedir(), ".claude", "meko-capture"));
81
+ // DI hook: tests substitute a fake shell so installSkills() branching
82
+ // (probe -> update vs install, fallback on old CLIs) is verifiable
83
+ // without invoking a real `claude` binary.
84
+ const runCommand = options.runCommand ?? run;
49
85
 
50
86
  return {
51
87
  id: "claude-code",
@@ -68,11 +104,11 @@ export function createClaudeCodeAdapter(options = {}) {
68
104
  isInstalled() {
69
105
  return detectClaude().found;
70
106
  },
71
- detectExisting(name) {
72
- return detectExistingMeko(name);
107
+ detectExisting(name, { scope = "user", projectRoot } = {}) {
108
+ return detectExistingMeko(name, { scope, projectRoot });
73
109
  },
74
- removeRegistration(name) {
75
- run(`claude mcp remove ${name}`);
110
+ removeRegistration(name, { scope = "user" } = {}) {
111
+ runCommand(`claude mcp remove --scope ${shellQuote(scope)} ${shellQuote(name)}`);
76
112
  },
77
113
  registerServer({ name, url, scope, apiKey, dryRun, verbose, log }) {
78
114
  const cmd = this.buildRegisterCommand({ name, url, scope, apiKey });
@@ -87,18 +123,21 @@ export function createClaudeCodeAdapter(options = {}) {
87
123
  }
88
124
  },
89
125
  buildRegisterCommand({ name, url, scope, apiKey }) {
90
- let cmd = `claude mcp add ${name} ${url} --transport http --scope ${scope}`;
126
+ let cmd =
127
+ `claude mcp add --transport http --scope ${shellQuote(scope)} ` +
128
+ `${shellQuote(name)} ${shellQuote(url)}`;
91
129
  if (apiKey) {
92
- cmd += ` -H "API_KEY: ${apiKey}"`;
130
+ cmd += ` --header ${shellQuote(`Authorization: Bearer ${apiKey}`)}`;
93
131
  }
94
132
  return cmd;
95
133
  },
96
134
  buildRegisterDisplayCommand({ name, url, scope, apiKey }) {
97
- let displayCmd = `claude mcp add ${name} ${url} --transport http --scope ${scope}`;
98
- if (apiKey) {
99
- displayCmd += ` -H "API_KEY: ${redactKey(apiKey)}"`;
100
- }
101
- return displayCmd;
135
+ return this.buildRegisterCommand({
136
+ name,
137
+ url,
138
+ scope,
139
+ apiKey: apiKey ? redactKey(apiKey) : null,
140
+ });
102
141
  },
103
142
  runRegisterCommand(cmd) {
104
143
  return run(cmd);
@@ -129,7 +168,34 @@ export function createClaudeCodeAdapter(options = {}) {
129
168
 
130
169
  mergeSettings(settingsPath, envPatch);
131
170
  mergeAllowList(settingsPath, "permissions.allow", MEKO_ALWAYS_AVAILABLE_TOOLS);
132
- const migrated = stripLiteralUrlFromHooks(settingsPath);
171
+ const pruned = pruneMekoHookEntries(settingsPath);
172
+ if (pruned) {
173
+ // The prune removed at least one stale Meko hook entry. On the common
174
+ // path these were frozen capture.js copies from prior install roots
175
+ // (issue #60). On the edge case where Cursor claude-compat installed
176
+ // from a DIFFERENT root than this Claude Code install, those Cursor
177
+ // entries are indistinguishable from stale Claude Code entries and
178
+ // were also removed — warn the user so they can re-run the Cursor
179
+ // installer if they notice Cursor hooks missing.
180
+ log.warn(
181
+ "Removed stale Meko hook entries from settings.json. If you use Cursor claude-compat from a different install path, re-run the Cursor installer to restore its hooks.",
182
+ );
183
+ }
184
+
185
+ // Drop an installer-version marker in the meko-capture watermark dir so
186
+ // future installer runs can tell which version last touched this
187
+ // machine. This is purely for diagnostics / future upgrade logic —
188
+ // capture.js itself ignores the marker.
189
+ const mekoCaptureDir = resolveMekoCaptureDir();
190
+ try {
191
+ mkdirSync(mekoCaptureDir, { recursive: true });
192
+ writeInstallerMarker(mekoCaptureDir);
193
+ } catch (err) {
194
+ if (verbose) {
195
+ log.dim(` Could not write meko-capture marker: ${err.message}`);
196
+ }
197
+ }
198
+
133
199
  if (verbose) {
134
200
  log.dim(` Updated ${settingsPath}`);
135
201
  log.dim(` MEKO_MCP_URL = ${url}`);
@@ -139,25 +205,38 @@ export function createClaudeCodeAdapter(options = {}) {
139
205
  log.dim(
140
206
  ` permissions.allow += ${MEKO_ALWAYS_AVAILABLE_TOOLS.length} Meko tools`,
141
207
  );
142
- if (migrated) {
143
- log.dim(" Migrated stale hook command literals to env-based form.");
144
- }
145
208
  }
146
209
  },
147
210
  installSkills({ scope, dryRun, verbose, log }) {
211
+ // NB: we deliberately do NOT pass --sparse. Claude CLI's sparse
212
+ // path filter silently produces an empty clone for our layout,
213
+ // leaving the marketplace.json missing. Full clone is small
214
+ // (~few MB, mostly markdown) and is the only way that reliably
215
+ // succeeds today.
148
216
  const marketplaceCmd =
149
- `claude plugin marketplace add yugabyte/meko-mcp-server --sparse .claude-plugin,skills --scope ${scope}`;
150
- const pluginCmd = `claude plugin install meko-agent-skills --scope ${scope}`;
217
+ `claude plugin marketplace add yugabyte/meko-mcp-server --scope ${scope}`;
218
+ // `claude plugin list` dropped --scope in CLI 2.1.x; it prints all
219
+ // scopes. The match-by-token regex used below is scope-agnostic by
220
+ // design — `plugin update` failing with "not found" on a
221
+ // different-scope install is handled in the fallback chain.
222
+ const listCmd = `claude plugin list`;
223
+ // Qualified `plugin@marketplace` form — see MEKO_PLUGIN_REF comment
224
+ // at the top of this file.
225
+ const installCmd = `claude plugin install ${MEKO_PLUGIN_REF} --scope ${scope}`;
226
+ const updateCmd = `claude plugin update ${MEKO_PLUGIN_REF} --scope ${scope}`;
227
+ const uninstallCmd = `claude plugin uninstall ${MEKO_PLUGIN_REF} --scope ${scope}`;
151
228
 
152
229
  if (dryRun) {
153
230
  log.dim(` Would run: ${marketplaceCmd}`);
154
- log.dim(` Would run: ${pluginCmd}`);
231
+ log.dim(` Would run: ${listCmd}`);
232
+ log.dim(` Would run: ${updateCmd} (if already installed)`);
233
+ log.dim(` Would run: ${installCmd} (if not yet installed)`);
155
234
  return { marketplace: "dry-run", skills: "dry-run" };
156
235
  }
157
236
 
158
237
  let marketplaceStatus = "ok";
159
238
  try {
160
- const output = run(marketplaceCmd);
239
+ const output = runCommand(marketplaceCmd);
161
240
  if (verbose && output) log.dim(` ${output}`);
162
241
  } catch (err) {
163
242
  const msg = err.stderr || err.message || "";
@@ -170,27 +249,130 @@ export function createClaudeCodeAdapter(options = {}) {
170
249
  }
171
250
  }
172
251
 
173
- let skillStatus = "ok";
252
+ // Probe for an existing install so we can call `plugin update` instead
253
+ // of `plugin install` (the latter no-ops on newer Claude CLIs when the
254
+ // plugin is already present — which means users running
255
+ // `npx @yugabytedb/meko-mcp@latest` never pull updated skill content).
256
+ let alreadyInstalled = false;
174
257
  try {
175
- const output = run(pluginCmd);
176
- if (verbose && output) log.dim(` ${output}`);
258
+ const listOutput = runCommand(listCmd);
259
+ if (verbose && listOutput) log.dim(` ${listOutput}`);
260
+ // Match only when "meko-agent-skills" appears as a standalone token.
261
+ // Leading anchor (^|\s) prevents false positives on substrings like
262
+ // `not-meko-agent-skills`. Trailing anchor accepts whitespace, EOL,
263
+ // or `@` — CLI 2.1.x formats plugins as `name@marketplace`, so the
264
+ // plugin appears as `meko-agent-skills@meko-agent-skills` in the
265
+ // probe output. `\b` is too loose because `-` counts as a word
266
+ // boundary, so `\bmeko-agent-skills\b` would match inside
267
+ // `not-meko-agent-skills`.
268
+ //
269
+ // This is deliberately scope-agnostic — `plugin list` prints all
270
+ // scopes. If the plugin exists at a scope other than the one we're
271
+ // installing into, `plugin update --scope ${scope}` will fail with
272
+ // "Plugin not found" and the `not found` branch in the update
273
+ // catch-block below falls through to `plugin install`.
274
+ alreadyInstalled = /(^|\s)meko-agent-skills(\s|@|$)/m.test(listOutput);
177
275
  } catch (err) {
276
+ // If the probe fails we fall through to `plugin install`, which is
277
+ // the original behavior — safer than assuming a state we can't see.
178
278
  const msg = err.stderr || err.message || "";
179
- if (msg.includes("already") || msg.includes("installed")) {
180
- skillStatus = "already";
181
- } else {
182
- skillStatus = "error";
183
- log.warn("Skills plugin install skipped (non-critical).");
184
- log.dim(msg.trim());
279
+ if (verbose) log.dim(` plugin list probe failed: ${msg.trim()}`);
280
+ }
281
+
282
+ let skillStatus = "ok";
283
+ if (alreadyInstalled) {
284
+ try {
285
+ const output = runCommand(updateCmd);
286
+ if (verbose && output) log.dim(` ${output}`);
287
+ log.dim(" Updated meko-agent-skills plugin");
288
+ } catch (err) {
289
+ const msg = err.stderr || err.message || "";
290
+ // Older Claude CLIs don't know `plugin update`. The exact wording
291
+ // varies (`unknown command`, `unrecognized option`, `invalid`,
292
+ // `command not found`) so we match a family of signals and fall
293
+ // back to uninstall + install, which achieves the same refresh.
294
+ if (
295
+ msg.includes("unknown command") ||
296
+ msg.includes("unrecognized") ||
297
+ msg.includes("invalid command") ||
298
+ msg.includes("command not found") ||
299
+ msg.includes("Unknown subcommand")
300
+ ) {
301
+ log.warn(" claude plugin update not supported — falling back to uninstall + install");
302
+ try {
303
+ const unOutput = runCommand(uninstallCmd);
304
+ if (verbose && unOutput) log.dim(` ${unOutput}`);
305
+ } catch (unErr) {
306
+ if (verbose) {
307
+ const unMsg = unErr.stderr || unErr.message || "";
308
+ log.dim(` plugin uninstall (fallback) failed: ${unMsg.trim()}`);
309
+ }
310
+ }
311
+ try {
312
+ const inOutput = runCommand(installCmd);
313
+ if (verbose && inOutput) log.dim(` ${inOutput}`);
314
+ log.dim(" Installed meko-agent-skills plugin");
315
+ } catch (inErr) {
316
+ const inMsg = inErr.stderr || inErr.message || "";
317
+ skillStatus = "error";
318
+ log.warn("Skills plugin install skipped (non-critical).");
319
+ log.dim(inMsg.trim());
320
+ }
321
+ } else if (msg.includes("not found")) {
322
+ // `plugin list` matched the plugin at some scope, but
323
+ // `plugin update --scope ${scope}` can't see it — the install
324
+ // lives at a different scope. Fall through to `plugin install`
325
+ // at the requested scope, which is what the user actually
326
+ // asked for. (Also covers rare probe false-positives where
327
+ // the regex matched but the plugin isn't really installed
328
+ // anywhere visible to update.)
329
+ log.dim(` plugin update reports not found at scope ${scope} — installing instead`);
330
+ try {
331
+ const inOutput = runCommand(installCmd);
332
+ if (verbose && inOutput) log.dim(` ${inOutput}`);
333
+ log.dim(" Installed meko-agent-skills plugin");
334
+ } catch (inErr) {
335
+ const inMsg = inErr.stderr || inErr.message || "";
336
+ if (inMsg.includes("already") || inMsg.includes("installed")) {
337
+ skillStatus = "already";
338
+ } else {
339
+ skillStatus = "error";
340
+ log.warn("Skills plugin install skipped (non-critical).");
341
+ log.dim(inMsg.trim());
342
+ }
343
+ }
344
+ } else {
345
+ skillStatus = "error";
346
+ log.warn("Skills plugin update skipped (non-critical).");
347
+ log.dim(msg.trim());
348
+ }
349
+ }
350
+ } else {
351
+ try {
352
+ const output = runCommand(installCmd);
353
+ if (verbose && output) log.dim(` ${output}`);
354
+ log.dim(" Installed meko-agent-skills plugin");
355
+ } catch (err) {
356
+ const msg = err.stderr || err.message || "";
357
+ if (msg.includes("already") || msg.includes("installed")) {
358
+ skillStatus = "already";
359
+ } else {
360
+ skillStatus = "error";
361
+ log.warn("Skills plugin install skipped (non-critical).");
362
+ log.dim(msg.trim());
363
+ }
185
364
  }
186
365
  }
187
366
 
188
367
  return { marketplace: marketplaceStatus, skills: skillStatus };
189
368
  },
190
- uninstall({ name, settingsPath }) {
369
+ uninstall({ name, scope = "user", settingsPath }) {
191
370
  const results = [];
192
371
  try {
193
- run(`claude mcp remove ${name}`);
372
+ // Route through runCommand (DI) so unit tests that stub the shell
373
+ // don't accidentally invoke the real `claude mcp remove` against
374
+ // the developer's or CI runner's own config.
375
+ runCommand(`claude mcp remove ${name}`);
194
376
  results.push({ key: "mcp", ok: true, message: `MCP server "${name}" removed.` });
195
377
  } catch {
196
378
  results.push({ key: "mcp", ok: false, message: `MCP server "${name}" was not registered (nothing to remove).` });
@@ -205,67 +387,200 @@ export function createClaudeCodeAdapter(options = {}) {
205
387
  }
206
388
 
207
389
  try {
208
- run("claude plugin uninstall meko-agent-skills");
390
+ pruneMekoHookEntries(settingsPath);
391
+ results.push({ key: "hooks", ok: true, message: "Stale Meko hook entries pruned from settings." });
392
+ } catch (err) {
393
+ results.push({ key: "hooks", ok: false, message: `Could not prune Meko hook entries: ${err.message}` });
394
+ }
395
+
396
+ try {
397
+ // Qualified plugin@marketplace form — CLI 2.1.x rejects the bare
398
+ // name (issue #63). Before: `catch` swallowed every failure as
399
+ // "not installed", so a CLI shape mismatch reported success while
400
+ // the plugin stayed registered. Distinguish real "not installed"
401
+ // from other errors by inspecting stderr (case-insensitive, since
402
+ // wording varies across CLI versions).
403
+ //
404
+ // Scope must match the installSkills() call site — a project-scope
405
+ // install is invisible to a user-scope uninstall on CLI 2.1.x, so
406
+ // without --scope the plugin stays registered after `--uninstall`.
407
+ runCommand(`claude plugin uninstall ${MEKO_PLUGIN_REF} --scope ${scope}`);
209
408
  results.push({ key: "skills", ok: true, message: "Skills plugin removed." });
210
- } catch {
211
- results.push({ key: "skills", ok: false, message: "Skills plugin was not installed (nothing to remove)." });
409
+ } catch (err) {
410
+ const msg = err.stderr || err.message || "";
411
+ const lower = msg.toLowerCase();
412
+ if (lower.includes("not found") || lower.includes("not installed")) {
413
+ results.push({ key: "skills", ok: false, message: "Skills plugin was not installed (nothing to remove)." });
414
+ } else {
415
+ results.push({ key: "skills", ok: false, message: `Skills plugin uninstall failed: ${msg.trim()}` });
416
+ }
212
417
  }
213
418
 
214
419
  try {
215
- run("claude plugin marketplace remove meko-agent-skills");
420
+ runCommand(`claude plugin marketplace remove ${MEKO_MARKETPLACE_NAME}`);
216
421
  results.push({ key: "marketplace", ok: true, message: "Plugin marketplace entry removed." });
217
- } catch {
218
- results.push({ key: "marketplace", ok: false, message: "Plugin marketplace entry was not registered (nothing to remove)." });
422
+ } catch (err) {
423
+ // Mirror the skills-uninstall treatment: a legitimate "never
424
+ // registered" state is different from a CLI shape mismatch or a
425
+ // missing binary. Without this, a broken CLI contract looks
426
+ // identical to success.
427
+ const msg = err.stderr || err.message || "";
428
+ const lower = msg.toLowerCase();
429
+ if (lower.includes("not found") || lower.includes("not registered") || lower.includes("not installed")) {
430
+ results.push({ key: "marketplace", ok: false, message: "Plugin marketplace entry was not registered (nothing to remove)." });
431
+ } else {
432
+ results.push({ key: "marketplace", ok: false, message: `Plugin marketplace remove failed: ${msg.trim()}` });
433
+ }
219
434
  }
220
435
 
221
436
  return results;
222
437
  },
223
- async validateConfig({ serverName }) {
438
+ async validateConfig({ serverName, scope = "user", projectRoot, url, apiKey }) {
439
+ const configPath = resolveConfigPath();
440
+ let cfg;
224
441
  try {
225
- const output = run("claude mcp list");
226
- const lines = output.split("\n").map((l) => l.trim()).filter(Boolean);
227
- const found = lines.some((line) => line.split(":")[0].trim() === serverName);
228
- if (found) return { ok: true, message: `Server "${serverName}" is registered.` };
442
+ cfg = readJsonFile(configPath);
443
+ } catch (err) {
444
+ return { ok: false, message: `Could not read ${configPath}: ${err.message}` };
445
+ }
446
+ const entry =
447
+ scope === "project"
448
+ ? cfg?.projects?.[projectRoot]?.mcpServers?.[serverName]
449
+ : cfg?.mcpServers?.[serverName];
450
+
451
+ if (!entry) {
452
+ return { ok: false, message: `Server "${serverName}" not found in ${configPath}.` };
453
+ }
454
+
455
+ if (url && entry.url !== url) {
229
456
  return {
230
457
  ok: false,
231
- message: `Server "${serverName}" not found in \`claude mcp list\` output. Run the installer again or add it manually.`,
458
+ message: `Server "${serverName}" in ${configPath} still points at ${entry.url ?? "<missing url>"}.`,
232
459
  };
233
- } catch (err) {
234
- return { ok: false, message: `Failed to run \`claude mcp list\`: ${err.message}` };
235
460
  }
461
+
462
+ const authHeader = getHeader(entry.headers, "Authorization");
463
+ if (apiKey) {
464
+ const expected = `Bearer ${apiKey}`;
465
+ if (authHeader !== expected) {
466
+ return {
467
+ ok: false,
468
+ message: `Server "${serverName}" in ${configPath} does not contain the current Authorization header.`,
469
+ };
470
+ }
471
+ } else if (authHeader) {
472
+ return {
473
+ ok: false,
474
+ message: `Server "${serverName}" in ${configPath} still contains an Authorization header.`,
475
+ };
476
+ }
477
+
478
+ return { ok: true, message: `Server "${serverName}" is registered in ${configPath}.` };
236
479
  },
237
480
  };
238
481
  }
239
482
 
240
- // Rewrites `MEKO_MCP_URL='...' [MEKO_API_KEY='...'] bash "<script>"` hook command
241
- // strings to `bash "<script>"`, for the three meko hook scripts. Earlier installer
242
- // versions (cursor.mjs buildScriptCommand) literalized URL+key into the command,
243
- // which silently shadowed the env block at runtime — see MEKO-20. Runs on every
244
- // Claude Code install as a one-time migration. Returns true if anything changed.
245
- export function stripLiteralUrlFromHooks(settingsPath) {
483
+ /**
484
+ * Remove stale Meko hook entries from a Claude Code settings.json —
485
+ * keeping any entry that points at the CURRENT install's hooks root.
486
+ *
487
+ * The meko-agent-skills plugin registers hooks at runtime via
488
+ * ${CLAUDE_PLUGIN_ROOT} (skills/hooks/hooks.json), so a Meko entry in
489
+ * settings.json.hooks.* pointing at SOME OTHER install path is a
490
+ * pre-plugin absolute-path entry left over from a prior install location
491
+ * (issue #60) or a legacy env-prefixed command (MEKO-20). Those fire
492
+ * frozen copies of capture.js on every session event and throw against
493
+ * the current contract.
494
+ *
495
+ * Cursor's claude-compat mode (cursor.mjs installClaudeCompatHooks)
496
+ * shares this file on scope=user and writes entries in exactly the same
497
+ * shape as stale Claude Code entries — both are absolute-path
498
+ * `bash "<hooks-handlers-root>/<script>"` commands. There's no
499
+ * schema-safe signal in the command string that distinguishes the two,
500
+ * so prune uses the current install root as a best-effort exemption:
501
+ *
502
+ * GUARANTEE (narrow): a Cursor claude-compat entry SURVIVES when its
503
+ * command references the SAME resolveHooksRoot() this Claude Code
504
+ * install resolved to. Cursor installed from the same path (e.g. both
505
+ * via homebrew, or both from the same repo clone) will keep its hooks.
506
+ *
507
+ * KNOWN TRADE-OFF (issue #60 comment thread): a Cursor claude-compat
508
+ * entry written from a DIFFERENT install root (e.g. Cursor installed
509
+ * from the repo clone, Claude Code installed from npx cache) WILL be
510
+ * pruned — it's indistinguishable from the stale-Claude-Code entries
511
+ * that issue #60 is about. Callers should warn the user, and users
512
+ * recover by re-running the Cursor installer.
513
+ *
514
+ * Match rule for pruning: command is a string that contains BOTH
515
+ * `hooks-handlers/` AND one of MEKO_HOOK_SCRIPTS, AND the command does
516
+ * NOT contain the current install's hooks root path. The `hooks-handlers/`
517
+ * fence keeps unrelated user scripts that happen to share a filename
518
+ * (e.g. their own `session-start.sh`) from being pruned.
519
+ *
520
+ * Prunes empty group.hooks arrays, empty groups, empty event arrays, and
521
+ * deletes cfg.hooks entirely when it ends up empty — so an untouched file
522
+ * stays byte-identical. Only writes when something actually changed.
523
+ *
524
+ * @param {string} settingsPath
525
+ * @returns {boolean} true iff the file was rewritten
526
+ */
527
+ export function pruneMekoHookEntries(settingsPath) {
246
528
  const cfg = readJsonFile(settingsPath);
247
529
  if (!cfg?.hooks || typeof cfg.hooks !== "object") return false;
248
530
 
531
+ const currentHooksRoot = resolveHooksRoot();
532
+
533
+ const isStaleMekoHookCommand = (cmd) =>
534
+ typeof cmd === "string"
535
+ && cmd.includes("hooks-handlers/")
536
+ && MEKO_HOOK_SCRIPTS.some((s) => cmd.includes(s))
537
+ && !cmd.includes(currentHooksRoot);
538
+
539
+ // Only cascade empty-array cleanup on events where we actually removed a
540
+ // Meko entry — otherwise a user's pre-existing empty hook array unrelated
541
+ // to Meko (e.g. { hooks: { Notification: [] } }) would get silently
542
+ // collapsed and trigger a file rewrite, breaking the "untouched file
543
+ // stays byte-identical" guarantee.
249
544
  let changed = false;
250
- for (const eventArr of Object.values(cfg.hooks)) {
545
+ for (const event of Object.keys(cfg.hooks)) {
546
+ const eventArr = cfg.hooks[event];
251
547
  if (!Array.isArray(eventArr)) continue;
548
+
549
+ let eventChanged = false;
252
550
  for (const group of eventArr) {
253
551
  if (!Array.isArray(group?.hooks)) continue;
254
- for (const h of group.hooks) {
255
- if (typeof h?.command !== "string") continue;
256
- if (!MEKO_HOOK_SCRIPTS.some((s) => h.command.includes(s))) continue;
257
- const rewritten = h.command.replace(
258
- /^\s*(?:MEKO_\w+=(?:'[^']*'|"[^"]*"|\S+)\s+)+/,
259
- "",
260
- );
261
- if (rewritten !== h.command) {
262
- h.command = rewritten;
263
- changed = true;
264
- }
265
- }
552
+ const before = group.hooks.length;
553
+ group.hooks = group.hooks.filter((h) => !isStaleMekoHookCommand(h?.command));
554
+ if (group.hooks.length !== before) eventChanged = true;
555
+ }
556
+ if (!eventChanged) continue;
557
+
558
+ const prunedEventArr = eventArr.filter(
559
+ (group) => Array.isArray(group?.hooks) && group.hooks.length > 0,
560
+ );
561
+ if (prunedEventArr.length !== eventArr.length) {
562
+ cfg.hooks[event] = prunedEventArr;
563
+ }
564
+
565
+ if (cfg.hooks[event].length === 0) {
566
+ delete cfg.hooks[event];
266
567
  }
568
+ changed = true;
569
+ }
570
+
571
+ if (changed && Object.keys(cfg.hooks).length === 0) {
572
+ delete cfg.hooks;
267
573
  }
268
574
 
269
575
  if (changed) writeJsonFile(settingsPath, cfg);
270
576
  return changed;
271
577
  }
578
+
579
+ function getHeader(headers, name) {
580
+ if (!headers || typeof headers !== "object") return undefined;
581
+ const wanted = name.toLowerCase();
582
+ for (const [key, value] of Object.entries(headers)) {
583
+ if (key.toLowerCase() === wanted) return value;
584
+ }
585
+ return undefined;
586
+ }