@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.
- package/README.md +181 -32
- package/assets/hooks-handlers/lib/capture.js +176 -77
- package/assets/hooks-handlers/lib/package.json +1 -0
- package/assets/hooks-handlers/test_single_conversation.sh +1 -1
- package/assets/skills/meko-mcp-tools/SKILL.md +167 -53
- package/assets/skills/meko-mcp-tools/references/_sections.md +1 -1
- package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +84 -22
- package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +89 -56
- package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +15 -7
- package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +17 -9
- package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +94 -5
- package/assets/skills/meko-mcp-tools/references/tools-overview.md +52 -37
- package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +108 -37
- package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +6 -10
- package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +30 -18
- package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +55 -69
- package/bin/create-meko-setup.mjs +147 -18
- package/lib/clients/claude-code.mjs +378 -68
- package/lib/clients/claude-desktop.mjs +35 -1
- package/lib/clients/codex.mjs +977 -0
- package/lib/clients/common.mjs +142 -1
- package/lib/clients/cursor.mjs +71 -3
- package/lib/clients/index.mjs +2 -0
- package/lib/config.mjs +30 -0
- package/lib/detect.mjs +52 -44
- package/lib/installer.mjs +9 -2
- package/lib/mcp-transport.mjs +61 -2
- package/lib/migrate/adapters/memory-md.mjs +13 -6
- package/lib/migrate/datapack.mjs +167 -0
- package/lib/migrate/id.mjs +127 -9
- package/lib/migrate/orchestrator.mjs +68 -10
- package/lib/migrate.mjs +346 -3
- package/lib/prompt.mjs +22 -4
- package/lib/validate.mjs +313 -9
- package/lib/version-check.mjs +178 -0
- 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
|
+
};
|