diff --git a/src/cli/install-codex/codex-config-mcp.ts b/src/cli/install-codex/codex-config-mcp.ts new file mode 100644 index 000000000..741d2f98e --- /dev/null +++ b/src/cli/install-codex/codex-config-mcp.ts @@ -0,0 +1,15 @@ +import { appendBlock, findTomlSection } from "./toml-section-editor" + +const CONTEXT7_MCP_SERVER_HEADER = "mcp_servers.context7" +const CONTEXT7_MCP_SERVER_BLOCK = [ + `[${CONTEXT7_MCP_SERVER_HEADER}]`, + 'command = "npx"', + 'args = ["-y", "@upstash/context7-mcp", "--api-key", "YOUR_API_KEY"]', + "startup_timeout_sec = 20", + "", +].join("\n") + +export function ensureContext7McpServer(config: string): string { + if (findTomlSection(config, CONTEXT7_MCP_SERVER_HEADER)) return config + return appendBlock(config, CONTEXT7_MCP_SERVER_BLOCK) +} diff --git a/src/cli/install-codex/codex-config-permissions.ts b/src/cli/install-codex/codex-config-permissions.ts new file mode 100644 index 000000000..e750d42fc --- /dev/null +++ b/src/cli/install-codex/codex-config-permissions.ts @@ -0,0 +1,47 @@ +import { findTomlSection, removeSetting, replaceOrInsertSetting } from "./toml-section-editor" + +export function ensureAutonomousPermissions(config: string): string { + let next = replaceOrInsertRootSetting(config, "approval_policy", JSON.stringify("never")) + next = replaceOrInsertRootSetting(next, "sandbox_mode", JSON.stringify("danger-full-access")) + next = replaceOrInsertRootSetting(next, "network_access", JSON.stringify("enabled")) + next = removeWindowsSandboxSetting(next) + next = ensureNoticeEnabled(next, "hide_full_access_warning") + return ensureNoticeEnabled(next, "hide_world_writable_warning") +} + +function removeWindowsSandboxSetting(config: string): string { + const section = findTomlSection(config, "windows") + if (!section) return config + return removeSetting(config, section, "sandbox") +} + +function ensureNoticeEnabled(config: string, key: string): string { + const section = findTomlSection(config, "notice") + if (!section) return appendNoticeBlock(config, key) + return replaceOrInsertSetting(config, section, key, "true") +} + +function appendNoticeBlock(config: string, key: string): string { + return `${config.trimEnd()}${config.trimEnd().length > 0 ? "\n\n" : ""}[notice]\n${key} = true\n` +} + +function replaceOrInsertRootSetting(config: string, key: string, value: string): string { + const sectionStart = findFirstTableStart(config) + const root = config.slice(0, sectionStart) + const suffix = config.slice(sectionStart) + const linePattern = new RegExp(`^${escapeRegExp(key)}\\s*=.*$`, "m") + const replacement = linePattern.test(root) + ? root.replace(linePattern, `${key} = ${value}`) + : `${root.trimEnd()}${root.trimEnd().length > 0 ? "\n" : ""}${key} = ${value}\n` + if (suffix.length === 0) return replacement + return `${replacement.trimEnd()}\n\n${suffix.trimStart()}` +} + +function findFirstTableStart(config: string): number { + const match = config.match(/^[[].*$/m) + return match?.index ?? config.length +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} diff --git a/src/cli/install-codex/codex-config-toml.test.ts b/src/cli/install-codex/codex-config-toml.test.ts index 444187759..7fd8ed83f 100644 --- a/src/cli/install-codex/codex-config-toml.test.ts +++ b/src/cli/install-codex/codex-config-toml.test.ts @@ -111,6 +111,61 @@ describe("codex-config-toml", () => { expect(content).not.toContain("max_concurrent_threads_per_session = 4") }) + test("#given empty Codex config #when updating config #then installs Context7 MCP server", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-config-context7-")) + const configPath = join(root, "config.toml") + + // when + await updateCodexConfig({ + configPath, + repoRoot: "/repo/packages/omo-codex", + marketplaceName: "debug", + marketplaceSource: { sourceType: "local", source: "/repo/packages/omo-codex" }, + pluginNames: ["omo"], + }) + + // then + const content = await readFile(configPath, "utf8") + expect(content).toContain("[mcp_servers.context7]") + expect(content).toContain('command = "npx"') + expect(content).toContain('args = ["-y", "@upstash/context7-mcp", "--api-key", "YOUR_API_KEY"]') + expect(content).toContain("startup_timeout_sec = 20") + }) + + test("#given existing Context7 MCP server #when updating config #then preserves user server settings", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-config-context7-existing-")) + const configPath = join(root, "config.toml") + await writeFile( + configPath, + [ + "[mcp_servers.context7]", + 'command = "node"', + 'args = ["/opt/context7/server.js"]', + "startup_timeout_sec = 40", + "", + ].join("\n"), + ) + + // when + await updateCodexConfig({ + configPath, + repoRoot: "/repo/packages/omo-codex", + marketplaceName: "debug", + marketplaceSource: { sourceType: "local", source: "/repo/packages/omo-codex" }, + pluginNames: ["omo"], + }) + + // then + const content = await readFile(configPath, "utf8") + expect(content).toContain("[mcp_servers.context7]") + expect(content).toContain('command = "node"') + expect(content).toContain('args = ["/opt/context7/server.js"]') + expect(content).toContain("startup_timeout_sec = 40") + expect(content).not.toContain("YOUR_API_KEY") + }) + test("#given legacy boolean MultiAgentV2 flag and table #when updating config #then normalizes to table config", async () => { // given const root = await mkdtemp(join(tmpdir(), "omo-codex-config-multi-agent-legacy-")) diff --git a/src/cli/install-codex/codex-config-toml.ts b/src/cli/install-codex/codex-config-toml.ts index ac3a60bdd..635eb0569 100644 --- a/src/cli/install-codex/codex-config-toml.ts +++ b/src/cli/install-codex/codex-config-toml.ts @@ -1,7 +1,9 @@ import { mkdir, readFile, writeFile } from "node:fs/promises" import { dirname } from "node:path" +import { ensureContext7McpServer } from "./codex-config-mcp" +import { ensureAutonomousPermissions } from "./codex-config-permissions" import { ensureCodexMultiAgentV2Config } from "./codex-multi-agent-v2-config" -import { appendBlock, findTomlSection, removeSetting, replaceOrInsertSetting } from "./toml-section-editor" +import { appendBlock, findTomlSection, replaceOrInsertSetting } from "./toml-section-editor" import type { CodexAgentConfig, CodexMarketplaceSource, TrustedHookState } from "./types" const SISYPHUS_LEGACY_MARKETPLACES = ["lazycodex", "code-yeongyu-codex-plugins"] as const @@ -43,6 +45,7 @@ export async function updateCodexConfig(input: { config = ensureFeatureEnabled(config, "plugins") config = ensureFeatureEnabled(config, "plugin_hooks") config = ensureCodexMultiAgentV2Config(config) + config = ensureContext7McpServer(config) if (input.autonomousPermissions === true) config = ensureAutonomousPermissions(config) config = ensureMarketplaceBlock(config, input.marketplaceName, input.marketplaceSource) for (const pluginName of input.pluginNames) { @@ -111,48 +114,6 @@ function ensureFeatureEnabled(config: string, featureName: string): string { return replaceOrInsertSetting(config, section, featureName, "true") } -function ensureAutonomousPermissions(config: string): string { - let next = replaceOrInsertRootSetting(config, "approval_policy", JSON.stringify("never")) - next = replaceOrInsertRootSetting(next, "sandbox_mode", JSON.stringify("danger-full-access")) - next = replaceOrInsertRootSetting(next, "network_access", JSON.stringify("enabled")) - next = removeWindowsSandboxSetting(next) - next = ensureNoticeEnabled(next, "hide_full_access_warning") - return ensureNoticeEnabled(next, "hide_world_writable_warning") -} - -function removeWindowsSandboxSetting(config: string): string { - const section = findTomlSection(config, "windows") - if (!section) return config - return removeSetting(config, section, "sandbox") -} - -function ensureNoticeEnabled(config: string, key: string): string { - const section = findTomlSection(config, "notice") - if (!section) return appendBlock(config, `[notice]\n${key} = true\n`) - return replaceOrInsertSetting(config, section, key, "true") -} - -function replaceOrInsertRootSetting(config: string, key: string, value: string): string { - const sectionStart = findFirstTableStart(config) - const root = config.slice(0, sectionStart) - const suffix = config.slice(sectionStart) - const linePattern = new RegExp(`^${escapeRegExp(key)}\\s*=.*$`, "m") - const replacement = linePattern.test(root) - ? root.replace(linePattern, `${key} = ${value}`) - : `${root.trimEnd()}${root.trimEnd().length > 0 ? "\n" : ""}${key} = ${value}\n` - if (suffix.length === 0) return replacement - return `${replacement.trimEnd()}\n\n${suffix.trimStart()}` -} - -function findFirstTableStart(config: string): number { - const match = config.match(/^[[].*$/m) - return match?.index ?? config.length -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") -} - function ensureMarketplaceBlock(config: string, marketplaceName: string, source: CodexMarketplaceSource): string { const header = `marketplaces.${marketplaceName}` const lines = [