@yugabytedb/meko-mcp 1.0.8 → 1.1.0

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