@yugabytedb/meko-mcp 1.2.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 (44) hide show
  1. package/README.md +28 -19
  2. package/assets/hooks-handlers/before-submit-prompt.sh +2 -2
  3. package/assets/hooks-handlers/lib/capture.js +209 -144
  4. package/assets/hooks-handlers/lib/checkpoint-timer.js +23 -11
  5. package/assets/hooks-handlers/session-start.sh +6 -2
  6. package/assets/scripts/meko-statusline.sh +119 -0
  7. package/assets/scripts/package-desktop-skill.sh +27 -24
  8. package/assets/skills/meko-mcp-tools/SKILL.md +42 -50
  9. package/assets/skills/meko-mcp-tools/references/_sections.md +1 -1
  10. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +75 -150
  11. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +30 -36
  12. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +6 -22
  13. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +9 -8
  14. package/assets/skills/meko-mcp-tools/references/tools-overview.md +35 -42
  15. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +28 -92
  16. package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +5 -9
  17. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +1 -81
  18. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +100 -107
  19. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +41 -11
  20. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
  21. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +71 -150
  22. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +12 -18
  23. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +12 -15
  24. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +35 -42
  25. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +28 -92
  26. package/assets/skills/meko-mcp-tools-desktop/references/tools-scope-permissions.md +5 -9
  27. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +1 -81
  28. package/assets/skills/meko-select-datapack-desktop/SKILL.md +198 -0
  29. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +121 -0
  30. package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +66 -0
  31. package/bin/create-meko-setup.mjs +29 -0
  32. package/lib/clients/claude-code.mjs +133 -6
  33. package/lib/clients/claude-desktop.mjs +37 -12
  34. package/lib/clients/codex.mjs +306 -5
  35. package/lib/clients/common.mjs +41 -1
  36. package/lib/clients/cursor.mjs +75 -19
  37. package/lib/config.mjs +26 -0
  38. package/lib/installer.mjs +24 -0
  39. package/lib/mcp-transport.mjs +5 -0
  40. package/lib/validate.mjs +0 -1
  41. package/package.json +1 -1
  42. package/scripts/bundle-assets.mjs +8 -0
  43. package/assets/skills/meko-mcp-tools/references/tools-db-query-patterns.md +0 -38
  44. package/assets/skills/meko-mcp-tools/references/tools-write-guardrails.md +0 -30
@@ -28,6 +28,7 @@ import { askYesNo } from "../prompt.mjs";
28
28
  import {
29
29
  getCodexAgentsMdPath,
30
30
  getCodexConfigPath,
31
+ getCodexHooksInstallRoot,
31
32
  getCodexHooksPath,
32
33
  getCodexMekoEnvFilePath,
33
34
  getCodexSkillsRoot,
@@ -36,6 +37,8 @@ import {
36
37
  } from "../config.mjs";
37
38
  import {
38
39
  getInstallerVersion,
40
+ installStableTree,
41
+ isPackagedInstall,
39
42
  isDanglingSymlink,
40
43
  markerIsStale,
41
44
  readInstallerMarker,
@@ -50,6 +53,16 @@ import {
50
53
  const CODEX_INSTALL_URL = "https://github.com/openai/codex";
51
54
  const CODEX_APP_INSTALL_URL = "https://openai.com/index/codex";
52
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
+
53
66
  // AGENTS.md sentinel markers. Idempotent upsert/strip relies on these being
54
67
  // stable across versions — never rename without a migration story.
55
68
  const AGENTS_MD_START = "<!-- meko:start -->";
@@ -80,6 +93,7 @@ const MEKO_HOOK_SCRIPTS = MEKO_HOOKS.map((hook) => hook.script);
80
93
  * getHooksPath?: () => string,
81
94
  * getSkillsRoot?: () => string,
82
95
  * resolveHooksRoot?: () => string,
96
+ * getHooksInstallRoot?: () => string,
83
97
  * runCommand?: (cmd: string) => string,
84
98
  * probeCodex?: () => {found: boolean, version: string, path: string},
85
99
  * probeCodexApp?: () => {found: boolean, path: string, platform: string, version: string} | null,
@@ -95,7 +109,10 @@ export function createCodexAdapter(options = {}) {
95
109
  const resolveMekoEnvFilePath =
96
110
  options.getMekoEnvFilePath ?? getCodexMekoEnvFilePath;
97
111
  const resolveHooksRootPath = options.resolveHooksRoot ?? resolveHooksRoot;
112
+ const resolveHooksInstallRoot =
113
+ options.getHooksInstallRoot ?? getCodexHooksInstallRoot;
98
114
  const resolveSkillsRoot = options.getSkillsRoot ?? getCodexSkillsRoot;
115
+ const packagedInstallDetector = options.isPackagedInstall ?? isPackagedInstall;
99
116
  const runCommand = options.runCommand ?? defaultRunCommand;
100
117
  const probeCodex = options.probeCodex ?? defaultProbeCodex;
101
118
  const probeCodexApp = options.probeCodexApp ?? defaultProbeCodexApp;
@@ -107,6 +124,16 @@ export function createCodexAdapter(options = {}) {
107
124
 
108
125
  let scopeWarned = false;
109
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
+
110
137
  /** Returns the resolved config.toml path; warns once on project scope. */
111
138
  function settingsPathFor(scope, log) {
112
139
  if (scope === "project" && !scopeWarned) {
@@ -189,6 +216,11 @@ export function createCodexAdapter(options = {}) {
189
216
  },
190
217
  removeRegistration(name, { scope, log } = {}) {
191
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);
192
224
  if (surfaces.cli) {
193
225
  try {
194
226
  runCommand(`codex mcp remove ${shellQuote(name)}`);
@@ -209,6 +241,8 @@ export function createCodexAdapter(options = {}) {
209
241
  const useApiKey = apiKey ? "MEKO_API_KEY" : null;
210
242
  const cliCmd = buildCliRegisterCommand(name, url, useApiKey);
211
243
 
244
+ const cloud = !isLocalUrl(url);
245
+
212
246
  if (dryRun) {
213
247
  if (surfaces.cli) {
214
248
  log.dim?.(` Would run: ${cliCmd}`);
@@ -220,6 +254,11 @@ export function createCodexAdapter(options = {}) {
220
254
  " Codex resolves the bearer token from $MEKO_API_KEY at runtime; the key is not written to config.toml.",
221
255
  );
222
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
+ }
223
262
  return;
224
263
  }
225
264
 
@@ -257,6 +296,42 @@ export function createCodexAdapter(options = {}) {
257
296
  if (verbose) log.dim?.(` Wrote [mcp_servers.${name}] to ${path}`);
258
297
  }
259
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
+
260
335
  // Cloud auth UX: env-var-only model. The key is never written to
261
336
  // config.toml; the user's runtime env supplies it. Print actionable
262
337
  // instructions and (when interactive) offer to wire things up.
@@ -300,9 +375,11 @@ export function createCodexAdapter(options = {}) {
300
375
  },
301
376
  setHookEnvironment({ url, apiKey, log, dryRun, verbose }) {
302
377
  const hooksPath = resolveHooksPath();
303
- const hooksRoot = resolveHooksRootPath();
378
+ const assetHooksRoot = resolveHooksRootPath();
379
+ const installedHooksRoot = resolveHooksInstallRoot();
304
380
  const envFilePath = resolveMekoEnvFilePath();
305
381
  if (dryRun) {
382
+ log?.dim?.(` Would copy Codex hook scripts to ${installedHooksRoot}`);
306
383
  log?.dim?.(` Would write env file at ${envFilePath} (mode 0600)`);
307
384
  log?.dim?.(` Would configure Codex hooks at ${hooksPath}`);
308
385
  if (apiKey) {
@@ -313,8 +390,16 @@ export function createCodexAdapter(options = {}) {
313
390
  return;
314
391
  }
315
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);
316
399
  writeCodexMekoEnvFile(envFilePath, { url, apiKey });
317
- const changed = upsertCodexMekoHooks(hooksPath, hooksRoot, { envFilePath });
400
+ const changed = upsertCodexMekoHooks(hooksPath, installedHooksRoot, {
401
+ envFilePath,
402
+ });
318
403
  if (verbose || changed) {
319
404
  log?.dim?.(` Updated Codex hooks at ${hooksPath}`);
320
405
  }
@@ -374,7 +459,9 @@ export function createCodexAdapter(options = {}) {
374
459
  removePath(destination);
375
460
  }
376
461
 
377
- const strategy = symlinkOrCopy(source, destination);
462
+ const strategy = symlinkOrCopy(source, destination, {
463
+ preferCopy: packagedInstallDetector(),
464
+ });
378
465
  if (strategy === "copy") writeInstallerMarker(destination);
379
466
  }
380
467
 
@@ -434,7 +521,10 @@ export function createCodexAdapter(options = {}) {
434
521
  try {
435
522
  const hooksPath = resolveHooksPath();
436
523
  const envFilePath = resolveMekoEnvFilePath();
437
- const changed = removeCodexMekoHooks(hooksPath, { envFilePath });
524
+ const changed = removeCodexMekoHooks(hooksPath, {
525
+ envFilePath,
526
+ hooksInstallRoot: resolveHooksInstallRoot(),
527
+ });
438
528
  if (changed) {
439
529
  results.push({
440
530
  key: "hooks",
@@ -576,7 +666,7 @@ function writeCodexMekoEnvFile(path, { url, apiKey }) {
576
666
  chmodSync(path, 0o600);
577
667
  }
578
668
 
579
- function removeCodexMekoHooks(hooksPath, { envFilePath } = {}) {
669
+ function removeCodexMekoHooks(hooksPath, { envFilePath, hooksInstallRoot } = {}) {
580
670
  const cfg = readJsonFile(hooksPath);
581
671
  let changed = false;
582
672
  if (cfg.hooks && typeof cfg.hooks === "object") {
@@ -610,6 +700,17 @@ function removeCodexMekoHooks(hooksPath, { envFilePath } = {}) {
610
700
  // best-effort
611
701
  }
612
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
+ }
613
714
  return changed;
614
715
  }
615
716
 
@@ -764,6 +865,200 @@ function buildCliRegisterCommand(name, url, bearerEnv) {
764
865
  return cmd;
765
866
  }
766
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
+
767
1062
  // ---------------------------------------------------------------------------
768
1063
  // TOML helpers (App-only fallback path)
769
1064
  // ---------------------------------------------------------------------------
@@ -1206,4 +1501,10 @@ export const __TEST__ = {
1206
1501
  AGENTS_MD_END,
1207
1502
  defaultDetectShellRc,
1208
1503
  buildCliRegisterCommand,
1504
+ ensureCodexHttpHeaderUserAgent,
1505
+ readCodexHttpHeaders,
1506
+ findHttpHeadersSubtable,
1507
+ parseTomlKvPairs,
1508
+ isLocalUrl,
1509
+ CODEX_MCP_USER_AGENT,
1209
1510
  };
@@ -9,11 +9,14 @@ import {
9
9
  unlinkSync,
10
10
  rmSync,
11
11
  readFileSync,
12
+ renameSync,
12
13
  writeFileSync,
13
14
  } from "node:fs";
14
15
  import { dirname, join, resolve } from "node:path";
15
16
  import { fileURLToPath } from "node:url";
16
17
 
18
+ export const MEKO_MCP_USER_AGENT = "meko-mcp-installer/1.0";
19
+
17
20
  /**
18
21
  * @returns {string} Absolute repository root path.
19
22
  */
@@ -83,6 +86,16 @@ export function resolveHooksRoot() {
83
86
  return join(getAssetRoot(), "hooks-handlers");
84
87
  }
85
88
 
89
+ /**
90
+ * @returns {string} absolute path to the statusline script source.
91
+ * Lives under skills/scripts/ in source, mirrored under assets/scripts/ in
92
+ * the npm-packed install — both roots are siblings of skills/hooks-handlers/
93
+ * and skills/skills/.
94
+ */
95
+ export function resolveStatuslineSource() {
96
+ return join(getAssetRoot(), "scripts", "meko-statusline.sh");
97
+ }
98
+
86
99
  /**
87
100
  * Run a shell command and return stdout.
88
101
  * @param {string} cmd
@@ -192,11 +205,15 @@ function unlinkAllowingMissing(targetPath) {
192
205
  * @param {string} destination
193
206
  * @returns {"symlink"|"copy"}
194
207
  */
195
- export function symlinkOrCopy(source, destination) {
208
+ export function symlinkOrCopy(source, destination, { preferCopy = false } = {}) {
196
209
  mkdirSync(dirname(destination), { recursive: true });
197
210
  if (isDanglingSymlink(destination)) {
198
211
  unlinkAllowingMissing(destination);
199
212
  }
213
+ if (preferCopy) {
214
+ cpSync(source, destination, { recursive: true });
215
+ return "copy";
216
+ }
200
217
  try {
201
218
  symlinkSync(source, destination, "junction");
202
219
  return "symlink";
@@ -206,6 +223,29 @@ export function symlinkOrCopy(source, destination) {
206
223
  }
207
224
  }
208
225
 
226
+ /** Atomically refresh a runtime-owned directory at a stable path. */
227
+ export function installStableTree(source, destination) {
228
+ if (resolve(source) === resolve(destination)) return;
229
+ const suffix = `${process.pid}-${Date.now()}`;
230
+ const staging = `${destination}.tmp-${suffix}`;
231
+ const backup = `${destination}.bak-${suffix}`;
232
+
233
+ mkdirSync(dirname(destination), { recursive: true });
234
+ try {
235
+ cpSync(source, staging, { recursive: true });
236
+ if (existsSync(destination)) renameSync(destination, backup);
237
+ try {
238
+ renameSync(staging, destination);
239
+ } catch (err) {
240
+ if (existsSync(backup)) renameSync(backup, destination);
241
+ throw err;
242
+ }
243
+ rmSync(backup, { recursive: true, force: true });
244
+ } finally {
245
+ rmSync(staging, { recursive: true, force: true });
246
+ }
247
+ }
248
+
209
249
  /**
210
250
  * Remove path if it is a symlink or directory/file.
211
251
  * @param {string} targetPath