@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.
- package/README.md +81 -23
- package/assets/hooks-handlers/before-submit-prompt.sh +44 -0
- package/assets/hooks-handlers/cursor-session-start.sh +42 -0
- package/assets/hooks-handlers/lib/capture.js +404 -88
- package/assets/hooks-handlers/lib/checkpoint-timer.js +23 -11
- package/assets/hooks-handlers/session-start.sh +6 -2
- package/assets/scripts/meko-statusline.sh +119 -0
- package/assets/scripts/package-desktop-skill.sh +39 -0
- package/assets/skills/meko-mcp-tools/SKILL.md +42 -50
- package/assets/skills/meko-mcp-tools/references/_sections.md +1 -1
- package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +75 -150
- package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +30 -36
- package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +6 -22
- package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +9 -8
- package/assets/skills/meko-mcp-tools/references/tools-overview.md +35 -42
- package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +28 -92
- package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +5 -9
- package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +1 -81
- package/assets/skills/meko-mcp-tools-desktop/SKILL.md +244 -0
- package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +180 -0
- package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +24 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +99 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +327 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +63 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +214 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +105 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +70 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-scope-permissions.md +71 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +75 -0
- package/assets/skills/meko-select-datapack-desktop/SKILL.md +198 -0
- package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +121 -0
- package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +66 -0
- package/bin/create-meko-setup.mjs +33 -4
- package/lib/clients/claude-code.mjs +133 -6
- package/lib/clients/claude-desktop.mjs +48 -20
- package/lib/clients/codex.mjs +538 -5
- package/lib/clients/common.mjs +122 -1
- package/lib/clients/cursor.mjs +120 -27
- package/lib/config.mjs +52 -0
- package/lib/installer.mjs +26 -2
- package/lib/mcp-transport.mjs +5 -0
- package/lib/validate.mjs +0 -1
- package/package.json +1 -1
- package/scripts/bundle-assets.mjs +16 -0
- package/assets/skills/meko-mcp-tools/references/tools-db-query-patterns.md +0 -38
- package/assets/skills/meko-mcp-tools/references/tools-write-guardrails.md +0 -30
package/lib/clients/codex.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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?.(
|
|
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
|
};
|