@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.
- package/README.md +242 -44
- package/assets/hooks-handlers/before-submit-prompt.sh +44 -0
- package/assets/hooks-handlers/cursor-session-start.sh +42 -0
- package/assets/hooks-handlers/lib/capture.js +457 -107
- package/assets/hooks-handlers/lib/package.json +1 -0
- package/assets/hooks-handlers/test_single_conversation.sh +1 -1
- package/assets/scripts/package-desktop-skill.sh +36 -0
- 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/assets/skills/meko-mcp-tools-desktop/SKILL.md +251 -0
- package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +150 -0
- package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +24 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +99 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +406 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +69 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +217 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +112 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +134 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-scope-permissions.md +75 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +155 -0
- package/bin/create-meko-setup.mjs +151 -22
- package/lib/clients/claude-code.mjs +378 -68
- package/lib/clients/claude-desktop.mjs +45 -8
- package/lib/clients/codex.mjs +1209 -0
- package/lib/clients/common.mjs +223 -1
- package/lib/clients/cursor.mjs +118 -13
- package/lib/clients/index.mjs +2 -0
- package/lib/config.mjs +56 -0
- package/lib/detect.mjs +52 -44
- package/lib/installer.mjs +11 -4
- 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
- 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
|
+
};
|