@yugabytedb/meko-mcp 1.1.0 → 2.0.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 (46) hide show
  1. package/README.md +81 -23
  2. package/assets/hooks-handlers/before-submit-prompt.sh +44 -0
  3. package/assets/hooks-handlers/cursor-session-start.sh +42 -0
  4. package/assets/hooks-handlers/lib/capture.js +404 -88
  5. package/assets/hooks-handlers/lib/checkpoint-timer.js +23 -11
  6. package/assets/hooks-handlers/session-start.sh +6 -2
  7. package/assets/scripts/meko-statusline.sh +119 -0
  8. package/assets/scripts/package-desktop-skill.sh +39 -0
  9. package/assets/skills/meko-mcp-tools/SKILL.md +42 -50
  10. package/assets/skills/meko-mcp-tools/references/_sections.md +1 -1
  11. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +75 -150
  12. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +30 -36
  13. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +6 -22
  14. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +9 -8
  15. package/assets/skills/meko-mcp-tools/references/tools-overview.md +35 -42
  16. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +28 -92
  17. package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +5 -9
  18. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +1 -81
  19. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +244 -0
  20. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +180 -0
  21. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +24 -0
  22. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +99 -0
  23. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +327 -0
  24. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +63 -0
  25. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +214 -0
  26. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +105 -0
  27. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +70 -0
  28. package/assets/skills/meko-mcp-tools-desktop/references/tools-scope-permissions.md +71 -0
  29. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +75 -0
  30. package/assets/skills/meko-select-datapack-desktop/SKILL.md +198 -0
  31. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +121 -0
  32. package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +66 -0
  33. package/bin/create-meko-setup.mjs +33 -4
  34. package/lib/clients/claude-code.mjs +133 -6
  35. package/lib/clients/claude-desktop.mjs +48 -20
  36. package/lib/clients/codex.mjs +538 -5
  37. package/lib/clients/common.mjs +122 -1
  38. package/lib/clients/cursor.mjs +120 -27
  39. package/lib/config.mjs +52 -0
  40. package/lib/installer.mjs +26 -2
  41. package/lib/mcp-transport.mjs +5 -0
  42. package/lib/validate.mjs +0 -1
  43. package/package.json +1 -1
  44. package/scripts/bundle-assets.mjs +16 -0
  45. package/assets/skills/meko-mcp-tools/references/tools-db-query-patterns.md +0 -38
  46. package/assets/skills/meko-mcp-tools/references/tools-write-guardrails.md +0 -30
@@ -11,12 +11,14 @@
11
11
  import { execSync } from "node:child_process";
12
12
  import {
13
13
  appendFileSync,
14
+ chmodSync,
14
15
  copyFileSync,
15
16
  existsSync,
16
17
  lstatSync,
17
18
  mkdirSync,
18
19
  readFileSync,
19
20
  readlinkSync,
21
+ rmSync,
20
22
  writeFileSync,
21
23
  } from "node:fs";
22
24
  import { homedir } from "node:os";
@@ -26,13 +28,22 @@ import { askYesNo } from "../prompt.mjs";
26
28
  import {
27
29
  getCodexAgentsMdPath,
28
30
  getCodexConfigPath,
31
+ getCodexHooksInstallRoot,
32
+ getCodexHooksPath,
33
+ getCodexMekoEnvFilePath,
29
34
  getCodexSkillsRoot,
35
+ readJsonFile,
36
+ writeJsonFile,
30
37
  } from "../config.mjs";
31
38
  import {
32
39
  getInstallerVersion,
40
+ installStableTree,
41
+ isPackagedInstall,
42
+ isDanglingSymlink,
33
43
  markerIsStale,
34
44
  readInstallerMarker,
35
45
  removePath,
46
+ resolveHooksRoot,
36
47
  resolveSkillSource,
37
48
  shellQuote,
38
49
  symlinkOrCopy,
@@ -42,6 +53,16 @@ import {
42
53
  const CODEX_INSTALL_URL = "https://github.com/openai/codex";
43
54
  const CODEX_APP_INSTALL_URL = "https://openai.com/index/codex";
44
55
 
56
+ // Cloud Meko (mcp.mekodata.ai) sits behind a WAF that returns an HTML 403 for
57
+ // any request with no User-Agent. Codex's native MCP client (codex_rmcp_client)
58
+ // sends none by default, so its `initialize` handshake 403s and the server
59
+ // fails to start. `codex mcp add` exposes no header flag, so we set a per-server
60
+ // `http_headers` entry in config.toml. Scoped to [mcp_servers.<name>] only — it
61
+ // affects requests to the Meko MCP server and nothing else. Local installs skip
62
+ // this (no WAF in front of localhost). See GH #174 and the capture.js UA fix.
63
+ const CODEX_MCP_USER_AGENT =
64
+ "codex-mcp/1.0 (+https://github.com/yugabyte/meko-mcp-server)";
65
+
45
66
  // AGENTS.md sentinel markers. Idempotent upsert/strip relies on these being
46
67
  // stable across versions — never rename without a migration story.
47
68
  const AGENTS_MD_START = "<!-- meko:start -->";
@@ -54,12 +75,25 @@ const RC_FENCE_START = "# >>> meko mcp >>>";
54
75
  const RC_FENCE_END = "# <<< meko mcp <<<";
55
76
 
56
77
  const SKILLS = [{ name: "meko-mcp-tools" }];
78
+ // Codex hook timeouts are in SECONDS (Duration::from_secs in
79
+ // codex-rs/hooks/src/engine/command_runner.rs), not milliseconds like Claude
80
+ // Code / Cursor. Default is 600s if omitted; we cap SessionStart/SessionEnd
81
+ // at 15s/30s to match the Claude Code values logically.
82
+ const MEKO_HOOKS = [
83
+ { event: "SessionStart", script: "session-start.sh", timeout: 15 },
84
+ { event: "PreCompact", script: "pre-compact.sh" },
85
+ { event: "SessionEnd", script: "session-end.sh", timeout: 30 },
86
+ ];
87
+ const MEKO_HOOK_SCRIPTS = MEKO_HOOKS.map((hook) => hook.script);
57
88
 
58
89
  /**
59
90
  * @param {{
60
91
  * getConfigPath?: () => string,
61
92
  * getAgentsMdPath?: () => string,
93
+ * getHooksPath?: () => string,
62
94
  * getSkillsRoot?: () => string,
95
+ * resolveHooksRoot?: () => string,
96
+ * getHooksInstallRoot?: () => string,
63
97
  * runCommand?: (cmd: string) => string,
64
98
  * probeCodex?: () => {found: boolean, version: string, path: string},
65
99
  * probeCodexApp?: () => {found: boolean, path: string, platform: string, version: string} | null,
@@ -71,7 +105,14 @@ const SKILLS = [{ name: "meko-mcp-tools" }];
71
105
  export function createCodexAdapter(options = {}) {
72
106
  const resolveConfigPath = options.getConfigPath ?? getCodexConfigPath;
73
107
  const resolveAgentsMdPath = options.getAgentsMdPath ?? getCodexAgentsMdPath;
108
+ const resolveHooksPath = options.getHooksPath ?? getCodexHooksPath;
109
+ const resolveMekoEnvFilePath =
110
+ options.getMekoEnvFilePath ?? getCodexMekoEnvFilePath;
111
+ const resolveHooksRootPath = options.resolveHooksRoot ?? resolveHooksRoot;
112
+ const resolveHooksInstallRoot =
113
+ options.getHooksInstallRoot ?? getCodexHooksInstallRoot;
74
114
  const resolveSkillsRoot = options.getSkillsRoot ?? getCodexSkillsRoot;
115
+ const packagedInstallDetector = options.isPackagedInstall ?? isPackagedInstall;
75
116
  const runCommand = options.runCommand ?? defaultRunCommand;
76
117
  const probeCodex = options.probeCodex ?? defaultProbeCodex;
77
118
  const probeCodexApp = options.probeCodexApp ?? defaultProbeCodexApp;
@@ -83,6 +124,16 @@ export function createCodexAdapter(options = {}) {
83
124
 
84
125
  let scopeWarned = false;
85
126
 
127
+ // A reinstall's remove→register sequence deletes the whole
128
+ // [mcp_servers.<name>] block (via `codex mcp remove` or removeCodexConfigBlock),
129
+ // including any http_headers in either inline or table form, before
130
+ // registerServer rewrites it — which would silently drop a user's custom
131
+ // headers. removeRegistration stashes the pre-existing headers (as a key→value
132
+ // map, form-agnostic) here so the later ensureCodexHttpHeaderUserAgent step
133
+ // can merge them back while guaranteeing a User-Agent. null = nothing to
134
+ // preserve.
135
+ let preservedHttpHeaders = null;
136
+
86
137
  /** Returns the resolved config.toml path; warns once on project scope. */
87
138
  function settingsPathFor(scope, log) {
88
139
  if (scope === "project" && !scopeWarned) {
@@ -165,6 +216,11 @@ export function createCodexAdapter(options = {}) {
165
216
  },
166
217
  removeRegistration(name, { scope, log } = {}) {
167
218
  const path = settingsPathFor(scope, log);
219
+ // Capture any existing http_headers (inline OR table form) BEFORE we
220
+ // delete the block, so a reinstall can restore the user's custom values
221
+ // (see preservedHttpHeaders). Done on both the CLI and TOML paths since
222
+ // `codex mcp remove` also drops the whole block and any subtable.
223
+ preservedHttpHeaders = readCodexHttpHeaders(safeReadFile(path), name);
168
224
  if (surfaces.cli) {
169
225
  try {
170
226
  runCommand(`codex mcp remove ${shellQuote(name)}`);
@@ -185,6 +241,8 @@ export function createCodexAdapter(options = {}) {
185
241
  const useApiKey = apiKey ? "MEKO_API_KEY" : null;
186
242
  const cliCmd = buildCliRegisterCommand(name, url, useApiKey);
187
243
 
244
+ const cloud = !isLocalUrl(url);
245
+
188
246
  if (dryRun) {
189
247
  if (surfaces.cli) {
190
248
  log.dim?.(` Would run: ${cliCmd}`);
@@ -196,6 +254,11 @@ export function createCodexAdapter(options = {}) {
196
254
  " Codex resolves the bearer token from $MEKO_API_KEY at runtime; the key is not written to config.toml.",
197
255
  );
198
256
  }
257
+ if (cloud) {
258
+ log.dim?.(
259
+ ` Would ensure [mcp_servers.${name}] has an http_headers User-Agent (Cloud WAF needs one; backs up ${path} first, preserves any existing header).`,
260
+ );
261
+ }
199
262
  return;
200
263
  }
201
264
 
@@ -233,6 +296,42 @@ export function createCodexAdapter(options = {}) {
233
296
  if (verbose) log.dim?.(` Wrote [mcp_servers.${name}] to ${path}`);
234
297
  }
235
298
 
299
+ // Cloud Meko's WAF 403s Codex's native MCP client when it sends no
300
+ // User-Agent on the initialize handshake. `codex mcp add` has no header
301
+ // flag, so patch the written block with an http_headers User-Agent.
302
+ // Reads the file first, preserves any existing http_headers, and backs
303
+ // up config.toml before writing. Local installs have no WAF, so skip.
304
+ if (cloud) {
305
+ // Merge back any custom http_headers captured by removeRegistration
306
+ // before the reinstall wiped the block, while guaranteeing a User-Agent.
307
+ const preserved = preservedHttpHeaders;
308
+ preservedHttpHeaders = null; // consume — don't leak into a later call
309
+ const outcome = ensureCodexHttpHeaderUserAgent(path, name, {
310
+ preserved,
311
+ });
312
+ if (outcome === "restored") {
313
+ log.dim?.(
314
+ ` Restored your existing http_headers on [mcp_servers.${name}] (with a User-Agent) after re-registration.`,
315
+ );
316
+ } else if (outcome === "merged") {
317
+ log.dim?.(
318
+ ` Added a User-Agent to your existing http_headers on [mcp_servers.${name}] so Codex's MCP client clears the Cloud WAF (backup written to ${path}.bak).`,
319
+ );
320
+ } else if (outcome === "added") {
321
+ log.dim?.(
322
+ ` Set http_headers User-Agent on [mcp_servers.${name}] so Codex's MCP client clears the Cloud WAF (backup written to ${path}.bak).`,
323
+ );
324
+ } else if (outcome === "present" && verbose) {
325
+ log.dim?.(
326
+ ` [mcp_servers.${name}] already has an http_headers User-Agent — left as-is.`,
327
+ );
328
+ } else if (outcome === "absent") {
329
+ log.warn?.(
330
+ ` Could not find [mcp_servers.${name}] in ${path} to set the Cloud WAF User-Agent header; if Codex reports an HTTP 403 on startup, add http_headers = { "User-Agent" = "${CODEX_MCP_USER_AGENT}" } manually.`,
331
+ );
332
+ }
333
+ }
334
+
236
335
  // Cloud auth UX: env-var-only model. The key is never written to
237
336
  // config.toml; the user's runtime env supplies it. Print actionable
238
337
  // instructions and (when interactive) offer to wire things up.
@@ -256,6 +355,12 @@ export function createCodexAdapter(options = {}) {
256
355
  log,
257
356
  });
258
357
  }
358
+ } else if (hasApp && platform === "darwin") {
359
+ setLaunchctlMekoApiKey({
360
+ apiKey,
361
+ runCommand,
362
+ log,
363
+ });
259
364
  }
260
365
  }
261
366
 
@@ -268,10 +373,39 @@ export function createCodexAdapter(options = {}) {
268
373
  if (yes) return true;
269
374
  return promptUser(`${name} MCP is already configured. Overwrite?`, false);
270
375
  },
271
- setHookEnvironment({ log, dryRun }) {
272
- if (!dryRun) {
376
+ setHookEnvironment({ url, apiKey, log, dryRun, verbose }) {
377
+ const hooksPath = resolveHooksPath();
378
+ const assetHooksRoot = resolveHooksRootPath();
379
+ const installedHooksRoot = resolveHooksInstallRoot();
380
+ const envFilePath = resolveMekoEnvFilePath();
381
+ if (dryRun) {
382
+ log?.dim?.(` Would copy Codex hook scripts to ${installedHooksRoot}`);
383
+ log?.dim?.(` Would write env file at ${envFilePath} (mode 0600)`);
384
+ log?.dim?.(` Would configure Codex hooks at ${hooksPath}`);
385
+ if (apiKey) {
386
+ log?.dim?.(
387
+ ` MEKO_API_KEY will be stored in ${envFilePath} (mode 0600); hook commands source it (no API key in process args).`,
388
+ );
389
+ }
390
+ return;
391
+ }
392
+
393
+ // Copy the hook scripts to a home-dir-stable location before wiring
394
+ // hooks.json at that path. Referencing the shipped asset root directly
395
+ // would pin an ephemeral npx cache dir that npx later GCs, leaving the
396
+ // hook a dangling `bash '<missing>'` that exits 127. See
397
+ // getCodexHooksInstallRoot for the full rationale.
398
+ installStableTree(assetHooksRoot, installedHooksRoot);
399
+ writeCodexMekoEnvFile(envFilePath, { url, apiKey });
400
+ const changed = upsertCodexMekoHooks(hooksPath, installedHooksRoot, {
401
+ envFilePath,
402
+ });
403
+ if (verbose || changed) {
404
+ log?.dim?.(` Updated Codex hooks at ${hooksPath}`);
405
+ }
406
+ if (apiKey) {
273
407
  log?.dim?.(
274
- " Codex does not expose user-installable session hooks; auto-capture is unavailable.",
408
+ ` MEKO_API_KEY stored in ${envFilePath} (mode 0600). Hook commands source the file at run time, so the key never appears in process arguments.`,
275
409
  );
276
410
  }
277
411
  },
@@ -314,9 +448,20 @@ export function createCodexAdapter(options = {}) {
314
448
  } else {
315
449
  continue;
316
450
  }
451
+ } else if (isDanglingSymlink(destination)) {
452
+ // Stale symlink left behind by an earlier install whose target
453
+ // (e.g. a deleted worktree) no longer exists. existsSync is false
454
+ // for these, so the branch above misses them. Clear it so the
455
+ // fresh-install path below can replace it cleanly.
456
+ log.dim?.(
457
+ ` Replacing dangling symlink at ${destination} (target missing).`,
458
+ );
459
+ removePath(destination);
317
460
  }
318
461
 
319
- const strategy = symlinkOrCopy(source, destination);
462
+ const strategy = symlinkOrCopy(source, destination, {
463
+ preferCopy: packagedInstallDetector(),
464
+ });
320
465
  if (strategy === "copy") writeInstallerMarker(destination);
321
466
  }
322
467
 
@@ -373,6 +518,28 @@ export function createCodexAdapter(options = {}) {
373
518
  });
374
519
  }
375
520
 
521
+ try {
522
+ const hooksPath = resolveHooksPath();
523
+ const envFilePath = resolveMekoEnvFilePath();
524
+ const changed = removeCodexMekoHooks(hooksPath, {
525
+ envFilePath,
526
+ hooksInstallRoot: resolveHooksInstallRoot(),
527
+ });
528
+ if (changed) {
529
+ results.push({
530
+ key: "hooks",
531
+ ok: true,
532
+ message: "Codex Meko hooks cleaned up.",
533
+ });
534
+ }
535
+ } catch (err) {
536
+ results.push({
537
+ key: "hooks",
538
+ ok: false,
539
+ message: `Could not clean Codex hooks: ${err.message}`,
540
+ });
541
+ }
542
+
376
543
  try {
377
544
  const agentsMdPath = resolveAgentsMdPath();
378
545
  const raw = safeReadFile(agentsMdPath);
@@ -434,6 +601,160 @@ export function createCodexAdapter(options = {}) {
434
601
  };
435
602
  }
436
603
 
604
+ // Codex's hooks.json schema only deserializes `hooks` and `state` — sibling
605
+ // keys like `env` are silently dropped, and `HookHandlerConfig::Command` has
606
+ // no per-command `env` field. The runner spawns each command via
607
+ // `$SHELL -lc <command>` with the parent env inherited.
608
+ //
609
+ // We sidestep that by writing a 0600 env file (default ~/.codex/meko-env.sh)
610
+ // that the hook command sources before invoking the script:
611
+ // `. /path/to/meko-env.sh && bash /path/to/hook.sh`
612
+ // The API key never appears in process arguments, so a `ps -ef` from another
613
+ // user (or a logging shim) can't lift it from the command line. Same trust
614
+ // boundary as `git credential-store`.
615
+ function upsertCodexMekoHooks(hooksPath, hooksRoot, { envFilePath } = {}) {
616
+ const cfg = readJsonFile(hooksPath);
617
+ const before = JSON.stringify(cfg);
618
+ if (!cfg.hooks || typeof cfg.hooks !== "object") {
619
+ cfg.hooks = {};
620
+ }
621
+
622
+ pruneCodexMekoHooksObject(cfg.hooks);
623
+ for (const hook of MEKO_HOOKS) {
624
+ const entry = {
625
+ type: "command",
626
+ command: buildCodexHookCommand(join(hooksRoot, hook.script), { envFilePath }),
627
+ };
628
+ if (hook.timeout) entry.timeout = hook.timeout;
629
+ upsertCodexHookEntry(cfg.hooks, hook.event, entry);
630
+ }
631
+
632
+ const after = JSON.stringify(cfg);
633
+ if (after !== before) {
634
+ writeJsonFile(hooksPath, cfg);
635
+ return true;
636
+ }
637
+ return false;
638
+ }
639
+
640
+ function buildCodexHookCommand(scriptPath, { envFilePath } = {}) {
641
+ const parts = [];
642
+ if (envFilePath) parts.push(`. ${shellQuote(envFilePath)}`);
643
+ parts.push(`bash ${shellQuote(scriptPath)}`);
644
+ return parts.join(" && ");
645
+ }
646
+
647
+ /**
648
+ * Write the Meko env file the Codex hooks source. Mode 0600 (user
649
+ * read/write only) so the API key isn't readable by other users.
650
+ * Idempotent: re-running with the same values is a no-op write that
651
+ * still asserts the mode.
652
+ */
653
+ function writeCodexMekoEnvFile(path, { url, apiKey }) {
654
+ mkdirSync(dirname(path), { recursive: true });
655
+ const lines = [
656
+ "# Auto-generated by @yugabytedb/meko-mcp installer.",
657
+ "# Sourced by Codex hooks (see ~/.codex/hooks.json) before each hook runs.",
658
+ "# Mode 0600 — do not commit, do not share, do not relax permissions.",
659
+ `export MEKO_MCP_URL=${shellQuote(url || "")}`,
660
+ `export MEKO_API_KEY=${shellQuote(apiKey || "")}`,
661
+ "",
662
+ ];
663
+ writeFileSync(path, lines.join("\n"), { encoding: "utf8", mode: 0o600 });
664
+ // writeFileSync's `mode` only applies on initial create; re-assert
665
+ // explicitly so a permission relaxation between installs is corrected.
666
+ chmodSync(path, 0o600);
667
+ }
668
+
669
+ function removeCodexMekoHooks(hooksPath, { envFilePath, hooksInstallRoot } = {}) {
670
+ const cfg = readJsonFile(hooksPath);
671
+ let changed = false;
672
+ if (cfg.hooks && typeof cfg.hooks === "object") {
673
+ const before = JSON.stringify(cfg);
674
+ pruneCodexMekoHooksObject(cfg.hooks);
675
+ if (Object.keys(cfg.hooks).length === 0) {
676
+ delete cfg.hooks;
677
+ }
678
+ // Older installs (PR 127 first cut) wrote a top-level cfg.env block that
679
+ // Codex silently ignored. Strip it during uninstall so we don't leave a
680
+ // misleading env hanging around in user configs.
681
+ if (cfg.env && typeof cfg.env === "object") {
682
+ delete cfg.env.MEKO_MCP_URL;
683
+ delete cfg.env.MEKO_API_KEY;
684
+ if (Object.keys(cfg.env).length === 0) {
685
+ delete cfg.env;
686
+ }
687
+ }
688
+ if (JSON.stringify(cfg) !== before) {
689
+ writeJsonFile(hooksPath, cfg);
690
+ changed = true;
691
+ }
692
+ }
693
+
694
+ // Remove the meko-env.sh file too — leaving a 0600 file with credentials
695
+ // around after uninstall is a security gap.
696
+ if (envFilePath) {
697
+ try {
698
+ rmSync(envFilePath, { force: true });
699
+ } catch {
700
+ // best-effort
701
+ }
702
+ }
703
+
704
+ // Remove the copied hook scripts (getCodexHooksInstallRoot). We remove the
705
+ // whole meko-hooks/ wrapper, not just the hooks-handlers leaf, so no empty
706
+ // parent is left behind. best-effort — never let cleanup fail uninstall.
707
+ if (hooksInstallRoot) {
708
+ try {
709
+ rmSync(dirname(hooksInstallRoot), { recursive: true, force: true });
710
+ } catch {
711
+ // best-effort
712
+ }
713
+ }
714
+ return changed;
715
+ }
716
+
717
+ function upsertCodexHookEntry(hooksObj, event, entry) {
718
+ if (!Array.isArray(hooksObj[event])) hooksObj[event] = [];
719
+ let group = hooksObj[event].find((item) => Array.isArray(item?.hooks));
720
+ if (!group) {
721
+ group = { hooks: [] };
722
+ hooksObj[event].push(group);
723
+ }
724
+ const exists = group.hooks.some((hook) => hook.command === entry.command);
725
+ if (!exists) group.hooks.push(entry);
726
+ }
727
+
728
+ function pruneCodexMekoHooksObject(hooksObj) {
729
+ for (const event of Object.keys(hooksObj)) {
730
+ const groups = hooksObj[event];
731
+ if (!Array.isArray(groups)) continue;
732
+ hooksObj[event] = groups
733
+ .map((group) => {
734
+ if (!Array.isArray(group?.hooks)) return group;
735
+ return {
736
+ ...group,
737
+ hooks: group.hooks.filter((hook) => !isMekoHookCommand(hook?.command)),
738
+ };
739
+ })
740
+ .filter((group) => !Array.isArray(group?.hooks) || group.hooks.length > 0);
741
+ if (hooksObj[event].length === 0) {
742
+ delete hooksObj[event];
743
+ }
744
+ }
745
+ }
746
+
747
+ function isMekoHookCommand(command) {
748
+ if (typeof command !== "string") return false;
749
+ return MEKO_HOOK_SCRIPTS.some((script) => {
750
+ const escaped = script.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
751
+ const pattern = new RegExp(
752
+ String.raw`(^|[/\\])hooks-handlers[/\\]${escaped}(?=$|['"\s])`,
753
+ );
754
+ return pattern.test(command);
755
+ });
756
+ }
757
+
437
758
  // ---------------------------------------------------------------------------
438
759
  // Probes (default impls; tests inject stubs)
439
760
  // ---------------------------------------------------------------------------
@@ -544,6 +865,200 @@ function buildCliRegisterCommand(name, url, bearerEnv) {
544
865
  return cmd;
545
866
  }
546
867
 
868
+ /**
869
+ * True for localhost / loopback MCP URLs (no cloud WAF in front). Parses the
870
+ * URL and compares the HOSTNAME exactly — a substring test would wrongly
871
+ * classify `https://localhost.example.com` (a real remote host) as local and
872
+ * skip the WAF User-Agent header. Unparseable input is treated as non-local
873
+ * (cloud), i.e. we err toward setting the header.
874
+ */
875
+ function isLocalUrl(url) {
876
+ let host;
877
+ try {
878
+ host = new URL(String(url ?? "")).hostname;
879
+ } catch {
880
+ return false;
881
+ }
882
+ // `new URL` strips the brackets from IPv6 literals, so `[::1]` → `::1`.
883
+ host = host.replace(/^\[|\]$/g, "").toLowerCase();
884
+ return (
885
+ host === "localhost" ||
886
+ host === "::1" ||
887
+ // Entire 127.0.0.0/8 block is loopback, not just 127.0.0.1.
888
+ /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)
889
+ );
890
+ }
891
+
892
+ const HTTP_HEADER_USER_AGENT_KEY = "User-Agent";
893
+
894
+ // Matches one `KEY = "VALUE"` (or single-quoted) pair. KEY may be
895
+ // double-quoted, single-quoted, or a bare TOML key (letters/digits/_/-).
896
+ // Used for both the inline `{ ... }` body and the table-form body.
897
+ const TOML_KV_PAIR =
898
+ /(?:"([^"]*)"|'([^']*)'|([A-Za-z0-9_-]+))\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
899
+
900
+ /** True if `map` has a `User-Agent` key, case-insensitively (HTTP names are). */
901
+ function hasUserAgentKey(map) {
902
+ return Object.keys(map).some((k) => k.toLowerCase() === "user-agent");
903
+ }
904
+
905
+ /** Parse `KEY = "VALUE"` pairs from a chunk of TOML into an ordered object. */
906
+ function parseTomlKvPairs(text) {
907
+ const out = {};
908
+ TOML_KV_PAIR.lastIndex = 0;
909
+ let m;
910
+ while ((m = TOML_KV_PAIR.exec(text)) !== null) {
911
+ const key = m[1] ?? m[2] ?? m[3];
912
+ const value = m[4] ?? m[5] ?? "";
913
+ if (key !== undefined) out[key] = value;
914
+ }
915
+ return out;
916
+ }
917
+
918
+ /**
919
+ * Locate a table-form `[mcp_servers.<name>.http_headers]` section, if present.
920
+ * `parseCodexConfigForServer`'s block ends at the first subtable, so this form
921
+ * lives OUTSIDE that block and needs its own lookup.
922
+ *
923
+ * @returns {{start: number, end: number, body: string}|null}
924
+ */
925
+ function findHttpHeadersSubtable(raw, name) {
926
+ const header = `[mcp_servers.${name}.http_headers]`;
927
+ const idx = findHeader(raw, header);
928
+ if (idx === -1) return null;
929
+ const end = findNextHeader(raw, idx + header.length);
930
+ return { start: idx, end, body: raw.slice(idx + header.length, end) };
931
+ }
932
+
933
+ /**
934
+ * Read the effective `http_headers` for a server from EITHER form (inline
935
+ * `http_headers = { ... }` inside the block, or a `[…​.http_headers]` subtable),
936
+ * returning an ordered key→value map, or null when neither is present. Used to
937
+ * snapshot a user's headers before a reinstall wipes them.
938
+ *
939
+ * @param {string|null} raw
940
+ * @param {string} name
941
+ * @returns {Record<string,string>|null}
942
+ */
943
+ function readCodexHttpHeaders(raw, name) {
944
+ if (raw === null || raw === undefined) return null;
945
+ const existing = parseCodexConfigForServer(raw, name);
946
+ if (!existing.found) return null;
947
+
948
+ const block = raw.slice(existing.start, existing.end);
949
+ const inline = block.match(/^[ \t]*http_headers[ \t]*=[ \t]*\{([^}]*)\}/m);
950
+ if (inline) return parseTomlKvPairs(inline[1]);
951
+
952
+ const subtable = findHttpHeadersSubtable(raw, name);
953
+ if (subtable) return parseTomlKvPairs(subtable.body);
954
+
955
+ return null;
956
+ }
957
+
958
+ /** Render a headers map as a single canonical inline `http_headers` line. */
959
+ function renderInlineHttpHeaders(map) {
960
+ const parts = Object.entries(map).map(
961
+ ([k, v]) => `"${escapeTomlString(k)}" = "${escapeTomlString(v)}"`,
962
+ );
963
+ return `http_headers = { ${parts.join(", ")} }`;
964
+ }
965
+
966
+ /**
967
+ * Ensure the `[mcp_servers.<name>]` block carries an `http_headers` User-Agent
968
+ * so Codex's native MCP client clears the Cloud WAF (see CODEX_MCP_USER_AGENT).
969
+ *
970
+ * This MERGES rather than all-or-nothing: it reads the effective headers from
971
+ * whichever form is present — inline `http_headers = { ... }` or a table-form
972
+ * `[mcp_servers.<name>.http_headers]` — plus any `preserved` map captured
973
+ * before a reinstall wiped the block, then guarantees a `User-Agent` entry
974
+ * exists while keeping every other header the user set. The result is written
975
+ * as a single canonical inline line, and any table-form section is removed so
976
+ * we never leave two conflicting `http_headers` definitions (invalid TOML).
977
+ *
978
+ * Contract — check, back up, then change:
979
+ * 1. Read config.toml (returns "absent" if the file or server block is gone).
980
+ * 2. Merge preserved + existing headers; if a User-Agent is already present
981
+ * AND no reshaping is needed (no table form to collapse), report "present"
982
+ * and write nothing.
983
+ * 3. Otherwise write the merged inline header via writeWithBackup (`.bak`).
984
+ *
985
+ * @param {string} configPath
986
+ * @param {string} name
987
+ * @param {{userAgent?: string, preserved?: Record<string,string>|null}} [opts]
988
+ * @returns {"added"|"restored"|"merged"|"present"|"absent"} what happened
989
+ */
990
+ function ensureCodexHttpHeaderUserAgent(
991
+ configPath,
992
+ name,
993
+ { userAgent, preserved = null } = {},
994
+ ) {
995
+ const ua = userAgent ?? CODEX_MCP_USER_AGENT;
996
+ const raw = safeReadFile(configPath);
997
+ if (raw === null) return "absent";
998
+ const existing = parseCodexConfigForServer(raw, name);
999
+ if (!existing.found) return "absent";
1000
+
1001
+ const block = raw.slice(existing.start, existing.end);
1002
+ const inlineMatch = block.match(
1003
+ /^[ \t]*http_headers[ \t]*=[ \t]*\{([^}]*)\}[ \t]*$/m,
1004
+ );
1005
+ const subtable = findHttpHeadersSubtable(raw, name);
1006
+
1007
+ // Merge order: existing on-disk headers first (inline or table), then any
1008
+ // reinstall-preserved map layered on top. Preserved wins on key collisions
1009
+ // because it reflects the user's most recent intent before the wipe.
1010
+ const onDisk = inlineMatch
1011
+ ? parseTomlKvPairs(inlineMatch[1])
1012
+ : subtable
1013
+ ? parseTomlKvPairs(subtable.body)
1014
+ : {};
1015
+ const merged = { ...onDisk, ...(preserved ?? {}) };
1016
+
1017
+ const hadUserAgent = hasUserAgentKey(merged);
1018
+ const restoring = Boolean(preserved);
1019
+
1020
+ // Nothing to do only when a User-Agent is already present AND the on-disk
1021
+ // form is a single inline line (nothing to collapse or restore).
1022
+ if (hadUserAgent && inlineMatch && !subtable && !restoring) return "present";
1023
+
1024
+ if (!hadUserAgent) merged[HTTP_HEADER_USER_AGENT_KEY] = ua;
1025
+
1026
+ const line = renderInlineHttpHeaders(merged);
1027
+
1028
+ // Build the new server block: drop any existing inline line, then insert the
1029
+ // canonical inline line right under the table header.
1030
+ let newBlock = block;
1031
+ if (inlineMatch) {
1032
+ newBlock = newBlock.replace(
1033
+ /^[ \t]*http_headers[ \t]*=[ \t]*\{[^}]*\}[ \t]*\n?/m,
1034
+ "",
1035
+ );
1036
+ }
1037
+ const headerLine = `[mcp_servers.${name}]`;
1038
+ const headerIdx = newBlock.indexOf(headerLine);
1039
+ const insertAt = headerIdx + headerLine.length;
1040
+ newBlock =
1041
+ newBlock.slice(0, insertAt) + `\n${line}` + newBlock.slice(insertAt);
1042
+
1043
+ // Reassemble the file, splicing the block back and excising any table-form
1044
+ // subtable (now folded into the inline line) to avoid a duplicate key.
1045
+ let next = raw.slice(0, existing.start) + newBlock + raw.slice(existing.end);
1046
+ if (subtable) {
1047
+ // Recompute the subtable bounds against the rewritten string — the block
1048
+ // edit shifted offsets. The subtable always sits after our block.
1049
+ const fresh = findHttpHeadersSubtable(next, name);
1050
+ if (fresh) {
1051
+ next = next.slice(0, fresh.start) + next.slice(fresh.end);
1052
+ next = next.replace(/\n{3,}/g, "\n\n");
1053
+ }
1054
+ }
1055
+
1056
+ writeWithBackup(configPath, next);
1057
+ if (restoring) return "restored";
1058
+ if (subtable || (inlineMatch && !hadUserAgent)) return "merged";
1059
+ return "added";
1060
+ }
1061
+
547
1062
  // ---------------------------------------------------------------------------
548
1063
  // TOML helpers (App-only fallback path)
549
1064
  // ---------------------------------------------------------------------------
@@ -915,12 +1430,24 @@ export async function maybeOfferLaunchctlSetenv({
915
1430
  false,
916
1431
  );
917
1432
  if (!accept) return { set: false };
1433
+ return setLaunchctlMekoApiKey({ apiKey, runCommand, log });
1434
+ }
1435
+
1436
+ /**
1437
+ * Run `launchctl setenv MEKO_API_KEY <key>` so the macOS Codex.app launched
1438
+ * from Finder/Dock can authenticate during the current login session.
1439
+ *
1440
+ * The key is passed to launchctl but never logged.
1441
+ *
1442
+ * @returns {{set: boolean}}
1443
+ */
1444
+ export function setLaunchctlMekoApiKey({ apiKey, runCommand, log }) {
918
1445
  try {
919
1446
  runCommand(`launchctl setenv MEKO_API_KEY ${shellQuote(apiKey)}`);
920
1447
  log?.dim?.(" Set MEKO_API_KEY via launchctl (current login session).");
921
1448
  return { set: true };
922
1449
  } catch (err) {
923
- log?.warn?.(` launchctl setenv failed: ${err.message}`);
1450
+ log?.warn?.(" launchctl setenv failed; run it manually if Codex.app needs cloud auth.");
924
1451
  return { set: false };
925
1452
  }
926
1453
  }
@@ -974,4 +1501,10 @@ export const __TEST__ = {
974
1501
  AGENTS_MD_END,
975
1502
  defaultDetectShellRc,
976
1503
  buildCliRegisterCommand,
1504
+ ensureCodexHttpHeaderUserAgent,
1505
+ readCodexHttpHeaders,
1506
+ findHttpHeadersSubtable,
1507
+ parseTomlKvPairs,
1508
+ isLocalUrl,
1509
+ CODEX_MCP_USER_AGENT,
977
1510
  };