@yugabytedb/meko-mcp 1.0.8 → 1.2.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 (51) hide show
  1. package/README.md +242 -44
  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 +457 -107
  5. package/assets/hooks-handlers/lib/package.json +1 -0
  6. package/assets/hooks-handlers/test_single_conversation.sh +1 -1
  7. package/assets/scripts/package-desktop-skill.sh +36 -0
  8. package/assets/skills/meko-mcp-tools/SKILL.md +167 -53
  9. package/assets/skills/meko-mcp-tools/references/_sections.md +1 -1
  10. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +84 -22
  11. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +89 -56
  12. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +15 -7
  13. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +17 -9
  14. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +94 -5
  15. package/assets/skills/meko-mcp-tools/references/tools-overview.md +52 -37
  16. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +108 -37
  17. package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +6 -10
  18. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +30 -18
  19. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +55 -69
  20. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +251 -0
  21. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +150 -0
  22. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +24 -0
  23. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +99 -0
  24. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +406 -0
  25. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +69 -0
  26. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +217 -0
  27. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +112 -0
  28. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +134 -0
  29. package/assets/skills/meko-mcp-tools-desktop/references/tools-scope-permissions.md +75 -0
  30. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +155 -0
  31. package/bin/create-meko-setup.mjs +151 -22
  32. package/lib/clients/claude-code.mjs +378 -68
  33. package/lib/clients/claude-desktop.mjs +45 -8
  34. package/lib/clients/codex.mjs +1209 -0
  35. package/lib/clients/common.mjs +223 -1
  36. package/lib/clients/cursor.mjs +118 -13
  37. package/lib/clients/index.mjs +2 -0
  38. package/lib/config.mjs +56 -0
  39. package/lib/detect.mjs +52 -44
  40. package/lib/installer.mjs +11 -4
  41. package/lib/mcp-transport.mjs +61 -2
  42. package/lib/migrate/adapters/memory-md.mjs +13 -6
  43. package/lib/migrate/datapack.mjs +167 -0
  44. package/lib/migrate/id.mjs +127 -9
  45. package/lib/migrate/orchestrator.mjs +68 -10
  46. package/lib/migrate.mjs +346 -3
  47. package/lib/prompt.mjs +22 -4
  48. package/lib/validate.mjs +313 -9
  49. package/lib/version-check.mjs +178 -0
  50. package/package.json +2 -1
  51. package/scripts/bundle-assets.mjs +8 -0
@@ -0,0 +1,1209 @@
1
+ /**
2
+ * codex.mjs - OpenAI Codex (CLI + Desktop App) adapter for the Meko MCP installer.
3
+ *
4
+ * The CLI and the Desktop App share the same on-disk state — `~/.codex/config.toml`,
5
+ * `~/.codex/AGENTS.md`, `~/.agents/skills/` — so a single adapter handles both
6
+ * surfaces. We prefer `codex mcp add` when the CLI is available (forward-compatible
7
+ * with future schema fields) and fall back to direct TOML editing when only the
8
+ * App is installed.
9
+ */
10
+
11
+ import { execSync } from "node:child_process";
12
+ import {
13
+ appendFileSync,
14
+ chmodSync,
15
+ copyFileSync,
16
+ existsSync,
17
+ lstatSync,
18
+ mkdirSync,
19
+ readFileSync,
20
+ readlinkSync,
21
+ rmSync,
22
+ writeFileSync,
23
+ } from "node:fs";
24
+ import { homedir } from "node:os";
25
+ import { dirname, join, resolve } from "node:path";
26
+
27
+ import { askYesNo } from "../prompt.mjs";
28
+ import {
29
+ getCodexAgentsMdPath,
30
+ getCodexConfigPath,
31
+ getCodexHooksPath,
32
+ getCodexMekoEnvFilePath,
33
+ getCodexSkillsRoot,
34
+ readJsonFile,
35
+ writeJsonFile,
36
+ } from "../config.mjs";
37
+ import {
38
+ getInstallerVersion,
39
+ isDanglingSymlink,
40
+ markerIsStale,
41
+ readInstallerMarker,
42
+ removePath,
43
+ resolveHooksRoot,
44
+ resolveSkillSource,
45
+ shellQuote,
46
+ symlinkOrCopy,
47
+ writeInstallerMarker,
48
+ } from "./common.mjs";
49
+
50
+ const CODEX_INSTALL_URL = "https://github.com/openai/codex";
51
+ const CODEX_APP_INSTALL_URL = "https://openai.com/index/codex";
52
+
53
+ // AGENTS.md sentinel markers. Idempotent upsert/strip relies on these being
54
+ // stable across versions — never rename without a migration story.
55
+ const AGENTS_MD_START = "<!-- meko:start -->";
56
+ const AGENTS_MD_END = "<!-- meko:end -->";
57
+
58
+ // Shell rc fence used by maybeOfferRcAppend. Mirrors well-known conventions
59
+ // (homebrew, asdf, etc.) so a user grepping their rc can see at a glance
60
+ // which tool added the block.
61
+ const RC_FENCE_START = "# >>> meko mcp >>>";
62
+ const RC_FENCE_END = "# <<< meko mcp <<<";
63
+
64
+ const SKILLS = [{ name: "meko-mcp-tools" }];
65
+ // Codex hook timeouts are in SECONDS (Duration::from_secs in
66
+ // codex-rs/hooks/src/engine/command_runner.rs), not milliseconds like Claude
67
+ // Code / Cursor. Default is 600s if omitted; we cap SessionStart/SessionEnd
68
+ // at 15s/30s to match the Claude Code values logically.
69
+ const MEKO_HOOKS = [
70
+ { event: "SessionStart", script: "session-start.sh", timeout: 15 },
71
+ { event: "PreCompact", script: "pre-compact.sh" },
72
+ { event: "SessionEnd", script: "session-end.sh", timeout: 30 },
73
+ ];
74
+ const MEKO_HOOK_SCRIPTS = MEKO_HOOKS.map((hook) => hook.script);
75
+
76
+ /**
77
+ * @param {{
78
+ * getConfigPath?: () => string,
79
+ * getAgentsMdPath?: () => string,
80
+ * getHooksPath?: () => string,
81
+ * getSkillsRoot?: () => string,
82
+ * resolveHooksRoot?: () => string,
83
+ * runCommand?: (cmd: string) => string,
84
+ * probeCodex?: () => {found: boolean, version: string, path: string},
85
+ * probeCodexApp?: () => {found: boolean, path: string, platform: string, version: string} | null,
86
+ * detectShellRc?: () => string | null,
87
+ * promptUser?: (question: string, defaultYes?: boolean) => Promise<boolean>,
88
+ * }} [options]
89
+ * @returns {object}
90
+ */
91
+ export function createCodexAdapter(options = {}) {
92
+ const resolveConfigPath = options.getConfigPath ?? getCodexConfigPath;
93
+ const resolveAgentsMdPath = options.getAgentsMdPath ?? getCodexAgentsMdPath;
94
+ const resolveHooksPath = options.getHooksPath ?? getCodexHooksPath;
95
+ const resolveMekoEnvFilePath =
96
+ options.getMekoEnvFilePath ?? getCodexMekoEnvFilePath;
97
+ const resolveHooksRootPath = options.resolveHooksRoot ?? resolveHooksRoot;
98
+ const resolveSkillsRoot = options.getSkillsRoot ?? getCodexSkillsRoot;
99
+ const runCommand = options.runCommand ?? defaultRunCommand;
100
+ const probeCodex = options.probeCodex ?? defaultProbeCodex;
101
+ const probeCodexApp = options.probeCodexApp ?? defaultProbeCodexApp;
102
+ const detectShellRc = options.detectShellRc ?? defaultDetectShellRc;
103
+ const promptUser = options.promptUser ?? askYesNo;
104
+
105
+ /** @type {{cli: object|null, app: object|null}} */
106
+ const surfaces = { cli: null, app: null };
107
+
108
+ let scopeWarned = false;
109
+
110
+ /** Returns the resolved config.toml path; warns once on project scope. */
111
+ function settingsPathFor(scope, log) {
112
+ if (scope === "project" && !scopeWarned) {
113
+ scopeWarned = true;
114
+ log?.warn?.(
115
+ "Codex has no project-scope config; installing into the user-level ~/.codex/config.toml.",
116
+ );
117
+ }
118
+ return resolveConfigPath();
119
+ }
120
+
121
+ return {
122
+ id: "codex",
123
+ displayName: "OpenAI Codex (CLI + App)",
124
+ settingsPath() {
125
+ // The scope arg is intentionally ignored: Codex has no project scope.
126
+ // The first call site with a logger (registerServer / installSkills)
127
+ // emits the degrade warning.
128
+ return resolveConfigPath();
129
+ },
130
+ async detectPrerequisites({ verbose, log } = { verbose: false, log: console }) {
131
+ const cli = probeCodex();
132
+ const app = probeCodexApp();
133
+ surfaces.cli = cli?.found ? cli : null;
134
+ surfaces.app = app?.found ? app : null;
135
+ if (!surfaces.cli && !surfaces.app) {
136
+ throw new Error(
137
+ `OpenAI Codex not found. Install the CLI (${CODEX_INSTALL_URL}) ` +
138
+ `or the desktop app (${CODEX_APP_INSTALL_URL}).`,
139
+ );
140
+ }
141
+ if (verbose) {
142
+ if (surfaces.cli) {
143
+ log.dim?.(` Codex CLI: ${surfaces.cli.path} (${surfaces.cli.version})`);
144
+ }
145
+ if (surfaces.app) {
146
+ log.dim?.(
147
+ ` Codex App: ${surfaces.app.path} (${surfaces.app.platform}${
148
+ surfaces.app.version ? `, ${surfaces.app.version}` : ""
149
+ })`,
150
+ );
151
+ }
152
+ }
153
+ },
154
+ isInstalled() {
155
+ // Cheap check used by the picker — full prereq probing happens in
156
+ // detectPrerequisites. We re-probe here because the picker runs before
157
+ // detectPrerequisites and we want it to reflect reality.
158
+ const cli = probeCodex();
159
+ const app = probeCodexApp();
160
+ return Boolean((cli && cli.found) || (app && app.found));
161
+ },
162
+ detectExisting(name, { scope } = {}) {
163
+ // Force a probe if we haven't yet — picker may not have called
164
+ // detectPrerequisites.
165
+ if (surfaces.cli === null && surfaces.app === null) {
166
+ const cli = probeCodex();
167
+ const app = probeCodexApp();
168
+ surfaces.cli = cli?.found ? cli : null;
169
+ surfaces.app = app?.found ? app : null;
170
+ }
171
+
172
+ if (surfaces.cli) {
173
+ try {
174
+ runCommand(`codex mcp get ${shellQuote(name)}`);
175
+ return { found: true, status: "Configured" };
176
+ } catch {
177
+ // fall through to TOML grep — `codex mcp get` exits non-zero when
178
+ // the entry isn't present, but might also fail for unrelated
179
+ // reasons (very old CLI, syntax error in config.toml). The TOML
180
+ // grep is authoritative.
181
+ }
182
+ }
183
+ const path = settingsPathFor(scope, null);
184
+ const raw = safeReadFile(path);
185
+ if (raw === null) return { found: false, status: "" };
186
+ return parseCodexConfigForServer(raw, name).found
187
+ ? { found: true, status: "Configured" }
188
+ : { found: false, status: "" };
189
+ },
190
+ removeRegistration(name, { scope, log } = {}) {
191
+ const path = settingsPathFor(scope, log);
192
+ if (surfaces.cli) {
193
+ try {
194
+ runCommand(`codex mcp remove ${shellQuote(name)}`);
195
+ return;
196
+ } catch {
197
+ // CLI doesn't know `mcp remove`, or the entry wasn't present.
198
+ // Fall through to direct TOML edit — idempotent on a missing
199
+ // block.
200
+ }
201
+ }
202
+ const raw = safeReadFile(path);
203
+ if (raw === null) return;
204
+ const next = removeCodexConfigBlock(raw, name);
205
+ if (next !== raw) writeWithBackup(path, next);
206
+ },
207
+ async registerServer({ name, url, apiKey, dryRun, verbose, log, scope, yes }) {
208
+ const path = settingsPathFor(scope, log);
209
+ const useApiKey = apiKey ? "MEKO_API_KEY" : null;
210
+ const cliCmd = buildCliRegisterCommand(name, url, useApiKey);
211
+
212
+ if (dryRun) {
213
+ if (surfaces.cli) {
214
+ log.dim?.(` Would run: ${cliCmd}`);
215
+ } else {
216
+ log.dim?.(` Would upsert [mcp_servers.${name}] in ${path}`);
217
+ }
218
+ if (apiKey) {
219
+ log.dim?.(
220
+ " Codex resolves the bearer token from $MEKO_API_KEY at runtime; the key is not written to config.toml.",
221
+ );
222
+ }
223
+ return;
224
+ }
225
+
226
+ let usedCliPath = false;
227
+ if (surfaces.cli) {
228
+ try {
229
+ const output = runCommand(cliCmd);
230
+ if (verbose && output) log.dim?.(` ${output.trim()}`);
231
+ usedCliPath = true;
232
+ } catch (err) {
233
+ const msg = (err && (err.stderr || err.message)) || "";
234
+ const lower = msg.toLowerCase();
235
+ // Old CLI (no `mcp add` subcommand) — fall through to TOML write.
236
+ if (
237
+ lower.includes("unknown command") ||
238
+ lower.includes("unrecognized") ||
239
+ lower.includes("invalid command")
240
+ ) {
241
+ log.warn?.(
242
+ " codex mcp add not supported by this CLI version — writing config.toml directly.",
243
+ );
244
+ } else {
245
+ throw err;
246
+ }
247
+ }
248
+ }
249
+
250
+ if (!usedCliPath) {
251
+ const raw = safeReadFile(path) ?? "";
252
+ const next = upsertCodexConfigBlock(raw, name, {
253
+ url,
254
+ bearer_token_env_var: useApiKey,
255
+ });
256
+ writeWithBackup(path, next);
257
+ if (verbose) log.dim?.(` Wrote [mcp_servers.${name}] to ${path}`);
258
+ }
259
+
260
+ // Cloud auth UX: env-var-only model. The key is never written to
261
+ // config.toml; the user's runtime env supplies it. Print actionable
262
+ // instructions and (when interactive) offer to wire things up.
263
+ if (apiKey) {
264
+ const platform = surfaces.app?.platform ?? process.platform;
265
+ const hasApp = Boolean(surfaces.app);
266
+ const shellRc = detectShellRc();
267
+ printCloudAuthInstructions({ shellRc, hasApp, platform, log });
268
+ if (!yes) {
269
+ await maybeOfferRcAppend({
270
+ apiKey,
271
+ shellRc,
272
+ promptUser,
273
+ log,
274
+ });
275
+ if (hasApp && platform === "darwin") {
276
+ await maybeOfferLaunchctlSetenv({
277
+ apiKey,
278
+ promptUser,
279
+ runCommand,
280
+ log,
281
+ });
282
+ }
283
+ } else if (hasApp && platform === "darwin") {
284
+ setLaunchctlMekoApiKey({
285
+ apiKey,
286
+ runCommand,
287
+ log,
288
+ });
289
+ }
290
+ }
291
+
292
+ if (surfaces.app) {
293
+ log.dim?.(" Restart Codex.app to attach the new MCP server.");
294
+ }
295
+ },
296
+ async maybeConfirmOverwrite({ existingFound, name, yes }) {
297
+ if (!existingFound) return true;
298
+ if (yes) return true;
299
+ return promptUser(`${name} MCP is already configured. Overwrite?`, false);
300
+ },
301
+ setHookEnvironment({ url, apiKey, log, dryRun, verbose }) {
302
+ const hooksPath = resolveHooksPath();
303
+ const hooksRoot = resolveHooksRootPath();
304
+ const envFilePath = resolveMekoEnvFilePath();
305
+ if (dryRun) {
306
+ log?.dim?.(` Would write env file at ${envFilePath} (mode 0600)`);
307
+ log?.dim?.(` Would configure Codex hooks at ${hooksPath}`);
308
+ if (apiKey) {
309
+ log?.dim?.(
310
+ ` MEKO_API_KEY will be stored in ${envFilePath} (mode 0600); hook commands source it (no API key in process args).`,
311
+ );
312
+ }
313
+ return;
314
+ }
315
+
316
+ writeCodexMekoEnvFile(envFilePath, { url, apiKey });
317
+ const changed = upsertCodexMekoHooks(hooksPath, hooksRoot, { envFilePath });
318
+ if (verbose || changed) {
319
+ log?.dim?.(` Updated Codex hooks at ${hooksPath}`);
320
+ }
321
+ if (apiKey) {
322
+ log?.dim?.(
323
+ ` MEKO_API_KEY stored in ${envFilePath} (mode 0600). Hook commands source the file at run time, so the key never appears in process arguments.`,
324
+ );
325
+ }
326
+ },
327
+ installSkills({ dryRun, log, scope, name }) {
328
+ void scope; // Codex skills are user-scope only.
329
+ const serverName = name ?? "meko";
330
+ const skillsRoot = resolveSkillsRoot();
331
+ const agentsMdPath = resolveAgentsMdPath();
332
+ const currentVersion = getInstallerVersion();
333
+
334
+ if (dryRun) {
335
+ log.dim?.(` Would install skills into ${skillsRoot}`);
336
+ log.dim?.(` Would upsert Meko block in ${agentsMdPath}`);
337
+ return { marketplace: "skipped", skills: "dry-run" };
338
+ }
339
+
340
+ mkdirSync(skillsRoot, { recursive: true });
341
+ for (const skill of SKILLS) {
342
+ const source = resolveSkillSource(skill.name);
343
+ const destination = join(skillsRoot, skill.name);
344
+
345
+ if (existsSync(destination)) {
346
+ // A symlink we own already points at the latest source — nothing
347
+ // to refresh. Mirrors the cursor adapter's isOwnSymlink branch.
348
+ if (isOwnSymlink(destination, source)) continue;
349
+
350
+ const marker = readInstallerMarker(destination);
351
+ if (!marker) {
352
+ log.dim?.(
353
+ ` Skipping ${destination}: no .meko-installer marker (user-managed).`,
354
+ );
355
+ continue;
356
+ }
357
+ if (markerIsStale(destination, currentVersion)) {
358
+ const from = marker.version ?? "pre-1.0.9";
359
+ log.dim?.(
360
+ ` Upgrading codex skill from ${from} -> ${currentVersion}`,
361
+ );
362
+ removePath(destination);
363
+ } else {
364
+ continue;
365
+ }
366
+ } else if (isDanglingSymlink(destination)) {
367
+ // Stale symlink left behind by an earlier install whose target
368
+ // (e.g. a deleted worktree) no longer exists. existsSync is false
369
+ // for these, so the branch above misses them. Clear it so the
370
+ // fresh-install path below can replace it cleanly.
371
+ log.dim?.(
372
+ ` Replacing dangling symlink at ${destination} (target missing).`,
373
+ );
374
+ removePath(destination);
375
+ }
376
+
377
+ const strategy = symlinkOrCopy(source, destination);
378
+ if (strategy === "copy") writeInstallerMarker(destination);
379
+ }
380
+
381
+ upsertAgentsMdBlock(agentsMdPath, currentVersion, { name: serverName });
382
+
383
+ if (surfaces.app) {
384
+ log.dim?.(" Restart Codex.app to pick up the new skill / AGENTS.md.");
385
+ }
386
+
387
+ return { marketplace: "skipped", skills: "ok" };
388
+ },
389
+ uninstall({ name, scope, log = console }) {
390
+ const results = [];
391
+ try {
392
+ this.removeRegistration(name, { scope, log });
393
+ results.push({
394
+ key: "mcp",
395
+ ok: true,
396
+ message: `Codex MCP server "${name}" removed.`,
397
+ });
398
+ } catch (err) {
399
+ results.push({
400
+ key: "mcp",
401
+ ok: false,
402
+ message: `Could not remove Codex MCP server: ${err.message}`,
403
+ });
404
+ }
405
+
406
+ try {
407
+ const skillsRoot = resolveSkillsRoot();
408
+ for (const skill of SKILLS) {
409
+ const dest = join(skillsRoot, skill.name);
410
+ if (!existsSync(dest)) continue;
411
+ // Remove the dest if it's either a marker-stamped copy (we own it)
412
+ // or a symlink pointing at our shipped source (also us).
413
+ const markerPath = join(dest, ".meko-installer");
414
+ if (existsSync(markerPath)) {
415
+ removePath(dest);
416
+ continue;
417
+ }
418
+ const expectedSource = resolveSkillSource(skill.name);
419
+ if (isOwnSymlink(dest, expectedSource)) removePath(dest);
420
+ }
421
+ results.push({
422
+ key: "skills",
423
+ ok: true,
424
+ message: "Codex skills cleaned up.",
425
+ });
426
+ } catch (err) {
427
+ results.push({
428
+ key: "skills",
429
+ ok: false,
430
+ message: `Could not remove Codex skills: ${err.message}`,
431
+ });
432
+ }
433
+
434
+ try {
435
+ const hooksPath = resolveHooksPath();
436
+ const envFilePath = resolveMekoEnvFilePath();
437
+ const changed = removeCodexMekoHooks(hooksPath, { envFilePath });
438
+ if (changed) {
439
+ results.push({
440
+ key: "hooks",
441
+ ok: true,
442
+ message: "Codex Meko hooks cleaned up.",
443
+ });
444
+ }
445
+ } catch (err) {
446
+ results.push({
447
+ key: "hooks",
448
+ ok: false,
449
+ message: `Could not clean Codex hooks: ${err.message}`,
450
+ });
451
+ }
452
+
453
+ try {
454
+ const agentsMdPath = resolveAgentsMdPath();
455
+ const raw = safeReadFile(agentsMdPath);
456
+ if (raw !== null) {
457
+ const next = removeAgentsMdBlock(raw);
458
+ if (next !== raw) writeWithBackup(agentsMdPath, next);
459
+ }
460
+ results.push({
461
+ key: "agents-md",
462
+ ok: true,
463
+ message: "Meko block stripped from AGENTS.md.",
464
+ });
465
+ } catch (err) {
466
+ results.push({
467
+ key: "agents-md",
468
+ ok: false,
469
+ message: `Could not update AGENTS.md: ${err.message}`,
470
+ });
471
+ }
472
+
473
+ if (surfaces.app) {
474
+ log.dim?.(" Restart Codex.app to apply the removal.");
475
+ }
476
+
477
+ return results;
478
+ },
479
+ async validateConfig({ serverName, scope, url }) {
480
+ const path = settingsPathFor(scope, null);
481
+ if (surfaces.cli) {
482
+ try {
483
+ runCommand(`codex mcp get ${shellQuote(serverName)}`);
484
+ return {
485
+ ok: true,
486
+ message: `Server "${serverName}" registered (codex mcp get succeeded).`,
487
+ };
488
+ } catch {
489
+ // fall through to TOML inspection
490
+ }
491
+ }
492
+ const raw = safeReadFile(path);
493
+ if (raw === null) {
494
+ return { ok: false, message: `Codex config not found at ${path}.` };
495
+ }
496
+ const parsed = parseCodexConfigForServer(raw, serverName);
497
+ if (!parsed.found) {
498
+ return {
499
+ ok: false,
500
+ message: `Server "${serverName}" not found in ${path}.`,
501
+ };
502
+ }
503
+ if (url && parsed.url && parsed.url !== url) {
504
+ return {
505
+ ok: false,
506
+ message: `Server "${serverName}" in ${path} points at ${parsed.url}, expected ${url}.`,
507
+ };
508
+ }
509
+ return { ok: true, message: `Server "${serverName}" is registered in ${path}.` };
510
+ },
511
+ };
512
+ }
513
+
514
+ // Codex's hooks.json schema only deserializes `hooks` and `state` — sibling
515
+ // keys like `env` are silently dropped, and `HookHandlerConfig::Command` has
516
+ // no per-command `env` field. The runner spawns each command via
517
+ // `$SHELL -lc <command>` with the parent env inherited.
518
+ //
519
+ // We sidestep that by writing a 0600 env file (default ~/.codex/meko-env.sh)
520
+ // that the hook command sources before invoking the script:
521
+ // `. /path/to/meko-env.sh && bash /path/to/hook.sh`
522
+ // The API key never appears in process arguments, so a `ps -ef` from another
523
+ // user (or a logging shim) can't lift it from the command line. Same trust
524
+ // boundary as `git credential-store`.
525
+ function upsertCodexMekoHooks(hooksPath, hooksRoot, { envFilePath } = {}) {
526
+ const cfg = readJsonFile(hooksPath);
527
+ const before = JSON.stringify(cfg);
528
+ if (!cfg.hooks || typeof cfg.hooks !== "object") {
529
+ cfg.hooks = {};
530
+ }
531
+
532
+ pruneCodexMekoHooksObject(cfg.hooks);
533
+ for (const hook of MEKO_HOOKS) {
534
+ const entry = {
535
+ type: "command",
536
+ command: buildCodexHookCommand(join(hooksRoot, hook.script), { envFilePath }),
537
+ };
538
+ if (hook.timeout) entry.timeout = hook.timeout;
539
+ upsertCodexHookEntry(cfg.hooks, hook.event, entry);
540
+ }
541
+
542
+ const after = JSON.stringify(cfg);
543
+ if (after !== before) {
544
+ writeJsonFile(hooksPath, cfg);
545
+ return true;
546
+ }
547
+ return false;
548
+ }
549
+
550
+ function buildCodexHookCommand(scriptPath, { envFilePath } = {}) {
551
+ const parts = [];
552
+ if (envFilePath) parts.push(`. ${shellQuote(envFilePath)}`);
553
+ parts.push(`bash ${shellQuote(scriptPath)}`);
554
+ return parts.join(" && ");
555
+ }
556
+
557
+ /**
558
+ * Write the Meko env file the Codex hooks source. Mode 0600 (user
559
+ * read/write only) so the API key isn't readable by other users.
560
+ * Idempotent: re-running with the same values is a no-op write that
561
+ * still asserts the mode.
562
+ */
563
+ function writeCodexMekoEnvFile(path, { url, apiKey }) {
564
+ mkdirSync(dirname(path), { recursive: true });
565
+ const lines = [
566
+ "# Auto-generated by @yugabytedb/meko-mcp installer.",
567
+ "# Sourced by Codex hooks (see ~/.codex/hooks.json) before each hook runs.",
568
+ "# Mode 0600 — do not commit, do not share, do not relax permissions.",
569
+ `export MEKO_MCP_URL=${shellQuote(url || "")}`,
570
+ `export MEKO_API_KEY=${shellQuote(apiKey || "")}`,
571
+ "",
572
+ ];
573
+ writeFileSync(path, lines.join("\n"), { encoding: "utf8", mode: 0o600 });
574
+ // writeFileSync's `mode` only applies on initial create; re-assert
575
+ // explicitly so a permission relaxation between installs is corrected.
576
+ chmodSync(path, 0o600);
577
+ }
578
+
579
+ function removeCodexMekoHooks(hooksPath, { envFilePath } = {}) {
580
+ const cfg = readJsonFile(hooksPath);
581
+ let changed = false;
582
+ if (cfg.hooks && typeof cfg.hooks === "object") {
583
+ const before = JSON.stringify(cfg);
584
+ pruneCodexMekoHooksObject(cfg.hooks);
585
+ if (Object.keys(cfg.hooks).length === 0) {
586
+ delete cfg.hooks;
587
+ }
588
+ // Older installs (PR 127 first cut) wrote a top-level cfg.env block that
589
+ // Codex silently ignored. Strip it during uninstall so we don't leave a
590
+ // misleading env hanging around in user configs.
591
+ if (cfg.env && typeof cfg.env === "object") {
592
+ delete cfg.env.MEKO_MCP_URL;
593
+ delete cfg.env.MEKO_API_KEY;
594
+ if (Object.keys(cfg.env).length === 0) {
595
+ delete cfg.env;
596
+ }
597
+ }
598
+ if (JSON.stringify(cfg) !== before) {
599
+ writeJsonFile(hooksPath, cfg);
600
+ changed = true;
601
+ }
602
+ }
603
+
604
+ // Remove the meko-env.sh file too — leaving a 0600 file with credentials
605
+ // around after uninstall is a security gap.
606
+ if (envFilePath) {
607
+ try {
608
+ rmSync(envFilePath, { force: true });
609
+ } catch {
610
+ // best-effort
611
+ }
612
+ }
613
+ return changed;
614
+ }
615
+
616
+ function upsertCodexHookEntry(hooksObj, event, entry) {
617
+ if (!Array.isArray(hooksObj[event])) hooksObj[event] = [];
618
+ let group = hooksObj[event].find((item) => Array.isArray(item?.hooks));
619
+ if (!group) {
620
+ group = { hooks: [] };
621
+ hooksObj[event].push(group);
622
+ }
623
+ const exists = group.hooks.some((hook) => hook.command === entry.command);
624
+ if (!exists) group.hooks.push(entry);
625
+ }
626
+
627
+ function pruneCodexMekoHooksObject(hooksObj) {
628
+ for (const event of Object.keys(hooksObj)) {
629
+ const groups = hooksObj[event];
630
+ if (!Array.isArray(groups)) continue;
631
+ hooksObj[event] = groups
632
+ .map((group) => {
633
+ if (!Array.isArray(group?.hooks)) return group;
634
+ return {
635
+ ...group,
636
+ hooks: group.hooks.filter((hook) => !isMekoHookCommand(hook?.command)),
637
+ };
638
+ })
639
+ .filter((group) => !Array.isArray(group?.hooks) || group.hooks.length > 0);
640
+ if (hooksObj[event].length === 0) {
641
+ delete hooksObj[event];
642
+ }
643
+ }
644
+ }
645
+
646
+ function isMekoHookCommand(command) {
647
+ if (typeof command !== "string") return false;
648
+ return MEKO_HOOK_SCRIPTS.some((script) => {
649
+ const escaped = script.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
650
+ const pattern = new RegExp(
651
+ String.raw`(^|[/\\])hooks-handlers[/\\]${escaped}(?=$|['"\s])`,
652
+ );
653
+ return pattern.test(command);
654
+ });
655
+ }
656
+
657
+ // ---------------------------------------------------------------------------
658
+ // Probes (default impls; tests inject stubs)
659
+ // ---------------------------------------------------------------------------
660
+
661
+ function defaultRunCommand(cmd) {
662
+ return execSync(cmd, { encoding: "utf8", stdio: "pipe" }).toString();
663
+ }
664
+
665
+ function defaultProbeCodex() {
666
+ const result = { found: false, version: "", path: "" };
667
+ let path = "";
668
+ try {
669
+ path = execSync("which codex", { encoding: "utf8", stdio: "pipe" }).trim();
670
+ } catch {
671
+ return result;
672
+ }
673
+ if (!path) return result;
674
+ result.path = path;
675
+ try {
676
+ result.version = execSync("codex --version", {
677
+ encoding: "utf8",
678
+ stdio: "pipe",
679
+ timeout: 10_000,
680
+ }).trim();
681
+ } catch {
682
+ return result;
683
+ }
684
+ result.found = true;
685
+ return result;
686
+ }
687
+
688
+ function defaultProbeCodexApp() {
689
+ const home = homedir();
690
+ if (process.platform === "darwin") {
691
+ for (const candidate of [
692
+ "/Applications/Codex.app",
693
+ join(home, "Applications", "Codex.app"),
694
+ ]) {
695
+ if (existsSync(candidate)) {
696
+ return {
697
+ found: true,
698
+ path: candidate,
699
+ platform: "darwin",
700
+ version: readMacAppVersion(candidate) ?? "",
701
+ };
702
+ }
703
+ }
704
+ return null;
705
+ }
706
+ if (process.platform === "linux") {
707
+ // Best-effort. Codex Linux distribution shape is fluid; check the most
708
+ // common landing spots and return null otherwise.
709
+ const linuxCandidates = [
710
+ join(home, ".local", "share", "applications", "codex.desktop"),
711
+ "/usr/share/applications/codex.desktop",
712
+ ];
713
+ for (const candidate of linuxCandidates) {
714
+ if (existsSync(candidate)) {
715
+ return { found: true, path: candidate, platform: "linux", version: "" };
716
+ }
717
+ }
718
+ return null;
719
+ }
720
+ // Windows desktop app detection is out of scope for v1.
721
+ return null;
722
+ }
723
+
724
+ function readMacAppVersion(appPath) {
725
+ const plist = join(appPath, "Contents", "Info.plist");
726
+ let raw;
727
+ try {
728
+ raw = readFileSync(plist, "utf8");
729
+ } catch {
730
+ return null;
731
+ }
732
+ const m = raw.match(
733
+ /<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/,
734
+ );
735
+ return m ? m[1] : null;
736
+ }
737
+
738
+ function defaultDetectShellRc() {
739
+ const shell = process.env.SHELL || "";
740
+ const home = homedir();
741
+ if (shell.endsWith("/zsh")) return join(home, ".zshrc");
742
+ if (shell.endsWith("/bash")) {
743
+ // macOS: bash users typically edit ~/.bash_profile (login shell);
744
+ // Linux: ~/.bashrc.
745
+ return process.platform === "darwin"
746
+ ? join(home, ".bash_profile")
747
+ : join(home, ".bashrc");
748
+ }
749
+ if (shell.endsWith("/fish")) {
750
+ return join(home, ".config", "fish", "config.fish");
751
+ }
752
+ return null;
753
+ }
754
+
755
+ // ---------------------------------------------------------------------------
756
+ // CLI command builders
757
+ // ---------------------------------------------------------------------------
758
+
759
+ function buildCliRegisterCommand(name, url, bearerEnv) {
760
+ let cmd = `codex mcp add ${shellQuote(name)} --url ${shellQuote(url)}`;
761
+ if (bearerEnv) {
762
+ cmd += ` --bearer-token-env-var ${shellQuote(bearerEnv)}`;
763
+ }
764
+ return cmd;
765
+ }
766
+
767
+ // ---------------------------------------------------------------------------
768
+ // TOML helpers (App-only fallback path)
769
+ // ---------------------------------------------------------------------------
770
+
771
+ /**
772
+ * Locate `[mcp_servers.<name>]` and return its raw block text.
773
+ * Read-only — no parsing into a structured object.
774
+ *
775
+ * @param {string} raw
776
+ * @param {string} name
777
+ * @returns {{found: boolean, start?: number, end?: number, url?: string}}
778
+ */
779
+ export function parseCodexConfigForServer(raw, name) {
780
+ const header = `[mcp_servers.${name}]`;
781
+ const headerIdx = findHeader(raw, header);
782
+ if (headerIdx === -1) return { found: false };
783
+ const blockEnd = findNextHeader(raw, headerIdx + header.length);
784
+ const block = raw.slice(headerIdx, blockEnd);
785
+ const urlMatch = block.match(/^\s*url\s*=\s*"([^"]*)"/m);
786
+ return {
787
+ found: true,
788
+ start: headerIdx,
789
+ end: blockEnd,
790
+ url: urlMatch ? urlMatch[1] : undefined,
791
+ };
792
+ }
793
+
794
+ /**
795
+ * Insert or replace `[mcp_servers.<name>]` block. `fields` keys must be
796
+ * string-valued (we don't need numbers / arrays / inline tables for the
797
+ * MCP fields we write). Pass `null`/`undefined` to omit a field.
798
+ *
799
+ * Emission contract:
800
+ * - The rendered block always ends with a single "\n" so the last
801
+ * field line is terminated.
802
+ * - When something follows the block in the existing TOML (another
803
+ * table header at `existing.end`), we add one additional "\n" so
804
+ * there is exactly one blank line separating the two tables —
805
+ * matching canonical `taplo` / `dprint-toml` formatting.
806
+ * - When the block is the last thing in the file we emit just the
807
+ * terminator "\n" — no trailing blank line.
808
+ *
809
+ * Without these terminators the replacement path glued the next
810
+ * top-level header onto the last field, e.g.
811
+ * bearer_token_env_var = "MEKO_API_KEY"[final]
812
+ * which is invalid TOML.
813
+ *
814
+ * @param {string} raw
815
+ * @param {string} name
816
+ * @param {Record<string, string|null|undefined>} fields
817
+ * @returns {string}
818
+ */
819
+ export function upsertCodexConfigBlock(raw, name, fields) {
820
+ const blockBody = renderBlock(name, fields);
821
+ const existing = parseCodexConfigForServer(raw, name);
822
+
823
+ if (!existing.found) {
824
+ // Fresh insert appended to the end of file.
825
+ if (raw.length === 0) return blockBody + "\n";
826
+ const sep = raw.endsWith("\n") ? "\n" : "\n\n";
827
+ return raw + sep + blockBody + "\n";
828
+ }
829
+
830
+ // In-place replacement. parseCodexConfigForServer's `end` is the index
831
+ // of the next `[` (top-level table) or raw.length when our block is
832
+ // last. Pick the trailing separator accordingly.
833
+ const tail = raw.slice(existing.end);
834
+ const trailing = tail.length === 0 ? "\n" : "\n\n";
835
+ return raw.slice(0, existing.start) + blockBody + trailing + tail;
836
+ }
837
+
838
+ /**
839
+ * Excise the `[mcp_servers.<name>]` block. Collapses extra blank lines
840
+ * left behind by the cut so we don't accumulate `\n\n\n` runs across
841
+ * round-trips.
842
+ *
843
+ * @param {string} raw
844
+ * @param {string} name
845
+ * @returns {string}
846
+ */
847
+ export function removeCodexConfigBlock(raw, name) {
848
+ const existing = parseCodexConfigForServer(raw, name);
849
+ if (!existing.found) return raw;
850
+ let next = raw.slice(0, existing.start) + raw.slice(existing.end);
851
+ next = next.replace(/\n{3,}/g, "\n\n");
852
+ return next;
853
+ }
854
+
855
+ /**
856
+ * Render a `[mcp_servers.<name>]` block. The returned string is the
857
+ * header line plus one line per non-null field, joined with "\n" and
858
+ * with NO trailing newline. Callers (upsertCodexConfigBlock) append
859
+ * their own terminator and any blank-line separation.
860
+ */
861
+ function renderBlock(name, fields) {
862
+ const lines = [`[mcp_servers.${name}]`];
863
+ for (const [key, value] of Object.entries(fields)) {
864
+ if (value === null || value === undefined) continue;
865
+ lines.push(`${key} = "${escapeTomlString(value)}"`);
866
+ }
867
+ return lines.join("\n");
868
+ }
869
+
870
+ function escapeTomlString(value) {
871
+ return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
872
+ }
873
+
874
+ /**
875
+ * Find the first occurrence of `header` at the start of a line, ignoring
876
+ * comments and indented (subtable) headers like `[mcp_servers.meko.tools]`.
877
+ *
878
+ * @param {string} raw
879
+ * @param {string} header
880
+ * @returns {number} index of the `[`, or -1
881
+ */
882
+ function findHeader(raw, header) {
883
+ // Match `[mcp_servers.<name>]` exactly (no subkey appended). Anchored to
884
+ // the start of a line and must be followed by a newline or end-of-string.
885
+ const re = new RegExp(
886
+ `^${escapeRegex(header)}\\s*$`,
887
+ "m",
888
+ );
889
+ const m = re.exec(raw);
890
+ return m ? m.index : -1;
891
+ }
892
+
893
+ /**
894
+ * Starting from `from`, return the index of the next top-level `[...]`
895
+ * header, or raw.length if none.
896
+ */
897
+ function findNextHeader(raw, from) {
898
+ const re = /^\[[^\]]+\]/m;
899
+ re.lastIndex = from;
900
+ const slice = raw.slice(from);
901
+ const m = re.exec(slice);
902
+ return m ? from + m.index : raw.length;
903
+ }
904
+
905
+ function escapeRegex(s) {
906
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
907
+ }
908
+
909
+ // ---------------------------------------------------------------------------
910
+ // AGENTS.md sentinel-block helpers
911
+ // ---------------------------------------------------------------------------
912
+
913
+ /**
914
+ * Upsert the Meko-managed AGENTS.md block fenced by sentinel comments.
915
+ * Idempotent: a second call with the same version + name produces a
916
+ * byte-identical file. Preserves user content outside the fence
917
+ * byte-for-byte.
918
+ *
919
+ * Block-ownership contract (what `upsertAgentsMdBlock` writes and what
920
+ * `removeAgentsMdBlock` is allowed to remove):
921
+ *
922
+ * - The block we own is exactly `block + "\n"` — the start fence
923
+ * line, the body, the end fence line, and a single trailing "\n".
924
+ * - On fresh insert into non-empty content, the block is appended
925
+ * immediately after the user's existing bytes. We do NOT add a
926
+ * separator "\n" — markdown rendering survives just fine with the
927
+ * fence sitting on the line right after the user's last line, and
928
+ * the user's last newline (if present) belongs to them, not us.
929
+ * - For visual separation, callers can include `\n\n` at the START
930
+ * of the rendered block (we don't, to keep the block self-contained
931
+ * and removable byte-faithfully).
932
+ *
933
+ * `removeAgentsMdBlock` strips exactly the bytes we own (`block` plus
934
+ * the trailing `"\n"` we wrote) and nothing else.
935
+ *
936
+ * Round-trip: a non-empty input ending in "\n" survives upsert → remove
937
+ * unchanged. An input that does NOT end in "\n" gets a "\n" appended on
938
+ * upsert (markdown best practice), which the round-trip cannot undo —
939
+ * this is an explicit and documented exception.
940
+ *
941
+ * @param {string} filePath
942
+ * @param {string} version
943
+ * @param {{name?: string}} [opts]
944
+ */
945
+ export function upsertAgentsMdBlock(filePath, version, opts = {}) {
946
+ const name = opts.name ?? "meko";
947
+ const block = renderAgentsMdBlock(version, name);
948
+ const raw = safeReadFile(filePath) ?? "";
949
+ const startIdx = raw.indexOf(AGENTS_MD_START);
950
+ const endIdx = raw.indexOf(AGENTS_MD_END);
951
+ if (startIdx === -1) {
952
+ // Fresh insert. Append `block + "\n"` immediately after the user's
953
+ // bytes. If the user's content does not end in "\n", insert one
954
+ // first so our fence appears on its own line — that prepended "\n"
955
+ // is treated as user content (markdown convention) and is NOT
956
+ // removed by removeAgentsMdBlock.
957
+ const userTerminator = raw.length > 0 && !raw.endsWith("\n") ? "\n" : "";
958
+ writeWithBackup(filePath, raw + userTerminator + block + "\n");
959
+ return;
960
+ }
961
+ if (endIdx === -1 || endIdx < startIdx) {
962
+ // Malformed (start fence without matching end fence). Append a fresh
963
+ // block; the orphan start fence is left in place for the user to
964
+ // notice / clean up.
965
+ const userTerminator = raw.length > 0 && !raw.endsWith("\n") ? "\n" : "";
966
+ writeWithBackup(filePath, raw + userTerminator + block + "\n");
967
+ return;
968
+ }
969
+ // In-place replacement: just swap the body between start and end fences.
970
+ const next =
971
+ raw.slice(0, startIdx) +
972
+ block +
973
+ raw.slice(endIdx + AGENTS_MD_END.length);
974
+ if (next === raw) return;
975
+ writeWithBackup(filePath, next);
976
+ }
977
+
978
+ /**
979
+ * Strip the Meko-managed block from AGENTS.md content. Returns the next
980
+ * content string. Removes only:
981
+ *
982
+ * - The bytes from AGENTS_MD_START through AGENTS_MD_END (inclusive).
983
+ * - A single trailing "\n" immediately after the end fence (the
984
+ * newline upsert wrote to terminate the block).
985
+ *
986
+ * Bytes BEFORE the start fence are preserved exactly, so a user's
987
+ * trailing newline survives byte-for-byte.
988
+ *
989
+ * @param {string} raw
990
+ * @returns {string}
991
+ */
992
+ export function removeAgentsMdBlock(raw) {
993
+ const startIdx = raw.indexOf(AGENTS_MD_START);
994
+ const endIdx = raw.indexOf(AGENTS_MD_END);
995
+ if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) return raw;
996
+ let cutEnd = endIdx + AGENTS_MD_END.length;
997
+ if (raw[cutEnd] === "\n") cutEnd += 1;
998
+ return raw.slice(0, startIdx) + raw.slice(cutEnd);
999
+ }
1000
+
1001
+ function renderAgentsMdBlock(version, name) {
1002
+ const toolPrefix = `mcp__${name}__`;
1003
+ return [
1004
+ `${AGENTS_MD_START} v${version}`,
1005
+ "## Meko memory (auto-managed by meko installer)",
1006
+ "",
1007
+ `You have a persistent memory store via the \`${name}\` MCP server. At the start of every conversation, call \`${toolPrefix}memory_search\` for relevant context. When the user shares facts, preferences, or decisions worth remembering, call \`${toolPrefix}memory_add\`. Skill details: ~/.agents/skills/meko-mcp-tools/SKILL.md`,
1008
+ "",
1009
+ "Cloud auth requires `MEKO_API_KEY` exported in your shell (or, for the desktop app on macOS, set via `launchctl setenv`).",
1010
+ AGENTS_MD_END,
1011
+ ].join("\n");
1012
+ }
1013
+
1014
+ // ---------------------------------------------------------------------------
1015
+ // Cloud auth helpers
1016
+ // ---------------------------------------------------------------------------
1017
+
1018
+ function printCloudAuthInstructions({ shellRc, hasApp, platform, log }) {
1019
+ // Never echo the key value here — these instructions are advisory and may
1020
+ // be captured in terminal scrollback, screenshares, CI logs, etc. The
1021
+ // interactive rc-append / launchctl helpers receive the key directly when
1022
+ // the user opts in.
1023
+ log.warn?.(
1024
+ "Codex resolves MEKO_API_KEY from your runtime env. The key was NOT written to ~/.codex/config.toml.",
1025
+ );
1026
+ const flavor = shellRc ? rcFlavor(shellRc) : null;
1027
+ if (shellRc) {
1028
+ log.dim?.(` Add to ${shellRc}:`);
1029
+ if (flavor === "fish") {
1030
+ log.dim?.(` set -gx MEKO_API_KEY '<your-meko-api-key>'`);
1031
+ } else {
1032
+ log.dim?.(` export MEKO_API_KEY='<your-meko-api-key>'`);
1033
+ }
1034
+ } else {
1035
+ log.dim?.(" Add to your shell rc:");
1036
+ log.dim?.(` bash/zsh: export MEKO_API_KEY='<your-meko-api-key>'`);
1037
+ log.dim?.(` fish: set -gx MEKO_API_KEY '<your-meko-api-key>'`);
1038
+ }
1039
+ log.dim?.(` Then open a new terminal (or run "exec $SHELL" / "exec fish").`);
1040
+ if (hasApp && platform === "darwin") {
1041
+ log.warn?.(
1042
+ "macOS Codex.app launched from Finder/Dock does NOT inherit shell rc — set it via launchctl too:",
1043
+ );
1044
+ log.dim?.(
1045
+ ` launchctl setenv MEKO_API_KEY '<your-meko-api-key>' # current login session`,
1046
+ );
1047
+ log.dim?.(
1048
+ " (For permanence across reboots, add a ~/Library/LaunchAgents/com.meko.env.plist.)",
1049
+ );
1050
+ }
1051
+ }
1052
+
1053
+ /**
1054
+ * Pick the right env-export syntax for the rc file we're writing to.
1055
+ * fish uses `set -gx`; everything else (zsh, bash, sh, ksh) uses
1056
+ * POSIX `export`. The detection key is the path: detectShellRc maps
1057
+ * fish to a `.../fish/config.fish` path, which is the only shell whose
1058
+ * rc file fish actually reads. (User-supplied paths flow through this
1059
+ * same check, so a user passing `~/.config/fish/config.fish` from any
1060
+ * shell still gets fish syntax.)
1061
+ *
1062
+ * @param {string} shellRc
1063
+ * @returns {"fish"|"posix"}
1064
+ */
1065
+ function rcFlavor(shellRc) {
1066
+ return shellRc.endsWith(".fish") ? "fish" : "posix";
1067
+ }
1068
+
1069
+ function buildExportLine(flavor, apiKey) {
1070
+ if (flavor === "fish") {
1071
+ // fish quoting is single-quote-only; no $-interpolation inside, and no
1072
+ // way to escape a single quote inside a single-quoted string. Disallow
1073
+ // single quotes outright rather than emit something that won't parse.
1074
+ if (String(apiKey).includes("'")) {
1075
+ throw new Error(
1076
+ "MEKO_API_KEY contains a single quote; cannot safely write to a fish rc.",
1077
+ );
1078
+ }
1079
+ return `set -gx MEKO_API_KEY '${apiKey}'`;
1080
+ }
1081
+ return `export MEKO_API_KEY=${shellQuote(apiKey)}`;
1082
+ }
1083
+
1084
+ /**
1085
+ * Interactive: append a sentinel-fenced env-export block to the user's
1086
+ * shell rc. Picks `set -gx` for fish, POSIX `export` otherwise. Skipped
1087
+ * silently when shellRc is null. Idempotent: re-running a second time
1088
+ * finds the existing block and is a no-op.
1089
+ *
1090
+ * @returns {Promise<{appended: boolean, flavor?: "fish"|"posix"}>}
1091
+ */
1092
+ export async function maybeOfferRcAppend({ apiKey, shellRc, promptUser, log }) {
1093
+ if (!shellRc) return { appended: false };
1094
+ const existing = safeReadFile(shellRc) ?? "";
1095
+ if (existing.includes(RC_FENCE_START)) {
1096
+ log?.dim?.(` ${shellRc} already contains a meko-mcp block; skipping.`);
1097
+ return { appended: false };
1098
+ }
1099
+ const flavor = rcFlavor(shellRc);
1100
+ const exportLine = buildExportLine(flavor, apiKey);
1101
+ const promptVerb = flavor === "fish" ? "set -gx" : "export";
1102
+ const accept = await promptUser(
1103
+ `Append "${promptVerb} MEKO_API_KEY ..." to ${shellRc}?`,
1104
+ false,
1105
+ );
1106
+ if (!accept) return { appended: false };
1107
+ const block = [
1108
+ "",
1109
+ RC_FENCE_START,
1110
+ exportLine,
1111
+ RC_FENCE_END,
1112
+ "",
1113
+ ].join("\n");
1114
+ mkdirSync(dirname(shellRc), { recursive: true });
1115
+ appendFileSync(shellRc, block, "utf8");
1116
+ log?.dim?.(` Appended meko block to ${shellRc}.`);
1117
+ return { appended: true, flavor };
1118
+ }
1119
+
1120
+ /**
1121
+ * Interactive: run `launchctl setenv MEKO_API_KEY <key>` so the macOS
1122
+ * Codex.app (launched from Finder/Dock) sees the env var for the current
1123
+ * login session.
1124
+ *
1125
+ * @returns {Promise<{set: boolean}>}
1126
+ */
1127
+ export async function maybeOfferLaunchctlSetenv({
1128
+ apiKey,
1129
+ promptUser,
1130
+ runCommand,
1131
+ log,
1132
+ }) {
1133
+ const accept = await promptUser(
1134
+ "Run `launchctl setenv MEKO_API_KEY ...` so Codex.app sees the key now?",
1135
+ false,
1136
+ );
1137
+ if (!accept) return { set: false };
1138
+ return setLaunchctlMekoApiKey({ apiKey, runCommand, log });
1139
+ }
1140
+
1141
+ /**
1142
+ * Run `launchctl setenv MEKO_API_KEY <key>` so the macOS Codex.app launched
1143
+ * from Finder/Dock can authenticate during the current login session.
1144
+ *
1145
+ * The key is passed to launchctl but never logged.
1146
+ *
1147
+ * @returns {{set: boolean}}
1148
+ */
1149
+ export function setLaunchctlMekoApiKey({ apiKey, runCommand, log }) {
1150
+ try {
1151
+ runCommand(`launchctl setenv MEKO_API_KEY ${shellQuote(apiKey)}`);
1152
+ log?.dim?.(" Set MEKO_API_KEY via launchctl (current login session).");
1153
+ return { set: true };
1154
+ } catch (err) {
1155
+ log?.warn?.(" launchctl setenv failed; run it manually if Codex.app needs cloud auth.");
1156
+ return { set: false };
1157
+ }
1158
+ }
1159
+
1160
+ // ---------------------------------------------------------------------------
1161
+ // File helpers
1162
+ // ---------------------------------------------------------------------------
1163
+
1164
+ function safeReadFile(path) {
1165
+ try {
1166
+ return readFileSync(path, "utf8");
1167
+ } catch (err) {
1168
+ if (err.code === "ENOENT") return null;
1169
+ throw err;
1170
+ }
1171
+ }
1172
+
1173
+ /**
1174
+ * True when `destination` is a symlink that resolves to `expectedSource`.
1175
+ * Used to recognize in-place symlink installs on re-run / uninstall.
1176
+ */
1177
+ function isOwnSymlink(destination, expectedSource) {
1178
+ try {
1179
+ const stat = lstatSync(destination);
1180
+ if (!stat.isSymbolicLink()) return false;
1181
+ const linkTarget = readlinkSync(destination);
1182
+ const resolvedTarget = resolve(dirname(destination), linkTarget);
1183
+ return resolvedTarget === expectedSource;
1184
+ } catch {
1185
+ return false;
1186
+ }
1187
+ }
1188
+
1189
+ function writeWithBackup(path, content) {
1190
+ mkdirSync(dirname(path), { recursive: true });
1191
+ if (existsSync(path)) {
1192
+ try {
1193
+ copyFileSync(path, path + ".bak");
1194
+ } catch {
1195
+ // best-effort
1196
+ }
1197
+ }
1198
+ writeFileSync(path, content, "utf8");
1199
+ }
1200
+
1201
+ // re-export for tests / outside use of the resolved-source helper
1202
+ export const __TEST__ = {
1203
+ RC_FENCE_START,
1204
+ RC_FENCE_END,
1205
+ AGENTS_MD_START,
1206
+ AGENTS_MD_END,
1207
+ defaultDetectShellRc,
1208
+ buildCliRegisterCommand,
1209
+ };