diff --git a/bin/oh-my-opencode.js b/bin/oh-my-opencode.js index 75b5794ce..596005026 100755 --- a/bin/oh-my-opencode.js +++ b/bin/oh-my-opencode.js @@ -5,6 +5,7 @@ import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { createRequire } from "node:module"; +import { basename } from "node:path"; import { getPlatformPackageCandidates, getBinaryPath } from "./platform.js"; const require = createRequire(import.meta.url); @@ -80,6 +81,23 @@ function getPackageBaseName() { } } +/** + * Determine which bin name the user invoked us with (oh-my-opencode, oh-my-openagent, omo, lazycodex). + * Propagated to the compiled CLI binary via OMO_INVOCATION_NAME so it can route accordingly + * (e.g. `lazycodex` defaults to the Codex install flow). + * @returns {string} + */ +function getInvocationName() { + if (process.env.OMO_INVOCATION_NAME) { + return process.env.OMO_INVOCATION_NAME; + } + const argv1 = process.argv[1] ?? ""; + if (!argv1) { + return "oh-my-opencode"; + } + return basename(argv1, ".js").replace(/\.exe$/, ""); +} + function main() { const { platform, arch } = process; const libcFamily = getLibcFamily(); @@ -119,11 +137,18 @@ function main() { process.exit(1); } + const invocationName = getInvocationName(); + const childEnv = { + ...process.env, + OMO_INVOCATION_NAME: invocationName, + }; + for (let index = 0; index < resolvedBinaries.length; index += 1) { const currentBinary = resolvedBinaries[index]; const hasFallback = index < resolvedBinaries.length - 1; const result = spawnSync(currentBinary.binPath, process.argv.slice(2), { stdio: "inherit", + env: childEnv, }); if (result.error) { diff --git a/src/cli/cli-installer.telemetry.test.ts b/src/cli/cli-installer.telemetry.test.ts index 772173910..5a7927d10 100644 --- a/src/cli/cli-installer.telemetry.test.ts +++ b/src/cli/cli-installer.telemetry.test.ts @@ -18,6 +18,7 @@ describe("runCliInstaller telemetry isolation", () => { hasOpenAI: false, hasGemini: false, hasCopilot: false, + hasCodex: false, hasOpencodeZen: false, hasZaiCodingPlan: false, hasKimiForCoding: false, diff --git a/src/cli/cli-installer.test.ts b/src/cli/cli-installer.test.ts index 3fbb21f20..b3473b550 100644 --- a/src/cli/cli-installer.test.ts +++ b/src/cli/cli-installer.test.ts @@ -33,6 +33,7 @@ describe("runCliInstaller", () => { hasOpenAI: false, hasGemini: false, hasCopilot: false, + hasCodex: false, hasOpencodeZen: false, hasZaiCodingPlan: false, hasKimiForCoding: false, @@ -80,6 +81,7 @@ describe("runCliInstaller", () => { hasOpenAI: false, hasGemini: false, hasCopilot: false, + hasCodex: false, hasOpencodeZen: false, hasZaiCodingPlan: false, hasKimiForCoding: false, diff --git a/src/cli/cli-installer.ts b/src/cli/cli-installer.ts index 029215757..3d8c56fb4 100644 --- a/src/cli/cli-installer.ts +++ b/src/cli/cli-installer.ts @@ -23,6 +23,7 @@ import { validateNonTuiArgs, } from "./install-validators" import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version" +import { runCodexInstaller } from "./install-codex" export async function runCliInstaller(args: InstallArgs, version: string): Promise { const validation = validateNonTuiArgs(args) @@ -115,6 +116,18 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi console.log(` Run ${color.cyan("opencode")} to start!`) console.log() + if (config.hasCodex) { + printInfo("Installing Codex harness adapter...") + try { + const codexResult = await runCodexInstaller() + printSuccess(`Codex plugin installed ${SYMBOLS.arrow} ${color.dim(codexResult.configPath)}`) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + printWarning(`Codex install failed (OpenCode install is still complete): ${message}`) + } + console.log() + } + printInfo( "Anonymous telemetry is enabled by default. Disable it with OMO_SEND_ANONYMOUS_TELEMETRY=0 or OMO_DISABLE_POSTHOG=1.", ) diff --git a/src/cli/cli-program.ts b/src/cli/cli-program.ts index 96519bd48..57a5a57ce 100644 --- a/src/cli/cli-program.ts +++ b/src/cli/cli-program.ts @@ -16,6 +16,41 @@ const VERSION = packageJson.version const program = new Command() +type InstallCommandOptions = { + readonly tui?: boolean + readonly claude?: InstallArgs["claude"] + readonly openai?: InstallArgs["openai"] + readonly gemini?: InstallArgs["gemini"] + readonly copilot?: InstallArgs["copilot"] + readonly codex?: InstallArgs["codex"] + readonly opencodeZen?: InstallArgs["opencodeZen"] + readonly zaiCodingPlan?: InstallArgs["zaiCodingPlan"] + readonly kimiForCoding?: InstallArgs["kimiForCoding"] + readonly opencodeGo?: InstallArgs["opencodeGo"] + readonly vercelAiGateway?: InstallArgs["vercelAiGateway"] + readonly skipAuth?: boolean +} + +export function resolveInstallArgs( + options: InstallCommandOptions, + invocationName: string | undefined = process.env.OMO_INVOCATION_NAME, +): InstallArgs { + return { + tui: options.tui !== false, + claude: options.claude, + openai: options.openai, + gemini: options.gemini, + copilot: options.copilot, + codex: options.codex ?? (invocationName === "lazycodex" ? "yes" : undefined), + opencodeZen: options.opencodeZen, + zaiCodingPlan: options.zaiCodingPlan, + kimiForCoding: options.kimiForCoding, + opencodeGo: options.opencodeGo, + vercelAiGateway: options.vercelAiGateway, + skipAuth: options.skipAuth ?? false, + } +} + program .name("oh-my-opencode") .description("The ultimate OpenCode plugin - multi-model orchestration, LSP tools, and more") @@ -32,16 +67,19 @@ program .option("--openai ", "OpenAI/ChatGPT subscription: no, yes (default: no)") .option("--gemini ", "Gemini integration: no, yes") .option("--copilot ", "GitHub Copilot subscription: no, yes") + .option("--codex ", "Install Codex harness adapter: no, yes (default: no)") .option("--opencode-zen ", "OpenCode Zen access: no, yes (default: no)") .option("--zai-coding-plan ", "Z.ai Coding Plan subscription: no, yes (default: no)") .option("--kimi-for-coding ", "Kimi For Coding subscription: no, yes (default: no)") .option("--opencode-go ", "OpenCode Go subscription: no, yes (default: no)") .option("--vercel-ai-gateway ", "Vercel AI Gateway: no, yes (default: no)") .option("--skip-auth", "Skip authentication setup hints") - .addHelpText("after", ` +.addHelpText("after", ` Examples: $ bunx oh-my-opencode install - $ bunx oh-my-opencode install --no-tui --claude=max20 --openai=yes --gemini=yes --copilot=no + $ bunx lazycodex install --no-tui --claude=yes --gemini=no --copilot=no + $ bunx oh-my-opencode install --no-tui --claude=max20 --openai=yes --gemini=yes --copilot=no --codex=yes + $ omo install --codex=yes $ bunx oh-my-opencode install --no-tui --claude=no --gemini=no --copilot=yes --opencode-zen=yes Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi > Vercel): @@ -55,19 +93,7 @@ Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi > Verce Vercel vercel/ models (universal proxy, always last fallback) `) .action(async (options) => { - const args: InstallArgs = { - tui: options.tui !== false, - claude: options.claude, - openai: options.openai, - gemini: options.gemini, - copilot: options.copilot, - opencodeZen: options.opencodeZen, - zaiCodingPlan: options.zaiCodingPlan, - kimiForCoding: options.kimiForCoding, - opencodeGo: options.opencodeGo, - vercelAiGateway: options.vercelAiGateway, - skipAuth: options.skipAuth ?? false, - } + const args = resolveInstallArgs(options) const exitCode = await install(args) process.exit(exitCode) }) diff --git a/src/cli/config-manager/detect-current-config.ts b/src/cli/config-manager/detect-current-config.ts index e1fb39823..bc3034b9a 100644 --- a/src/cli/config-manager/detect-current-config.ts +++ b/src/cli/config-manager/detect-current-config.ts @@ -79,6 +79,7 @@ export function detectCurrentConfig(): DetectedConfig { hasOpenAI: true, hasGemini: false, hasCopilot: false, + hasCodex: false, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false, diff --git a/src/cli/install-codex/codex-cache.test.ts b/src/cli/install-codex/codex-cache.test.ts new file mode 100644 index 000000000..e08c10c8e --- /dev/null +++ b/src/cli/install-codex/codex-cache.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, readFile, readlink, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { linkCachedPluginBins, rewriteCachedMcpManifest } from "./codex-cache" + +describe("codex-cache", () => { + test("rewrites cached mcp manifest relative args and cwd", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-cache-")) + await writeFile( + join(root, ".mcp.json"), + JSON.stringify({ mcpServers: { lsp: { cwd: ".", args: ["./components/lsp/dist/cli.js", "mcp"] } } }), + ) + + // when + await rewriteCachedMcpManifest(root) + + // then + const rewritten = JSON.parse(await readFile(join(root, ".mcp.json"), "utf8")) as { + mcpServers: { lsp: { cwd?: string; args: string[] } } + } + expect(rewritten.mcpServers.lsp.cwd).toBeUndefined() + expect(rewritten.mcpServers.lsp.args[0]).toBe(join(root, "./components/lsp/dist/cli.js")) + }) + + test("links cached plugin bins and stays idempotent", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-cache-")) + const pluginRoot = join(root, "plugin") + const binDir = join(root, "bin") + await mkdir(pluginRoot, { recursive: true }) + await writeFile(join(pluginRoot, "package.json"), JSON.stringify({ name: "@scope/omo", bin: { "omo-hook": "dist/cli.js" } })) + await mkdir(join(pluginRoot, "dist"), { recursive: true }) + await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n") + + // when + const first = await linkCachedPluginBins({ binDir, pluginRoot }) + const second = await linkCachedPluginBins({ binDir, pluginRoot }) + + // then + expect(first).toHaveLength(1) + expect(second).toHaveLength(1) + const linkedTarget = await readlink(join(binDir, "omo-hook")) + expect(linkedTarget).toBe(join(pluginRoot, "dist", "cli.js")) + }) +}) diff --git a/src/cli/install-codex/codex-cache.ts b/src/cli/install-codex/codex-cache.ts new file mode 100644 index 000000000..56c1d5034 --- /dev/null +++ b/src/cli/install-codex/codex-cache.ts @@ -0,0 +1,180 @@ +import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, symlink, writeFile } from "node:fs/promises" +import { basename, dirname, join, sep } from "node:path" +import type { InstalledPlugin, RunCommand } from "./types" + +export async function installCachedPlugin(input: { + readonly codexHome: string + readonly marketplaceName: string + readonly name: string + readonly sourcePath: string + readonly version: string + readonly runCommand: RunCommand +}): Promise { + await maybeRunNpmInstall(input.sourcePath, input.runCommand) + await maybeRunNpmBuild(input.sourcePath, input.runCommand) + + const targetPath = join(input.codexHome, "plugins", "cache", input.marketplaceName, input.name, input.version) + await replaceDirectory(input.sourcePath, targetPath) + await maybeRunNpmInstall(targetPath, input.runCommand, ["install", "--omit=dev"]) + await rewriteCachedMcpManifest(targetPath) + return { name: input.name, version: input.version, path: targetPath } +} + +export async function pruneMarketplaceCache(input: { + readonly codexHome: string + readonly marketplaceName: string + readonly keepPluginNames: readonly string[] +}): Promise { + const cacheRoot = join(input.codexHome, "plugins", "cache", input.marketplaceName) + if (!(await exists(cacheRoot))) return + const keep = new Set(input.keepPluginNames) + const entries = await readdir(cacheRoot, { withFileTypes: true }) + for (const entry of entries) { + if (!entry.isDirectory() || keep.has(entry.name)) continue + await rm(join(cacheRoot, entry.name), { recursive: true, force: true }) + } +} + +export async function linkCachedPluginBins(input: { + readonly binDir: string + readonly pluginRoot: string +}): Promise { + const binLinks = await discoverPackageBins(input.pluginRoot) + await mkdir(input.binDir, { recursive: true }) + const linked: Array<{ name: string; path: string; target: string }> = [] + for (const link of binLinks) { + const linkPath = join(input.binDir, link.name) + await replaceSymlink(linkPath, link.target) + linked.push({ name: link.name, path: linkPath, target: link.target }) + } + return linked +} + +export async function rewriteCachedMcpManifest(pluginRoot: string): Promise { + const manifestPath = join(pluginRoot, ".mcp.json") + if (!(await exists(manifestPath))) return + const raw = await readFile(manifestPath, "utf8") + const parsed: unknown = JSON.parse(raw) + if (!isRecord(parsed) || !isRecord(parsed.mcpServers)) return + let changed = false + for (const server of Object.values(parsed.mcpServers)) { + if (!isRecord(server)) continue + if (server.cwd === "." || server.cwd === "./") { + delete server.cwd + changed = true + } + const currentArgs = server.args + if (!Array.isArray(currentArgs)) continue + const nextArgs = currentArgs.map((arg) => { + if (typeof arg !== "string") return arg + if (arg.startsWith("./") || arg.startsWith("../")) return join(pluginRoot, arg) + return arg + }) + if (nextArgs.some((value, index) => value !== currentArgs[index])) { + server.args = nextArgs + changed = true + } + } + if (changed) await writeFile(manifestPath, `${JSON.stringify(parsed, null, "\t")}\n`) +} + +async function maybeRunNpmInstall(cwd: string, runCommand: RunCommand, args: readonly string[] = ["install"]): Promise { + if (!(await exists(join(cwd, "package.json")))) return + await runCommand("npm", args, { cwd }) +} + +async function maybeRunNpmBuild(cwd: string, runCommand: RunCommand): Promise { + if (!(await exists(join(cwd, "package.json")))) return + const packageJson: unknown = JSON.parse(await readFile(join(cwd, "package.json"), "utf8")) + if (!isRecord(packageJson)) return + const scripts = packageJson.scripts + if (!isRecord(scripts) || typeof scripts.build !== "string") return + await runCommand("npm", ["run", "build"], { cwd }) +} + +async function replaceDirectory(sourcePath: string, targetPath: string): Promise { + await mkdir(dirname(targetPath), { recursive: true }) + const tempPath = join(dirname(targetPath), `.tmp-${basename(targetPath)}-${process.pid}-${Date.now()}`) + await rm(tempPath, { recursive: true, force: true }) + await cp(sourcePath, tempPath, { recursive: true, filter: (source) => shouldCopyPluginPath(source, sourcePath) }) + await rm(targetPath, { recursive: true, force: true }) + await rename(tempPath, targetPath) +} + +async function discoverPackageBins(root: string): Promise { + const links: Array<{ name: string; target: string }> = [] + await collectPackageBins(root, root, links) + return links +} + +async function collectPackageBins(directory: string, root: string, links: Array<{ name: string; target: string }>): Promise { + const entries = await readdir(directory, { withFileTypes: true }) + if (entries.some((entry) => entry.isFile() && entry.name === "package.json")) { + await appendPackageBinLinks(join(directory, "package.json"), directory, links) + } + for (const entry of entries) { + if (!entry.isDirectory()) continue + if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") continue + const childPath = join(directory, entry.name) + if (!childPath.startsWith(root)) continue + await collectPackageBins(childPath, root, links) + } +} + +async function appendPackageBinLinks(packageJsonPath: string, packageRoot: string, links: Array<{ name: string; target: string }>): Promise { + const packageJson: unknown = JSON.parse(await readFile(packageJsonPath, "utf8")) + if (!isRecord(packageJson)) return + const packageName = packageJson.name + const packageBin = packageJson.bin + if (typeof packageBin === "string" && typeof packageName === "string") { + links.push({ name: basename(packageName), target: join(packageRoot, packageBin) }) + return + } + if (!isRecord(packageBin)) return + for (const [name, target] of Object.entries(packageBin)) { + if (typeof target !== "string") continue + links.push({ name, target: join(packageRoot, target) }) + } +} + +async function replaceSymlink(linkPath: string, targetPath: string): Promise { + if (await existingNonSymlink(linkPath)) throw new Error(`${linkPath} already exists and is not a symlink`) + await rm(linkPath, { force: true }) + await symlink(targetPath, linkPath) +} + +async function existingNonSymlink(path: string): Promise { + try { + const stat = await lstat(path) + if (!stat.isSymbolicLink()) return true + await readlink(path) + return false + } catch (error) { + if (isNodeErrorWithCode(error) && error.code === "ENOENT") return false + throw error + } +} + +function shouldCopyPluginPath(path: string, root: string): boolean { + const relative = path === root ? "" : path.slice(root.length + sep.length) + if (relative === "") return true + const parts = relative.split(sep) + return !parts.some((part) => part === ".git" || part === "node_modules") +} + +async function exists(path: string): Promise { + try { + await lstat(path) + return true + } catch { + return false + } +} + +function isNodeErrorWithCode(error: unknown): error is NodeJS.ErrnoException { + return typeof error === "object" && error !== null && "code" in error +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/src/cli/install-codex/codex-config-toml.test.ts b/src/cli/install-codex/codex-config-toml.test.ts new file mode 100644 index 000000000..cdffb487a --- /dev/null +++ b/src/cli/install-codex/codex-config-toml.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test" +import { mkdtemp, readFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { updateCodexConfig } from "./codex-config-toml" + +describe("codex-config-toml", () => { + test("writes config blocks and stays idempotent", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-config-")) + const configPath = join(root, "config.toml") + + // when + await updateCodexConfig({ + configPath, + repoRoot: "/repo/packages/omo-codex", + marketplaceName: "code-yeongyu-codex-plugins", + pluginNames: ["omo"], + trustedHookStates: [{ key: "omo@code-yeongyu-codex-plugins:hooks/hooks.json:post_tool_use:0:0", trustedHash: "sha256:abc" }], + }) + await updateCodexConfig({ + configPath, + repoRoot: "/repo/packages/omo-codex", + marketplaceName: "code-yeongyu-codex-plugins", + pluginNames: ["omo"], + trustedHookStates: [{ key: "omo@code-yeongyu-codex-plugins:hooks/hooks.json:post_tool_use:0:0", trustedHash: "sha256:abc" }], + }) + + // then + const content = await readFile(configPath, "utf8") + expect(content).toContain("[features]") + expect(content).toContain("plugins = true") + expect(content).toContain("plugin_hooks = true") + expect(content).toContain("[marketplaces.code-yeongyu-codex-plugins]") + expect(content).toContain("[plugins.\"omo@code-yeongyu-codex-plugins\"]") + expect(content).toContain("[hooks.state.\"omo@code-yeongyu-codex-plugins:hooks/hooks.json:post_tool_use:0:0\"]") + }) +}) diff --git a/src/cli/install-codex/codex-config-toml.ts b/src/cli/install-codex/codex-config-toml.ts new file mode 100644 index 000000000..7ddecd287 --- /dev/null +++ b/src/cli/install-codex/codex-config-toml.ts @@ -0,0 +1,190 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises" +import { dirname } from "node:path" +import type { TrustedHookState } from "./types" + +export async function updateCodexConfig(input: { + readonly configPath: string + readonly repoRoot: string + readonly marketplaceName: string + readonly pluginNames: readonly string[] + readonly trustedHookStates?: readonly TrustedHookState[] +}): Promise { + await mkdir(dirname(input.configPath), { recursive: true }) + let config = "" + if (await exists(input.configPath)) config = await readFile(input.configPath, "utf8") + + const pluginSet = new Set(input.pluginNames) + config = removeStaleMarketplacePluginBlocks(config, input.marketplaceName, pluginSet) + config = removeStaleMarketplaceHookStateBlocks(config, input.marketplaceName, pluginSet) + config = ensureFeatureEnabled(config, "plugins") + config = ensureFeatureEnabled(config, "plugin_hooks") + config = ensureMarketplaceBlock(config, input.marketplaceName, input.repoRoot) + for (const pluginName of input.pluginNames) { + config = ensurePluginEnabled(config, `${pluginName}@${input.marketplaceName}`) + } + for (const state of input.trustedHookStates ?? []) { + config = ensureHookTrusted(config, state.key, state.trustedHash) + } + + await writeFile(input.configPath, `${config.trimEnd()}\n`) +} + +function removeStaleMarketplacePluginBlocks(config: string, marketplaceName: string, keepPluginNames: Set): string { + return removeTomlSections(config, (header) => { + const pluginKey = parseQuotedPluginHeader(header) + if (pluginKey === null) return false + const suffix = `@${marketplaceName}` + if (!pluginKey.endsWith(suffix)) return false + return !keepPluginNames.has(pluginKey.slice(0, -suffix.length)) + }) +} + +function removeStaleMarketplaceHookStateBlocks(config: string, marketplaceName: string, keepPluginNames: Set): string { + return removeTomlSections(config, (header) => { + const prefix = "hooks.state." + if (!header.startsWith(prefix)) return false + const hookKey = parseJsonString(header.slice(prefix.length)) + if (hookKey === null) return false + const separator = hookKey.indexOf(":") + if (separator === -1) return false + const pluginKey = hookKey.slice(0, separator) + const suffix = `@${marketplaceName}` + if (!pluginKey.endsWith(suffix)) return false + return !keepPluginNames.has(pluginKey.slice(0, -suffix.length)) + }) +} + +function ensureFeatureEnabled(config: string, featureName: string): string { + const section = findTomlSection(config, "features") + if (!section) return appendBlock(config, `[features]\n${featureName} = true\n`) + return replaceOrInsertSetting(config, section, featureName, "true") +} + +function ensureMarketplaceBlock(config: string, marketplaceName: string, repoRoot: string): string { + const header = `marketplaces.${marketplaceName}` + if (findTomlSection(config, header)) return config + return appendBlock( + config, + [ + `[${header}]`, + `last_updated = "${new Date().toISOString().replace(/\.\d{3}Z$/, "Z")}"`, + 'source_type = "local"', + `source = ${JSON.stringify(repoRoot)}`, + "", + ].join("\n"), + ) +} + +function ensurePluginEnabled(config: string, pluginKey: string): string { + const header = `plugins.${JSON.stringify(pluginKey)}` + const section = findTomlSection(config, header) + if (!section) return appendBlock(config, `[${header}]\nenabled = true\n`) + return replaceOrInsertSetting(config, section, "enabled", "true") +} + +function ensureHookTrusted(config: string, key: string, trustedHash: string): string { + const header = `hooks.state.${JSON.stringify(key)}` + const section = findTomlSection(config, header) + if (!section) return appendBlock(config, `[${header}]\ntrusted_hash = ${JSON.stringify(trustedHash)}\n`) + return replaceOrInsertSetting(config, section, "trusted_hash", JSON.stringify(trustedHash)) +} + +function removeTomlSections(config: string, shouldRemove: (header: string) => boolean): string { + return splitTomlSections(config) + .filter((section) => section.header === null || !shouldRemove(section.header)) + .map((section) => section.text) + .join("") + .replace(/\n{3,}/g, "\n\n") +} + +function splitTomlSections(config: string): Array<{ header: string | null; text: string }> { + const lines = config.match(/[^\n]*\n?|$/g) ?? [] + const sections: Array<{ header: string | null; text: string }> = [] + let current: { header: string | null; text: string } = { header: null, text: "" } + for (const line of lines) { + if (line.length === 0) break + const header = parseTomlHeader(line) + if (header !== null) { + if (current.text.length > 0) sections.push(current) + current = { header, text: line } + } else { + current.text += line + } + } + if (current.text.length > 0) sections.push(current) + return sections +} + +function findTomlSection(config: string, header: string): { start: number; end: number; text: string } | null { + const headerLine = `[${header}]` + const lines = config.match(/[^\n]*\n?|$/g) ?? [] + let offset = 0 + let start = -1 + for (const line of lines) { + if (line.length === 0) break + const trimmed = line.trim() + if (start === -1) { + if (trimmed === headerLine) start = offset + } else if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + return { start, end: offset, text: config.slice(start, offset) } + } + offset += line.length + } + if (start === -1) return null + return { start, end: config.length, text: config.slice(start) } +} + +function replaceOrInsertSetting(config: string, section: { start: number; end: number; text: string }, key: string, value: string): string { + const linePattern = new RegExp(`^${escapeRegExp(key)}\\s*=.*$`, "m") + const replacement = linePattern.test(section.text) + ? section.text.replace(linePattern, `${key} = ${value}`) + : insertSetting(section.text, key, value) + return config.slice(0, section.start) + replacement + config.slice(section.end) +} + +function insertSetting(sectionText: string, key: string, value: string): string { + const lines = sectionText.split("\n") + lines.splice(1, 0, `${key} = ${value}`) + return lines.join("\n") +} + +function parseTomlHeader(line: string): string | null { + const trimmed = line.trim() + if (!trimmed.startsWith("[") || !trimmed.endsWith("]") || trimmed.startsWith("[[")) return null + return trimmed.slice(1, -1) +} + +function parseQuotedPluginHeader(header: string): string | null { + const prefix = "plugins." + if (!header.startsWith(prefix)) return null + return parseJsonString(header.slice(prefix.length)) +} + +function parseJsonString(value: string): string | null { + try { + const parsed: unknown = JSON.parse(value) + return typeof parsed === "string" ? parsed : null + } catch (error) { + if (error instanceof Error) return null + return null + } +} + +function appendBlock(config: string, block: string): string { + const prefix = config.trimEnd() + return `${prefix}${prefix.length > 0 ? "\n\n" : ""}${block.trimEnd()}\n` +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +async function exists(path: string): Promise { + try { + await readFile(path, "utf8") + return true + } catch (error) { + if (error instanceof Error) return false + return false + } +} diff --git a/src/cli/install-codex/codex-hook-trust.test.ts b/src/cli/install-codex/codex-hook-trust.test.ts new file mode 100644 index 000000000..c5501bb6b --- /dev/null +++ b/src/cli/install-codex/codex-hook-trust.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test" +import { join } from "node:path" +import { trustedHookStatesForPlugin } from "./codex-hook-trust" + +describe("codex-hook-trust", () => { + test("computes trusted hook hashes for vendored plugin", async () => { + // given + const pluginRoot = join( + "/Users/yeongyu/local-workspaces/omodex", + "packages", + "omo-codex", + "plugin", + ) + + // when + const states = await trustedHookStatesForPlugin({ + marketplaceName: "code-yeongyu-codex-plugins", + pluginName: "omo", + pluginRoot, + }) + + // then + expect(states.length).toBeGreaterThan(0) + expect(states[0]?.trustedHash.startsWith("sha256:")).toBe(true) + }) +}) diff --git a/src/cli/install-codex/codex-hook-trust.ts b/src/cli/install-codex/codex-hook-trust.ts new file mode 100644 index 000000000..8432de86a --- /dev/null +++ b/src/cli/install-codex/codex-hook-trust.ts @@ -0,0 +1,95 @@ +import { createHash } from "node:crypto" +import { readFile } from "node:fs/promises" +import { join } from "node:path" +import type { TrustedHookState } from "./types" + +const EVENT_LABELS = new Map([ + ["PreToolUse", "pre_tool_use"], + ["PermissionRequest", "permission_request"], + ["PostToolUse", "post_tool_use"], + ["PreCompact", "pre_compact"], + ["PostCompact", "post_compact"], + ["SessionStart", "session_start"], + ["UserPromptSubmit", "user_prompt_submit"], + ["SubagentStart", "subagent_start"], + ["SubagentStop", "subagent_stop"], + ["Stop", "stop"], +]) + +export async function trustedHookStatesForPlugin(input: { + readonly marketplaceName: string + readonly pluginName: string + readonly pluginRoot: string +}): Promise { + const manifestPath = join(input.pluginRoot, ".codex-plugin", "plugin.json") + if (!(await exists(manifestPath))) return [] + const manifest: unknown = JSON.parse(await readFile(manifestPath, "utf8")) + if (!isRecord(manifest) || typeof manifest.hooks !== "string") return [] + + const hooksPath = join(input.pluginRoot, manifest.hooks) + if (!(await exists(hooksPath))) return [] + const parsed: unknown = JSON.parse(await readFile(hooksPath, "utf8")) + if (!isRecord(parsed) || !isRecord(parsed.hooks)) return [] + + const keySource = `${input.pluginName}@${input.marketplaceName}:${stripDotSlash(manifest.hooks)}` + const states: TrustedHookState[] = [] + for (const [eventName, groups] of Object.entries(parsed.hooks)) { + if (!Array.isArray(groups)) continue + const eventLabel = EVENT_LABELS.get(eventName) + if (eventLabel === undefined) continue + for (const [groupIndex, group] of groups.entries()) { + if (!isRecord(group) || !Array.isArray(group.hooks)) continue + for (const [handlerIndex, handler] of group.hooks.entries()) { + if (!isRecord(handler) || handler.type !== "command") continue + if (handler.async === true) continue + if (typeof handler.command !== "string" || handler.command.trim() === "") continue + const key = `${keySource}:${eventLabel}:${groupIndex}:${handlerIndex}` + states.push({ key, trustedHash: commandHookHash(eventLabel, group.matcher, handler) }) + } + } + } + return states +} + +function commandHookHash(eventName: string, matcher: unknown, handler: Record): string { + const timeout = Math.max(Number(handler.timeout ?? 600), 1) + const normalizedHandler: Record = { + type: "command", + command: handler.command, + timeout, + async: false, + } + if (typeof handler.statusMessage === "string") normalizedHandler.statusMessage = handler.statusMessage + + const identity: Record = { event_name: eventName, hooks: [normalizedHandler] } + if (typeof matcher === "string") identity.matcher = matcher + const canonical = JSON.stringify(canonicalJson(identity)) + return `sha256:${createHash("sha256").update(canonical).digest("hex")}` +} + +function canonicalJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalJson) + if (!isRecord(value)) return value + const result: Record = {} + for (const key of Object.keys(value).sort()) { + result[key] = canonicalJson(value[key]) + } + return result +} + +function stripDotSlash(value: string): string { + return value.startsWith("./") ? value.slice(2) : value +} + +async function exists(path: string): Promise { + try { + await readFile(path, "utf8") + return true + } catch { + return false + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/src/cli/install-codex/codex-marketplace.test.ts b/src/cli/install-codex/codex-marketplace.test.ts new file mode 100644 index 000000000..2252281c2 --- /dev/null +++ b/src/cli/install-codex/codex-marketplace.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { readMarketplace, readPluginManifest, resolvePluginSource } from "./codex-marketplace" + +describe("codex-marketplace", () => { + test("reads marketplace and resolves plugin source with override", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-marketplace-")) + const pkgRoot = join(root, "packages", "omo-codex") + const pluginRoot = join(pkgRoot, "plugin", ".codex-plugin") + await mkdir(pluginRoot, { recursive: true }) + await writeFile(join(pkgRoot, "marketplace.json"), JSON.stringify({ name: "code-yeongyu-codex-plugins", plugins: [{ name: "omo", source: "./plugins/omo" }] })) + await writeFile(join(pluginRoot, "plugin.json"), JSON.stringify({ name: "omo", version: "0.1.0" })) + + // when + const marketplace = await readMarketplace(root) + const sourcePath = resolvePluginSource(pkgRoot, marketplace.plugins[0], { pathOverride: "./plugin" }) + const manifest = await readPluginManifest(sourcePath) + + // then + expect(marketplace.name).toBe("code-yeongyu-codex-plugins") + expect(sourcePath).toBe(join(pkgRoot, "plugin")) + expect(manifest.name).toBe("omo") + }) + + test("rejects traversal source path", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-marketplace-")) + const pkgRoot = join(root, "packages", "omo-codex") + await mkdir(pkgRoot, { recursive: true }) + await writeFile(join(pkgRoot, "marketplace.json"), JSON.stringify({ name: "code-yeongyu-codex-plugins", plugins: [{ name: "omo", source: "./../escape" }] })) + + // when + const action = readMarketplace(root) + + // then + await expect(action).rejects.toThrow("local plugin source path must stay within the marketplace root") + }) +}) diff --git a/src/cli/install-codex/codex-marketplace.ts b/src/cli/install-codex/codex-marketplace.ts new file mode 100644 index 000000000..e5a791912 --- /dev/null +++ b/src/cli/install-codex/codex-marketplace.ts @@ -0,0 +1,110 @@ +import { readFile } from "node:fs/promises" +import { join } from "node:path" +import type { + MarketplaceManifest, + MarketplacePluginEntry, + MarketplacePluginSourceLocal, + PluginManifest, +} from "./types" + +const DEFAULT_MARKETPLACE_PATH = "packages/omo-codex/marketplace.json" + +export async function readMarketplace( + repoRoot: string, + options?: { readonly marketplacePath?: string }, +): Promise { + const marketplacePath = options?.marketplacePath ?? join(repoRoot, DEFAULT_MARKETPLACE_PATH) + const raw = await readFile(marketplacePath, "utf8") + const parsed: unknown = JSON.parse(raw) + if (!isRecord(parsed)) throw new Error("marketplace.json must be an object") + if (typeof parsed.name !== "string" || parsed.name.trim() === "") { + throw new Error("marketplace.json name must be a non-empty string") + } + validatePathSegment(parsed.name, "marketplace name") + if (!Array.isArray(parsed.plugins)) throw new Error("marketplace.json plugins must be an array") + return { + name: parsed.name, + plugins: parsed.plugins.map((plugin, index) => normalizeMarketplacePlugin(plugin, index)), + } +} + +export function resolvePluginSource( + repoRoot: string, + plugin: MarketplacePluginEntry, + options?: { readonly pathOverride?: string }, +): string { + const sourcePath = localSourcePath(options?.pathOverride ?? plugin.source) + const relativePath = sourcePath.slice(2) + return join(repoRoot, ...relativePath.split(/[\\/]/)) +} + +export async function readPluginManifest(pluginRoot: string): Promise { + const raw = await readFile(join(pluginRoot, ".codex-plugin", "plugin.json"), "utf8") + const parsed: unknown = JSON.parse(raw) + if (!isRecord(parsed)) throw new Error(`${pluginRoot} plugin.json must be an object`) + if (typeof parsed.name !== "string" || parsed.name.trim() === "") { + throw new Error(`${pluginRoot} plugin.json name must be a non-empty string`) + } + if (parsed.version !== undefined && (typeof parsed.version !== "string" || parsed.version.trim() === "")) { + throw new Error(`${pluginRoot} plugin.json version must be a non-empty string`) + } + if (parsed.hooks !== undefined && (typeof parsed.hooks !== "string" || parsed.hooks.trim() === "")) { + throw new Error(`${pluginRoot} plugin.json hooks must be a non-empty string`) + } + return { + name: parsed.name, + version: typeof parsed.version === "string" ? parsed.version.trim() : undefined, + hooks: typeof parsed.hooks === "string" ? parsed.hooks.trim() : undefined, + } +} + +export function validatePathSegment(value: string, label: string): void { + if (!/^[A-Za-z0-9._+-]+$/.test(value)) { + throw new Error(`${label} contains unsupported characters: ${value}`) + } + if (value === "." || value === "..") { + throw new Error(`${label} must not be a path traversal segment`) + } +} + +function normalizeMarketplacePlugin(plugin: unknown, index: number): MarketplacePluginEntry { + if (!isRecord(plugin)) throw new Error(`marketplace plugin ${index} must be an object`) + if (typeof plugin.name !== "string" || plugin.name.trim() === "") { + throw new Error(`marketplace plugin ${index} name must be a non-empty string`) + } + validatePathSegment(plugin.name, "plugin name") + if (plugin.source === undefined || typeof plugin.source === "string") { + if (typeof plugin.source === "string") { + validateLocalSourcePath(plugin.source) + } + return { name: plugin.name, source: plugin.source } + } + if (isRecord(plugin.source) && plugin.source.source === "local" && typeof plugin.source.path === "string") { + validateLocalSourcePath(plugin.source.path) + const local: MarketplacePluginSourceLocal = { source: "local", path: plugin.source.path } + return { name: plugin.name, source: local } + } + throw new Error("local plugin source must be a string path or { source: \"local\", path } object") +} + +function localSourcePath(source: string | MarketplacePluginSourceLocal | undefined): string { + if (typeof source === "string") return validateLocalSourcePath(source) + if (source?.source === "local") return validateLocalSourcePath(source.path) + throw new Error("local plugin source path is required") +} + +function validateLocalSourcePath(path: string): string { + if (!path.startsWith("./")) throw new Error("local plugin source path must start with ./") + const relative = path.slice(2) + if (relative.length === 0) throw new Error("local plugin source path must not be empty") + for (const part of relative.split(/[\\/]/)) { + if (part === "" || part === "." || part === "..") { + throw new Error("local plugin source path must stay within the marketplace root") + } + } + return path +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/src/cli/install-codex/codex-process.ts b/src/cli/install-codex/codex-process.ts new file mode 100644 index 000000000..4e6fbe2ec --- /dev/null +++ b/src/cli/install-codex/codex-process.ts @@ -0,0 +1,17 @@ +import { spawn } from "../../shared/bun-spawn-shim" +import type { RunCommand } from "./types" + +export const defaultRunCommand: RunCommand = async (command, args, options) => { + const proc = spawn({ + cmd: [command, ...args], + cwd: options.cwd, + stdin: "ignore", + stdout: "inherit", + stderr: "inherit", + }) + + const code = await proc.exited + if (code !== 0) { + throw new Error(`${command} ${args.join(" ")} failed in ${options.cwd} with exit code ${code}`) + } +} diff --git a/src/cli/install-codex/index.ts b/src/cli/install-codex/index.ts new file mode 100644 index 000000000..20c3d353d --- /dev/null +++ b/src/cli/install-codex/index.ts @@ -0,0 +1,14 @@ +export type { + CodexInstallOptions, + CodexInstallResult, + InstalledPlugin, + MarketplaceManifest, + PluginManifest, + TrustedHookState, +} from "./types" +export { runCodexInstaller } from "./install-codex" +export { readMarketplace, readPluginManifest, resolvePluginSource, validatePathSegment } from "./codex-marketplace" +export { installCachedPlugin, linkCachedPluginBins, pruneMarketplaceCache, rewriteCachedMcpManifest } from "./codex-cache" +export { updateCodexConfig } from "./codex-config-toml" +export { trustedHookStatesForPlugin } from "./codex-hook-trust" +export { defaultRunCommand } from "./codex-process" diff --git a/src/cli/install-codex/install-codex.test.ts b/src/cli/install-codex/install-codex.test.ts new file mode 100644 index 000000000..465f72a0d --- /dev/null +++ b/src/cli/install-codex/install-codex.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test" +import { mkdtemp, readFile, stat } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { runCodexInstaller } from "./install-codex" + +describe("install-codex", () => { + test("installs vendored plugin into codex home and stays idempotent", async () => { + // given + const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-home-")) + const binDir = await mkdtemp(join(tmpdir(), "omo-codex-bin-")) + const repoRoot = "/Users/yeongyu/local-workspaces/omodex" + + // when + const first = await runCodexInstaller({ codexHome, binDir, repoRoot, runCommand: async () => undefined }) + const second = await runCodexInstaller({ codexHome, binDir, repoRoot, runCommand: async () => undefined }) + + // then + expect(first.marketplaceName).toBe("code-yeongyu-codex-plugins") + expect(second.installed.length).toBe(1) + const configContent = await readFile(join(codexHome, "config.toml"), "utf8") + expect(configContent).toContain("[features]") + expect(configContent).toContain("[marketplaces.code-yeongyu-codex-plugins]") + expect(configContent).toContain("[plugins.\"omo@code-yeongyu-codex-plugins\"]") + expect(configContent).toContain("[hooks.state.") + + const pluginPath = first.installed[0]?.path + expect(pluginPath).toBeDefined() + const stats = await stat(pluginPath ?? "") + expect(stats.isDirectory()).toBe(true) + }) +}) diff --git a/src/cli/install-codex/install-codex.ts b/src/cli/install-codex/install-codex.ts new file mode 100644 index 000000000..3a7019d25 --- /dev/null +++ b/src/cli/install-codex/install-codex.ts @@ -0,0 +1,116 @@ +import { homedir } from "node:os" +import { join, resolve } from "node:path" +import { existsSync } from "node:fs" +import { installCachedPlugin, linkCachedPluginBins, pruneMarketplaceCache } from "./codex-cache" +import { updateCodexConfig } from "./codex-config-toml" +import { trustedHookStatesForPlugin } from "./codex-hook-trust" +import { readMarketplace, readPluginManifest, resolvePluginSource, validatePathSegment } from "./codex-marketplace" +import { defaultRunCommand } from "./codex-process" +import type { CodexInstallOptions, CodexInstallResult, InstalledPlugin } from "./types" + +export async function runCodexInstaller(options: CodexInstallOptions = {}): Promise { + const repoRoot = resolve(options.repoRoot ?? findRepoRootFromImporter(import.meta.dir)) + const codexHome = resolve(options.codexHome ?? process.env.CODEX_HOME ?? join(homedir(), ".codex")) + const binDir = resolve(options.binDir ?? process.env.CODEX_LOCAL_BIN_DIR ?? join(homedir(), ".local", "bin")) + const runCommand = options.runCommand ?? defaultRunCommand + const log = options.log ?? (() => undefined) + + const codexPackageRoot = join(repoRoot, "packages", "omo-codex") + const marketplace = await readMarketplace(repoRoot, { + marketplacePath: join(codexPackageRoot, "marketplace.json"), + }) + + const installed: InstalledPlugin[] = [] + for (const entry of marketplace.plugins) { + const sourcePath = resolvePluginSource(codexPackageRoot, entry, { pathOverride: "./plugin" }) + const manifest = await readPluginManifest(sourcePath) + if (manifest.name !== entry.name) { + throw new Error( + `plugin manifest name ${JSON.stringify(manifest.name)} does not match marketplace name ${JSON.stringify(entry.name)}`, + ) + } + + const version = manifest.version ?? "local" + validatePathSegment(version, "plugin version") + log(`Building ${entry.name}@${version}`) + + const plugin = await installCachedPlugin({ + codexHome, + marketplaceName: marketplace.name, + name: entry.name, + runCommand, + sourcePath, + version, + }) + + const links = await linkCachedPluginBins({ binDir, pluginRoot: plugin.path }) + for (const link of links) { + log(`Linked ${link.name} -> ${link.target}`) + } + installed.push(plugin) + } + + const trustedHookStates = ( + await Promise.all( + installed.map((plugin) => + trustedHookStatesForPlugin({ + marketplaceName: marketplace.name, + pluginName: plugin.name, + pluginRoot: plugin.path, + }), + ), + ) + ).flat() + + await pruneMarketplaceCache({ + codexHome, + marketplaceName: marketplace.name, + keepPluginNames: marketplace.plugins.map((plugin) => plugin.name), + }) + + const configPath = join(codexHome, "config.toml") + await updateCodexConfig({ + configPath, + repoRoot: codexPackageRoot, + marketplaceName: marketplace.name, + pluginNames: marketplace.plugins.map((plugin) => plugin.name), + trustedHookStates, + }) + + await trackCodexInstallTelemetry() + + return { + marketplaceName: marketplace.name, + installed, + configPath, + codexHome, + } +} + +function findRepoRootFromImporter(importerDir: string): string { + let current = importerDir + for (let depth = 0; depth <= 5; depth += 1) { + const pluginManifestPath = join(current, "packages", "omo-codex", "plugin", ".codex-plugin", "plugin.json") + if (existsSyncLike(pluginManifestPath)) return current + current = resolve(current, "..") + } + throw new Error( + "Unable to locate vendored Codex plugin: expected packages/omo-codex/plugin/.codex-plugin/plugin.json within 5 parent levels", + ) +} + +function existsSyncLike(path: string): boolean { + return existsSync(path) +} + +async function trackCodexInstallTelemetry(): Promise { + try { + const { createInstallPostHog, getPostHogDistinctId } = await import("@oh-my-opencode/omo-codex/telemetry") + const posthog = createInstallPostHog() + posthog.trackActive(getPostHogDistinctId(), "install_completed") + await posthog.shutdown() + } catch { + // no-excuse-ok: catch + // telemetry must never break installs + } +} diff --git a/src/cli/install-codex/lazycodex-routing.test.ts b/src/cli/install-codex/lazycodex-routing.test.ts new file mode 100644 index 000000000..6bda742d2 --- /dev/null +++ b/src/cli/install-codex/lazycodex-routing.test.ts @@ -0,0 +1,94 @@ +/// + +import { afterEach, describe, expect, test } from "bun:test" +import process from "node:process" +import { resolveInstallArgs } from "../cli-program" +import { argsToConfig } from "../install-validators" + +describe("lazycodex install routing", () => { + const originalInvocationName = process.env.OMO_INVOCATION_NAME + + afterEach(() => { + if (originalInvocationName === undefined) { + delete process.env.OMO_INVOCATION_NAME + return + } + process.env.OMO_INVOCATION_NAME = originalInvocationName + }) + + test("defaults codex to yes when invoked as lazycodex and user did not pass --codex", () => { + // given + process.env.OMO_INVOCATION_NAME = "lazycodex" + + // when + const args = resolveInstallArgs({ + tui: false, + claude: "no", + gemini: "no", + copilot: "no", + }) + const config = argsToConfig(args) + + // then + expect(args.codex).toBe("yes") + expect(config.hasCodex).toBe(true) + }) + + test("respects explicit --codex=no when invoked as lazycodex", () => { + // given + process.env.OMO_INVOCATION_NAME = "lazycodex" + + // when + const args = resolveInstallArgs({ + tui: false, + claude: "no", + gemini: "no", + copilot: "no", + codex: "no", + }) + const config = argsToConfig(args) + + // then + expect(args.codex).toBe("no") + expect(config.hasCodex).toBe(false) + }) + + test("keeps codex disabled by default when invocation name is oh-my-opencode", () => { + // given + process.env.OMO_INVOCATION_NAME = "oh-my-opencode" + + // when + const args = resolveInstallArgs({ + tui: false, + claude: "no", + gemini: "no", + copilot: "no", + }) + const config = argsToConfig(args) + + // then + expect(args.codex).toBeUndefined() + expect(config.hasCodex).toBe(false) + }) + + test("keeps codex disabled by default when invocation name is unset", () => { + // given + delete process.env.OMO_INVOCATION_NAME + + // when + const args = resolveInstallArgs( + { + tui: false, + claude: "no", + gemini: "no", + copilot: "no", + }, + undefined, + ) + const config = argsToConfig(args) + + // then + expect(args.codex).toBeUndefined() + expect(config.hasCodex).toBe(false) + }) +}) diff --git a/src/cli/install-codex/types.ts b/src/cli/install-codex/types.ts new file mode 100644 index 000000000..c1269a64c --- /dev/null +++ b/src/cli/install-codex/types.ts @@ -0,0 +1,56 @@ +export interface MarketplacePluginSourceLocal { + readonly source: "local" + readonly path: string +} + +export interface MarketplacePluginEntry { + readonly name: string + readonly source?: string | MarketplacePluginSourceLocal +} + +export interface MarketplaceManifest { + readonly name: string + readonly plugins: readonly MarketplacePluginEntry[] +} + +export interface PluginManifest { + readonly name: string + readonly version?: string + readonly hooks?: string +} + +export interface InstalledPlugin { + readonly name: string + readonly version: string + readonly path: string +} + +export interface TrustedHookState { + readonly key: string + readonly trustedHash: string +} + +export interface CommandRunOptions { + readonly cwd: string +} + +export type RunCommand = ( + command: string, + args: readonly string[], + options: CommandRunOptions, +) => Promise + +export interface CodexInstallOptions { + readonly codexHome?: string + readonly binDir?: string + readonly repoRoot?: string + readonly runCommand?: RunCommand + readonly log?: (message: string) => void +} + +export interface CodexInstallResult { + readonly marketplaceName: string + readonly installed: readonly InstalledPlugin[] + readonly configPath: string + readonly codexHome: string +} diff --git a/src/cli/install-validators.test.ts b/src/cli/install-validators.test.ts index 119f21ab8..872fcf6d6 100644 --- a/src/cli/install-validators.test.ts +++ b/src/cli/install-validators.test.ts @@ -10,6 +10,7 @@ function createArgs(overrides: Partial = {}): InstallArgs { openai: "no", gemini: "no", copilot: "no", + codex: "no", opencodeZen: "no", zaiCodingPlan: "no", kimiForCoding: "no", diff --git a/src/cli/install-validators.ts b/src/cli/install-validators.ts index 695747edf..418857619 100644 --- a/src/cli/install-validators.ts +++ b/src/cli/install-validators.ts @@ -37,6 +37,7 @@ export function formatConfigSummary(config: InstallConfig): string { lines.push(formatProvider("OpenAI/ChatGPT", config.hasOpenAI, "GPT-5.4 for Oracle")) lines.push(formatProvider("Gemini", config.hasGemini)) lines.push(formatProvider("GitHub Copilot", config.hasCopilot, "fallback")) + lines.push(formatProvider("Codex Harness (oh-my-codex plugin)", config.hasCodex)) lines.push(formatProvider("OpenCode Zen", config.hasOpencodeZen, "opencode/ models")) lines.push(formatProvider("Z.ai Coding Plan", config.hasZaiCodingPlan, "Librarian/Multimodal")) lines.push(formatProvider("Kimi For Coding", config.hasKimiForCoding, "Sisyphus/Prometheus fallback")) @@ -134,6 +135,10 @@ export function validateNonTuiArgs(args: InstallArgs): { valid: boolean; errors: errors.push(`Invalid --copilot value: ${args.copilot} (expected: no, yes)`) } + if (args.codex !== undefined && !["no", "yes"].includes(args.codex)) { + errors.push(`Invalid --codex value: ${args.codex} (expected: no, yes)`) + } + if (args.openai !== undefined && !["no", "yes"].includes(args.openai)) { errors.push(`Invalid --openai value: ${args.openai} (expected: no, yes)`) } @@ -168,9 +173,10 @@ export function argsToConfig(args: InstallArgs): InstallConfig { hasOpenAI: args.openai === "yes", hasGemini: args.gemini === "yes", hasCopilot: args.copilot === "yes", + hasCodex: args.codex === "yes", hasOpencodeZen: args.opencodeZen === "yes", hasZaiCodingPlan: args.zaiCodingPlan === "yes", -hasKimiForCoding: args.kimiForCoding === "yes", + hasKimiForCoding: args.kimiForCoding === "yes", hasOpencodeGo: args.opencodeGo === "yes", hasVercelAiGateway: args.vercelAiGateway === "yes", } @@ -181,9 +187,10 @@ export function detectedToInitialValues(detected: DetectedConfig): { openai: BooleanArg gemini: BooleanArg copilot: BooleanArg + codex: BooleanArg opencodeZen: BooleanArg zaiCodingPlan: BooleanArg -kimiForCoding: BooleanArg + kimiForCoding: BooleanArg opencodeGo: BooleanArg vercelAiGateway: BooleanArg } { @@ -197,9 +204,10 @@ kimiForCoding: BooleanArg openai: detected.hasOpenAI ? "yes" : "no", gemini: detected.hasGemini ? "yes" : "no", copilot: detected.hasCopilot ? "yes" : "no", + codex: detected.hasCodex ? "yes" : "no", opencodeZen: detected.hasOpencodeZen ? "yes" : "no", zaiCodingPlan: detected.hasZaiCodingPlan ? "yes" : "no", -kimiForCoding: detected.hasKimiForCoding ? "yes" : "no", + kimiForCoding: detected.hasKimiForCoding ? "yes" : "no", opencodeGo: detected.hasOpencodeGo ? "yes" : "no", vercelAiGateway: detected.hasVercelAiGateway ? "yes" : "no", } diff --git a/src/cli/tui-install-prompts.ts b/src/cli/tui-install-prompts.ts index cec155688..b7f6f1a5e 100644 --- a/src/cli/tui-install-prompts.ts +++ b/src/cli/tui-install-prompts.ts @@ -70,6 +70,16 @@ export async function promptInstallConfig(detected: DetectedConfig): Promise { hasOpenAI: false, hasGemini: false, hasCopilot: false, + hasCodex: false, hasOpencodeZen: false, hasZaiCodingPlan: false, hasKimiForCoding: false, @@ -89,6 +90,7 @@ describe("runTuiInstaller", () => { hasOpenAI: false, hasGemini: false, hasCopilot: false, + hasCodex: false, hasOpencodeZen: false, hasZaiCodingPlan: false, hasKimiForCoding: false, @@ -103,6 +105,7 @@ describe("runTuiInstaller", () => { hasOpenAI: false, hasGemini: false, hasCopilot: false, + hasCodex: false, hasOpencodeZen: false, hasZaiCodingPlan: false, hasKimiForCoding: false, diff --git a/src/cli/tui-installer.ts b/src/cli/tui-installer.ts index ef4b9088a..b50471605 100644 --- a/src/cli/tui-installer.ts +++ b/src/cli/tui-installer.ts @@ -12,6 +12,7 @@ import { import { detectedToInitialValues, formatConfigSummary, SYMBOLS } from "./install-validators" import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version" import { promptInstallConfig } from "./tui-install-prompts" +import { runCodexInstaller } from "./install-codex" export async function runTuiInstaller(args: InstallArgs, version: string): Promise { if (!process.stdin.isTTY || !process.stdout.isTTY) { @@ -83,6 +84,18 @@ export async function runTuiInstaller(args: InstallArgs, version: string): Promi p.note(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete") + if (config.hasCodex) { + spinner.start("Installing Codex harness adapter") + try { + const codexResult = await runCodexInstaller() + spinner.stop(`Codex plugin installed to ${color.cyan(codexResult.configPath)}`) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + spinner.stop(`Codex install failed ${color.yellow("[!]")}`) + p.log.warn(`Codex install failed (OpenCode install remains successful): ${message}`) + } + } + p.log.success(color.bold(isUpdate ? "Configuration updated!" : "Installation complete!")) p.log.message(`Run ${color.cyan("opencode")} to start!`) p.log.info("Anonymous telemetry is enabled by default. Disable it with OMO_SEND_ANONYMOUS_TELEMETRY=0 or OMO_DISABLE_POSTHOG=1.") diff --git a/src/cli/types.ts b/src/cli/types.ts index c61f96bd4..673c0589b 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -7,9 +7,10 @@ export interface InstallArgs { openai?: BooleanArg gemini?: BooleanArg copilot?: BooleanArg + codex?: BooleanArg opencodeZen?: BooleanArg zaiCodingPlan?: BooleanArg -kimiForCoding?: BooleanArg + kimiForCoding?: BooleanArg opencodeGo?: BooleanArg vercelAiGateway?: BooleanArg skipAuth?: boolean @@ -21,6 +22,7 @@ export interface InstallConfig { hasOpenAI: boolean hasGemini: boolean hasCopilot: boolean + hasCodex: boolean hasOpencodeZen: boolean hasZaiCodingPlan: boolean hasKimiForCoding: boolean @@ -42,6 +44,7 @@ export interface DetectedConfig { hasOpenAI: boolean hasGemini: boolean hasCopilot: boolean + hasCodex: boolean hasOpencodeZen: boolean hasZaiCodingPlan: boolean hasKimiForCoding: boolean