From 594a9c7432affa049263fe126056054f179a9cb1 Mon Sep 17 00:00:00 2001 From: rafaelandrade Date: Mon, 13 Jul 2026 20:53:21 -0300 Subject: [PATCH 1/3] feat(generate): add Codex editor support - New generateCodex(): writes AGENTS.md (shared orchestrator+skills logic) and .codex/config.toml with MCP servers translated to Codex's TOML format - Codex does not expand ${env:VAR} inside [mcp_servers..env] (unlike Claude/Cursor); host env vars are forwarded via env_vars instead - Register 'codex' in the generators map (hub generate -e codex) - Gracefully skip MCPs with no url/image/package instead of crashing (caught via manual testing against a real hub.config.ts) --- packages/cli/src/commands/generate.ts | 134 +++++++++++++++++++++++++- 1 file changed, 133 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index 45457b9..6c8b248 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -2274,6 +2274,136 @@ async function generateClaudeCode(config: HubConfig, hubDir: string) { console.log(chalk.green(" Generated .gitignore")); } +function tomlString(value: string): string { + if (!value.includes("'") && !value.includes("\n")) { + return `'${value}'`; + } + const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n"); + return `"${escaped}"`; +} + +function tomlArray(values: string[]): string { + return `[${values.map((v) => tomlString(v)).join(", ")}]`; +} + +const ENV_PLACEHOLDER = /^\$\{env:([A-Z0-9_]+)\}$/; + +/** + * Codex CLI stores MCP servers in `.codex/config.toml` using snake_case + * `[mcp_servers.]` tables. Unlike Claude/Cursor, Codex does not expand + * `${env:VAR}` inside the `env` map — host env vars must be whitelisted via + * `env_vars` and are forwarded by name instead. + */ +function splitEnvForCodex(env: Record | undefined): { + literal: Record; + forwarded: string[]; +} { + const literal: Record = {}; + const forwarded: string[] = []; + for (const [key, raw] of Object.entries(env ?? {})) { + const match = ENV_PLACEHOLDER.exec(raw); + if (match && match[1] === key) { + forwarded.push(key); + } else { + literal[key] = raw; + } + } + return { literal, forwarded }; +} + +function buildCodexMcpBlock(name: string, mcp: MCPConfig): string | null { + const lines: string[] = [`[mcp_servers.${name}]`]; + + if (mcp.url) { + lines.push(`url = ${tomlString(mcp.url)}`); + return lines.join("\n"); + } + + const { literal, forwarded } = splitEnvForCodex(mcp.env); + + if (mcp.image) { + const args = ["run", "-i", "--rm"]; + for (const [key, value] of Object.entries(mcp.env ?? {})) { + const match = ENV_PLACEHOLDER.exec(value); + if (match && match[1] === key) { + args.push("-e", key); + } else { + args.push("-e", `${key}=${value}`); + } + } + args.push(mcp.image); + lines.push(`command = 'docker'`, `args = ${tomlArray(args)}`); + } else if (mcp.package) { + lines.push(`command = 'npx'`, `args = ${tomlArray(["-y", mcp.package])}`); + } else { + return null; + } + + if (forwarded.length) { + lines.push(`env_vars = ${tomlArray(forwarded)}`); + } + const literalKeys = Object.keys(literal); + if (literalKeys.length) { + lines.push("", `[mcp_servers.${name}.env]`); + for (const key of literalKeys) { + lines.push(`${key} = ${tomlString(literal[key])}`); + } + } + + return lines.join("\n"); +} + +async function generateCodex(config: HubConfig, hubDir: string) { + const orchestratorRule = buildOrchestratorRule(config); + const cleanedOrchestrator = orchestratorRule.replace(/^---[\s\S]*?---\n/m, "").trim(); + const skillsSectionCodex = await buildSkillsSection(hubDir, config); + const personaCodex = await loadPersona(hubDir); + const agentsMdCodex = [cleanedOrchestrator, skillsSectionCodex].filter(Boolean).join("\n"); + await writeFile(join(hubDir, "AGENTS.md"), agentsMdCodex + "\n", "utf-8"); + console.log(chalk.green(" Generated AGENTS.md")); + if (personaCodex) { + console.log(chalk.green(` Applied persona: ${personaCodex.name} (${personaCodex.role})`)); + } + + if (config.mcps?.length) { + const upstreamSet = getUpstreamNames(config.mcps); + const blocks: string[] = [ + "# GENERATED by `hub generate -e codex` from hub.yaml — do not edit by hand.", + "#", + "# Codex only loads this file when the project is trusted:", + "# codex projects trust", + "# AGENTS.md and skills/ are read natively and do not need extra config here.", + "", + ]; + for (const mcp of config.mcps) { + if (upstreamSet.has(mcp.name)) continue; + let resolved: MCPConfig = mcp; + if (mcp.upstreams?.length) { + const { upstreamsJson, collectedEnv } = buildProxyUpstreams(mcp, config.mcps); + resolved = { ...mcp, env: { MCP_PROXY_UPSTREAMS: upstreamsJson, ...collectedEnv } }; + } + const block = buildCodexMcpBlock(mcp.name, resolved); + if (block === null) { + console.log(chalk.yellow(` Skipping MCP "${mcp.name}": no url, image, or package configured`)); + continue; + } + blocks.push(block, ""); + } + const codexDir = join(hubDir, ".codex"); + await mkdir(codexDir, { recursive: true }); + await writeFile( + join(codexDir, "config.toml"), + blocks.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n", + "utf-8" + ); + console.log(chalk.green(" Generated .codex/config.toml")); + } + + const gitignoreLines = buildGitignoreLines(config); + await writeManagedFile(join(hubDir, ".gitignore"), gitignoreLines); + console.log(chalk.green(" Generated .gitignore")); +} + async function generateKiro(config: HubConfig, hubDir: string) { const kiroDir = join(hubDir, ".kiro"); const steeringDir = join(kiroDir, "steering"); @@ -2658,6 +2788,7 @@ function buildGitignoreLines(config: HubConfig): string[] { ".kiro/steering/persona.md", ".cursor/rules/persona.mdc", ".opencode/rules/persona.md", + ".codex/rules/persona.md", ); return lines; @@ -2668,6 +2799,7 @@ export const generators: Record = { "claude-code": { name: "Claude Code", generate: generateClaudeCode }, kiro: { name: "Kiro", generate: generateKiro }, opencode: { name: "OpenCode", generate: generateOpenCode }, + codex: { name: "Codex", generate: generateCodex }, }; async function resolveEditor(opts: { editor?: string; resetEditor?: boolean }): Promise { @@ -2712,7 +2844,7 @@ async function resolveEditor(opts: { editor?: string; resetEditor?: boolean }): export const generateCommand = new Command("generate") .description("Generate editor-specific configuration files from hub.yaml") - .option("-e, --editor ", "Target editor (cursor, claude-code, kiro, opencode)") + .option("-e, --editor ", "Target editor (cursor, claude-code, kiro, opencode, codex)") .option("--reset-editor", "Reset saved editor preference and choose again") .option("--check", "Check if generated configs are outdated (exit code 1 if outdated)") .action(async (opts: { editor?: string; resetEditor?: boolean; check?: boolean }) => { From fb2d2b6b8a7282c97b28641071e975876c4e8a78 Mon Sep 17 00:00:00 2001 From: rafaelandrade Date: Mon, 13 Jul 2026 21:38:25 -0300 Subject: [PATCH 2/3] fix(codex): warn on unforwardable env placeholders and url auth Addresses CodeRabbit review on PR #72: - splitEnvForCodex now detects any ${env:...} placeholder (including lowercase and name-mismatched) and warns instead of writing a literal that Codex won't expand - url MCPs with auth emit a warning (Codex needs bearer_token_env_var, which the hub schema has no source for) instead of silently dropping it - buildCodexMcpBlock returns { block, warnings }; caller logs warnings - export both functions and add generate.test.ts (14 unit tests) --- packages/cli/src/commands/generate.test.ts | 130 +++++++++++++++++++++ packages/cli/src/commands/generate.ts | 45 +++++-- 2 files changed, 165 insertions(+), 10 deletions(-) create mode 100644 packages/cli/src/commands/generate.test.ts diff --git a/packages/cli/src/commands/generate.test.ts b/packages/cli/src/commands/generate.test.ts new file mode 100644 index 0000000..eba1e95 --- /dev/null +++ b/packages/cli/src/commands/generate.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from "vitest"; +import { splitEnvForCodex, buildCodexMcpBlock } from "./generate.js"; +import type { MCPConfig } from "../core/hub-config.js"; + +describe("splitEnvForCodex", () => { + it("returns empty result for undefined env", () => { + expect(splitEnvForCodex(undefined)).toEqual({ + literal: {}, + forwarded: [], + warnings: [], + }); + }); + + it("forwards a placeholder that matches its key", () => { + const result = splitEnvForCodex({ FIGMA_API_KEY: "${env:FIGMA_API_KEY}" }); + expect(result.forwarded).toEqual(["FIGMA_API_KEY"]); + expect(result.literal).toEqual({}); + expect(result.warnings).toEqual([]); + }); + + it("keeps non-placeholder values as literals", () => { + const result = splitEnvForCodex({ LOG_LEVEL: "info" }); + expect(result.literal).toEqual({ LOG_LEVEL: "info" }); + expect(result.forwarded).toEqual([]); + expect(result.warnings).toEqual([]); + }); + + it("warns when a placeholder references a different key", () => { + const result = splitEnvForCodex({ FOO: "${env:BAR}" }); + expect(result.forwarded).toEqual([]); + expect(result.literal).toEqual({}); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("FOO"); + expect(result.warnings[0]).toContain("${env:BAR}"); + }); + + it("forwards lowercase placeholder that matches its key", () => { + const result = splitEnvForCodex({ foo: "${env:foo}" }); + expect(result.forwarded).toEqual(["foo"]); + expect(result.warnings).toEqual([]); + }); + + it("handles a mix of forwarded, literal and mismatched entries", () => { + const result = splitEnvForCodex({ + TOKEN: "${env:TOKEN}", + LOG_LEVEL: "info", + MISMATCH: "${env:OTHER}", + }); + expect(result.forwarded).toEqual(["TOKEN"]); + expect(result.literal).toEqual({ LOG_LEVEL: "info" }); + expect(result.warnings).toHaveLength(1); + }); +}); + +describe("buildCodexMcpBlock", () => { + const build = (mcp: MCPConfig) => buildCodexMcpBlock(mcp.name, mcp); + + it("returns null when no url, image, command or package is set", () => { + expect(build({ name: "empty" })).toBeNull(); + }); + + it("builds an http block from url", () => { + const result = build({ name: "figma", url: "https://mcp.figma.com/mcp" }); + expect(result).not.toBeNull(); + expect(result!.block).toContain("[mcp_servers.figma]"); + expect(result!.block).toContain("url = 'https://mcp.figma.com/mcp'"); + expect(result!.warnings).toEqual([]); + }); + + it("warns when a url MCP declares auth (Codex needs bearer_token_env_var)", () => { + const result = build({ + name: "secure", + url: "https://mcp.example.com/mcp", + auth: "bearer", + }); + expect(result).not.toBeNull(); + expect(result!.warnings).toHaveLength(1); + expect(result!.warnings[0]).toContain("bearer_token_env_var"); + }); + + it("builds an npx block from package and appends args", () => { + const result = build({ + name: "framelink", + package: "figma-developer-mcp", + args: ["--stdio"], + }); + expect(result!.block).toContain("command = 'npx'"); + expect(result!.block).toContain("args = ['-y', 'figma-developer-mcp', '--stdio']"); + }); + + it("builds a direct command block", () => { + const result = build({ name: "bee", command: "bee", args: ["mcp", "serve"] }); + expect(result!.block).toContain("command = 'bee'"); + expect(result!.block).toContain("args = ['mcp', 'serve']"); + }); + + it("builds a docker block and forwards matching env placeholders", () => { + const result = build({ + name: "signoz", + image: "signoz/signoz-mcp-server:latest", + env: { SIGNOZ_URL: "${env:SIGNOZ_URL}", LOG_LEVEL: "info" }, + }); + expect(result!.block).toContain("command = 'docker'"); + expect(result!.block).toContain("'-e', 'SIGNOZ_URL'"); + expect(result!.block).toContain("'-e', 'LOG_LEVEL=info'"); + expect(result!.block).toContain("'signoz/signoz-mcp-server:latest'"); + }); + + it("emits env_vars and a literal env table for a package MCP", () => { + const result = build({ + name: "client-hub", + package: "@arvoretech/client-hub-mcp", + env: { CLIENT_HUB_API_TOKEN: "${env:CLIENT_HUB_API_TOKEN}", MODE: "prod" }, + }); + expect(result!.block).toContain("env_vars = ['CLIENT_HUB_API_TOKEN']"); + expect(result!.block).toContain("[mcp_servers.client-hub.env]"); + expect(result!.block).toContain("MODE = 'prod'"); + }); + + it("propagates env warnings prefixed with the server name", () => { + const result = build({ + name: "broken", + package: "some-mcp", + env: { FOO: "${env:BAR}" }, + }); + expect(result!.warnings).toHaveLength(1); + expect(result!.warnings[0]).toContain("broken"); + expect(result!.warnings[0]).toContain("FOO"); + }); +}); diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index ed30ab3..8a50e1a 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -926,40 +926,62 @@ function tomlArray(values: string[]): string { return `[${values.map((v) => tomlString(v)).join(", ")}]`; } -const ENV_PLACEHOLDER = /^\$\{env:([A-Z0-9_]+)\}$/; +const ENV_PLACEHOLDER = /^\$\{env:([A-Za-z0-9_]+)\}$/; +const ANY_ENV_PLACEHOLDER = /^\$\{env:.+\}$/; /** * Codex CLI stores MCP servers in `.codex/config.toml` using snake_case * `[mcp_servers.]` tables. Unlike Claude/Cursor, Codex does not expand * `${env:VAR}` inside the `env` map — host env vars must be whitelisted via * `env_vars` and are forwarded by name instead. + * + * A placeholder can only be forwarded when it references the exact same name as + * its key (`FOO: ${env:FOO}`). Mismatched (`FOO: ${env:BAR}`) or malformed + * placeholders cannot be represented in Codex's model, so they are reported as + * warnings instead of being written literally (which Codex would not expand). */ -function splitEnvForCodex(env: Record | undefined): { +export function splitEnvForCodex(env: Record | undefined): { literal: Record; forwarded: string[]; + warnings: string[]; } { const literal: Record = {}; const forwarded: string[] = []; + const warnings: string[] = []; for (const [key, raw] of Object.entries(env ?? {})) { const match = ENV_PLACEHOLDER.exec(raw); if (match && match[1] === key) { forwarded.push(key); + } else if (ANY_ENV_PLACEHOLDER.test(raw)) { + warnings.push( + `env "${key}": placeholder "${raw}" cannot be forwarded (Codex only supports \${env:${key}}); omitting it` + ); } else { literal[key] = raw; } } - return { literal, forwarded }; + return { literal, forwarded, warnings }; } -function buildCodexMcpBlock(name: string, mcp: MCPConfig): string | null { +export function buildCodexMcpBlock( + name: string, + mcp: MCPConfig +): { block: string; warnings: string[] } | null { const lines: string[] = [`[mcp_servers.${name}]`]; + const warnings: string[] = []; if (mcp.url) { lines.push(`url = ${tomlString(mcp.url)}`); - return lines.join("\n"); + if (mcp.auth) { + warnings.push( + `"${name}": auth is configured but Codex needs a bearer_token_env_var; set it manually in .codex/config.toml` + ); + } + return { block: lines.join("\n"), warnings }; } - const { literal, forwarded } = splitEnvForCodex(mcp.env); + const { literal, forwarded, warnings: envWarnings } = splitEnvForCodex(mcp.env); + warnings.push(...envWarnings.map((w) => `"${name}": ${w}`)); if (mcp.image) { const args = ["run", "-i", "--rm"]; @@ -993,7 +1015,7 @@ function buildCodexMcpBlock(name: string, mcp: MCPConfig): string | null { } } - return lines.join("\n"); + return { block: lines.join("\n"), warnings }; } async function generateCodex(config: HubConfig, hubDir: string) { @@ -1056,12 +1078,15 @@ async function generateCodex(config: HubConfig, hubDir: string) { const { upstreamsJson, collectedEnv } = buildProxyUpstreams(mcp, config.mcps); resolved = { ...mcp, env: { MCP_PROXY_UPSTREAMS: upstreamsJson, ...collectedEnv } }; } - const block = buildCodexMcpBlock(mcp.name, resolved); - if (block === null) { + const result = buildCodexMcpBlock(mcp.name, resolved); + if (result === null) { console.log(chalk.yellow(` Skipping MCP "${mcp.name}": no url, image, or package configured`)); continue; } - blocks.push(block, ""); + for (const warning of result.warnings) { + console.log(chalk.yellow(` ${warning}`)); + } + blocks.push(result.block, ""); } await writeFile( join(codexDir, "config.toml"), From becdf41ee75bbc123c00654207778c76a23e9f7e Mon Sep 17 00:00:00 2001 From: rafaelandrade Date: Mon, 13 Jul 2026 21:42:21 -0300 Subject: [PATCH 3/3] refactor(codex): extract pure TOML helpers to codex-config module The unit tests imported generate.ts, which imports @arvoretech/hub-core (a workspace dep only present once built). CI runs test:cov before the build step, so Vite could not resolve the import and the suite failed. Move the pure helpers (tomlString, tomlArray, splitEnvForCodex, buildCodexMcpBlock) into commands/codex-config.ts with no heavy deps; generate.ts imports from there and tests target the module directly. --- ...{generate.test.ts => codex-config.test.ts} | 2 +- packages/cli/src/commands/codex-config.ts | 105 ++++++++++++++++++ packages/cli/src/commands/generate.ts | 105 +----------------- 3 files changed, 107 insertions(+), 105 deletions(-) rename packages/cli/src/commands/{generate.test.ts => codex-config.test.ts} (98%) create mode 100644 packages/cli/src/commands/codex-config.ts diff --git a/packages/cli/src/commands/generate.test.ts b/packages/cli/src/commands/codex-config.test.ts similarity index 98% rename from packages/cli/src/commands/generate.test.ts rename to packages/cli/src/commands/codex-config.test.ts index eba1e95..6038787 100644 --- a/packages/cli/src/commands/generate.test.ts +++ b/packages/cli/src/commands/codex-config.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { splitEnvForCodex, buildCodexMcpBlock } from "./generate.js"; +import { splitEnvForCodex, buildCodexMcpBlock } from "./codex-config.js"; import type { MCPConfig } from "../core/hub-config.js"; describe("splitEnvForCodex", () => { diff --git a/packages/cli/src/commands/codex-config.ts b/packages/cli/src/commands/codex-config.ts new file mode 100644 index 0000000..47b7382 --- /dev/null +++ b/packages/cli/src/commands/codex-config.ts @@ -0,0 +1,105 @@ +import type { MCPConfig } from "../core/hub-config.js"; + +export function tomlString(value: string): string { + if (!value.includes("'") && !value.includes("\n")) { + return `'${value}'`; + } + const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n"); + return `"${escaped}"`; +} + +export function tomlArray(values: string[]): string { + return `[${values.map((v) => tomlString(v)).join(", ")}]`; +} + +const ENV_PLACEHOLDER = /^\$\{env:([A-Za-z0-9_]+)\}$/; +const ANY_ENV_PLACEHOLDER = /^\$\{env:.+\}$/; + +/** + * Codex CLI stores MCP servers in `.codex/config.toml` using snake_case + * `[mcp_servers.]` tables. Unlike Claude/Cursor, Codex does not expand + * `${env:VAR}` inside the `env` map — host env vars must be whitelisted via + * `env_vars` and are forwarded by name instead. + * + * A placeholder can only be forwarded when it references the exact same name as + * its key (`FOO: ${env:FOO}`). Mismatched (`FOO: ${env:BAR}`) or malformed + * placeholders cannot be represented in Codex's model, so they are reported as + * warnings instead of being written literally (which Codex would not expand). + */ +export function splitEnvForCodex(env: Record | undefined): { + literal: Record; + forwarded: string[]; + warnings: string[]; +} { + const literal: Record = {}; + const forwarded: string[] = []; + const warnings: string[] = []; + for (const [key, raw] of Object.entries(env ?? {})) { + const match = ENV_PLACEHOLDER.exec(raw); + if (match && match[1] === key) { + forwarded.push(key); + } else if (ANY_ENV_PLACEHOLDER.test(raw)) { + warnings.push( + `env "${key}": placeholder "${raw}" cannot be forwarded (Codex only supports \${env:${key}}); omitting it` + ); + } else { + literal[key] = raw; + } + } + return { literal, forwarded, warnings }; +} + +export function buildCodexMcpBlock( + name: string, + mcp: MCPConfig +): { block: string; warnings: string[] } | null { + const lines: string[] = [`[mcp_servers.${name}]`]; + const warnings: string[] = []; + + if (mcp.url) { + lines.push(`url = ${tomlString(mcp.url)}`); + if (mcp.auth) { + warnings.push( + `"${name}": auth is configured but Codex needs a bearer_token_env_var; set it manually in .codex/config.toml` + ); + } + return { block: lines.join("\n"), warnings }; + } + + const { literal, forwarded, warnings: envWarnings } = splitEnvForCodex(mcp.env); + warnings.push(...envWarnings.map((w) => `"${name}": ${w}`)); + + if (mcp.image) { + const args = ["run", "-i", "--rm"]; + for (const [key, value] of Object.entries(mcp.env ?? {})) { + const match = ENV_PLACEHOLDER.exec(value); + if (match && match[1] === key) { + args.push("-e", key); + } else { + args.push("-e", `${key}=${value}`); + } + } + args.push(mcp.image); + lines.push(`command = 'docker'`, `args = ${tomlArray(args)}`); + } else if (mcp.command) { + lines.push(`command = ${tomlString(mcp.command)}`); + if (mcp.args?.length) lines.push(`args = ${tomlArray(mcp.args)}`); + } else if (mcp.package) { + lines.push(`command = 'npx'`, `args = ${tomlArray(["-y", mcp.package, ...(mcp.args || [])])}`); + } else { + return null; + } + + if (forwarded.length) { + lines.push(`env_vars = ${tomlArray(forwarded)}`); + } + const literalKeys = Object.keys(literal); + if (literalKeys.length) { + lines.push("", `[mcp_servers.${name}.env]`); + for (const key of literalKeys) { + lines.push(`${key} = ${tomlString(literal[key])}`); + } + } + + return { block: lines.join("\n"), warnings }; +} diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index 8a50e1a..a4dbf99 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -8,6 +8,7 @@ import { loadHubConfig, type HubConfig, type HookEntry, type MCPConfig } from ". import { getSavedEditor, saveGenerateState, getKiroMode, saveKiroMode, readCache, writeCache, checkOutdated, type KiroMode } from "../core/hub-cache.js"; import { fetchRemoteSources } from "../core/design-sources.js"; import { loadPersona, buildPersonaEditorFile } from "./persona.js"; +import { buildCodexMcpBlock } from "./codex-config.js"; import { buildCapabilitiesPrompt, resolvePiConfig } from "@arvoretech/hub-core"; const HUB_DOCS_URL = "https://hub.arvore.com.br/llms-full.txt"; @@ -914,110 +915,6 @@ async function generateClaudeCode(config: HubConfig, hubDir: string) { console.log(chalk.green(" Generated .gitignore")); } -function tomlString(value: string): string { - if (!value.includes("'") && !value.includes("\n")) { - return `'${value}'`; - } - const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n"); - return `"${escaped}"`; -} - -function tomlArray(values: string[]): string { - return `[${values.map((v) => tomlString(v)).join(", ")}]`; -} - -const ENV_PLACEHOLDER = /^\$\{env:([A-Za-z0-9_]+)\}$/; -const ANY_ENV_PLACEHOLDER = /^\$\{env:.+\}$/; - -/** - * Codex CLI stores MCP servers in `.codex/config.toml` using snake_case - * `[mcp_servers.]` tables. Unlike Claude/Cursor, Codex does not expand - * `${env:VAR}` inside the `env` map — host env vars must be whitelisted via - * `env_vars` and are forwarded by name instead. - * - * A placeholder can only be forwarded when it references the exact same name as - * its key (`FOO: ${env:FOO}`). Mismatched (`FOO: ${env:BAR}`) or malformed - * placeholders cannot be represented in Codex's model, so they are reported as - * warnings instead of being written literally (which Codex would not expand). - */ -export function splitEnvForCodex(env: Record | undefined): { - literal: Record; - forwarded: string[]; - warnings: string[]; -} { - const literal: Record = {}; - const forwarded: string[] = []; - const warnings: string[] = []; - for (const [key, raw] of Object.entries(env ?? {})) { - const match = ENV_PLACEHOLDER.exec(raw); - if (match && match[1] === key) { - forwarded.push(key); - } else if (ANY_ENV_PLACEHOLDER.test(raw)) { - warnings.push( - `env "${key}": placeholder "${raw}" cannot be forwarded (Codex only supports \${env:${key}}); omitting it` - ); - } else { - literal[key] = raw; - } - } - return { literal, forwarded, warnings }; -} - -export function buildCodexMcpBlock( - name: string, - mcp: MCPConfig -): { block: string; warnings: string[] } | null { - const lines: string[] = [`[mcp_servers.${name}]`]; - const warnings: string[] = []; - - if (mcp.url) { - lines.push(`url = ${tomlString(mcp.url)}`); - if (mcp.auth) { - warnings.push( - `"${name}": auth is configured but Codex needs a bearer_token_env_var; set it manually in .codex/config.toml` - ); - } - return { block: lines.join("\n"), warnings }; - } - - const { literal, forwarded, warnings: envWarnings } = splitEnvForCodex(mcp.env); - warnings.push(...envWarnings.map((w) => `"${name}": ${w}`)); - - if (mcp.image) { - const args = ["run", "-i", "--rm"]; - for (const [key, value] of Object.entries(mcp.env ?? {})) { - const match = ENV_PLACEHOLDER.exec(value); - if (match && match[1] === key) { - args.push("-e", key); - } else { - args.push("-e", `${key}=${value}`); - } - } - args.push(mcp.image); - lines.push(`command = 'docker'`, `args = ${tomlArray(args)}`); - } else if (mcp.command) { - lines.push(`command = ${tomlString(mcp.command)}`); - if (mcp.args?.length) lines.push(`args = ${tomlArray(mcp.args)}`); - } else if (mcp.package) { - lines.push(`command = 'npx'`, `args = ${tomlArray(["-y", mcp.package, ...(mcp.args || [])])}`); - } else { - return null; - } - - if (forwarded.length) { - lines.push(`env_vars = ${tomlArray(forwarded)}`); - } - const literalKeys = Object.keys(literal); - if (literalKeys.length) { - lines.push("", `[mcp_servers.${name}.env]`); - for (const key of literalKeys) { - lines.push(`${key} = ${tomlString(literal[key])}`); - } - } - - return { block: lines.join("\n"), warnings }; -} - async function generateCodex(config: HubConfig, hubDir: string) { const codexDir = join(hubDir, ".codex"); await mkdir(codexDir, { recursive: true });