From 1d55a038d4768502838197d39d9d8139e7ddcadf Mon Sep 17 00:00:00 2001 From: jagadhis Date: Sat, 5 Sep 2026 15:07:42 +0530 Subject: [PATCH] Add managed Codex CLI setup --- CONTRIBUTING.md | 2 +- README.md | 2 +- src-tauri/src/harness.rs | 117 ++++++++++++++++++++++++++++++++-- src-tauri/src/lib.rs | 1 + src/lib/harness/child.ts | 4 ++ src/surfaces/SettingsView.tsx | 34 ++++++++++ 6 files changed, 153 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b4a13a7a..56e5dc9b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ Please don’t open PRs that add a new provider right now. The existing harnesse You need Node.js 20+, a current stable Rust toolchain, and at least one provider CLI installed and logged in: - [Claude Code](https://claude.com/product/claude-code) - `claude auth login` -- [Codex](https://developers.openai.com/codex/cli) - `codex login` +- [Codex](https://developers.openai.com/codex/cli) - install it from **Settings → Providers**, then run `codex` once to sign in - [Cursor CLI](https://cursor.com/cli) - `agent login` - [Grok Build](https://docs.x.ai/build/overview) - `curl -fsSL https://x.ai/cli/install.sh | bash` then `grok login` - [OpenCode](https://opencode.ai) - `opencode auth login` diff --git a/README.md b/README.md index feba4330..cc2a529c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCod > Install and log in to at least one provider first: > > - [Claude Code](https://claude.com/product/claude-code) - `claude auth login` -> - [Codex](https://developers.openai.com/codex/cli) - `codex login` +> - [Codex](https://developers.openai.com/codex/cli) - MonoCode can install the CLI from **Settings → Providers**; run `codex` once afterward to sign in > - [Cursor CLI](https://cursor.com/cli) - `agent login` > - [Grok Build](https://docs.x.ai/build/overview) - `curl -fsSL https://x.ai/cli/install.sh | bash` then `grok login` > - [OpenCode](https://opencode.ai) - `opencode auth login` diff --git a/src-tauri/src/harness.rs b/src-tauri/src/harness.rs index 66f885d3..b2341e7a 100644 --- a/src-tauri/src/harness.rs +++ b/src-tauri/src/harness.rs @@ -20,6 +20,8 @@ const STDERR_EVENT: &str = "harness-stderr"; const EXIT_EVENT: &str = "harness-exit"; const SSE_EVENT: &str = "harness-sse"; const SSE_END_EVENT: &str = "harness-sse-end"; +const CODEX_INSTALL_URL: &str = "https://chatgpt.com/codex/install.sh"; +const MAX_INSTALLER_BYTES: u64 = 1024 * 1024; #[derive(Serialize, Clone)] #[serde(rename_all = "camelCase")] @@ -223,11 +225,95 @@ pub fn harness_resolve_codex() -> Result { path: path.to_string_lossy().into_owned(), }) .ok_or_else(|| { - "Codex CLI not found. Install it from https://developers.openai.com/codex/cli and run `codex login`, then retry." + "Codex CLI not found. Install it from Settings > Providers, run `codex` once to sign in, then retry." .into() }) } +/// Install the official standalone Codex CLI into MonoCode's user-owned tool directory. +/// This is only called after the user confirms the install from Settings. +#[tauri::command] +pub async fn harness_install_codex() -> Result { + tauri::async_runtime::spawn_blocking(install_codex_sync) + .await + .map_err(|error| error.to_string())? +} + +fn install_codex_sync() -> Result { + let home = dirs_home().ok_or_else(|| "Could not resolve your home directory".to_string())?; + let install_dir = codex_install_dir(Path::new(&home)); + std::fs::create_dir_all(&install_dir) + .map_err(|error| format!("Could not create the Codex install directory: {error}"))?; + + let response = ureq::AgentBuilder::new() + .timeout(Duration::from_secs(30)) + .build() + .get(CODEX_INSTALL_URL) + .set("Accept", "text/x-shellscript,text/plain") + .set("User-Agent", "MonoCode") + .call() + .map_err(|error| format!("Could not download the Codex installer: {error}"))?; + if let Some(length) = response + .header("Content-Length") + .and_then(|value| value.parse::().ok()) + { + if length > MAX_INSTALLER_BYTES { + return Err("The Codex installer response was unexpectedly large".into()); + } + } + + let mut installer = Vec::new(); + response + .into_reader() + .take(MAX_INSTALLER_BYTES + 1) + .read_to_end(&mut installer) + .map_err(|error| format!("Could not read the Codex installer: {error}"))?; + if installer.len() as u64 > MAX_INSTALLER_BYTES { + return Err("The Codex installer response was unexpectedly large".into()); + } + if !installer.starts_with(b"#!") { + return Err("The Codex installer response was not a shell script".into()); + } + + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let installer_path = std::env::temp_dir().join(format!( + "monocode-codex-installer-{}-{stamp}.sh", + std::process::id() + )); + std::fs::write(&installer_path, installer) + .map_err(|error| format!("Could not stage the Codex installer: {error}"))?; + + let mut command = Command::new("/bin/sh"); + command + .arg(&installer_path) + .env("CODEX_INSTALL_DIR", &install_dir) + .env("CODEX_NON_INTERACTIVE", "1") + .stdin(Stdio::null()); + apply_gui_env(&mut command); + let result = command.output(); + let _ = std::fs::remove_file(&installer_path); + let output = result.map_err(|error| format!("Could not run the Codex installer: {error}"))?; + if !output.status.success() { + let detail = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(if detail.is_empty() { + "The Codex installer did not complete successfully".into() + } else { + format!("The Codex installer failed: {detail}") + }); + } + + let binary = install_dir.join("codex"); + if !is_executable_file(&binary) { + return Err("Codex installed, but its executable could not be found".into()); + } + Ok(CursorBinary { + path: binary.to_string_lossy().into_owned(), + }) +} + /// Resolve the OpenCode CLI (`opencode`). #[tauri::command(async)] pub fn harness_resolve_opencode() -> Result { @@ -1168,10 +1254,7 @@ fn resolve_codex() -> Option { let mut candidates: Vec = Vec::new(); if let Some(home) = &home { - candidates.push(home.join(".local/bin/codex")); - candidates.push(home.join(".npm-global/bin/codex")); - candidates.push(home.join(".cargo/bin/codex")); - candidates.push(home.join("n/bin/codex")); + candidates.extend(codex_home_candidates(home)); } candidates.push(PathBuf::from("/opt/homebrew/bin/codex")); candidates.push(PathBuf::from("/usr/local/bin/codex")); @@ -1194,6 +1277,21 @@ fn resolve_codex() -> Option { candidates.into_iter().find(|path| path.is_file()) } +fn codex_install_dir(home: &Path) -> PathBuf { + home.join(".monocode/bin") +} + +fn codex_home_candidates(home: &Path) -> Vec { + vec![ + home.join(".local/bin/codex"), + codex_install_dir(home).join("codex"), + home.join(".bun/bin/codex"), + home.join(".npm-global/bin/codex"), + home.join(".cargo/bin/codex"), + home.join("n/bin/codex"), + ] +} + fn resolve_opencode() -> Option { let home = dirs_home().map(PathBuf::from); let mut candidates: Vec = Vec::new(); @@ -2158,6 +2256,15 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn codex_candidates_include_monocode_and_bun_installs() { + let home = Path::new("/Users/example"); + let candidates = codex_home_candidates(home); + + assert!(candidates.contains(&home.join(".monocode/bin/codex"))); + assert!(candidates.contains(&home.join(".bun/bin/codex"))); + } + #[test] fn cursor_agent_accepts_symlink_named_agent() { let dir = std::env::temp_dir().join(format!("monocode-agent-{}", std::process::id())); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5c66bbdd..e2ecacd5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -227,6 +227,7 @@ pub fn run() { cursor_store::cursor_tool_calls, harness::harness_resolve_cursor, harness::harness_resolve_codex, + harness::harness_install_codex, harness::harness_resolve_opencode, harness::harness_resolve_claude, harness::harness_resolve_omp, diff --git a/src/lib/harness/child.ts b/src/lib/harness/child.ts index 240a5525..ba9d5c17 100644 --- a/src/lib/harness/child.ts +++ b/src/lib/harness/child.ts @@ -289,6 +289,10 @@ export function resolveCodexBinary(): Promise<{ path: string }> { return invoke("harness_resolve_codex"); } +export function installCodexBinary(): Promise<{ path: string }> { + return invoke("harness_install_codex"); +} + export function resolveOpenCodeBinary(): Promise<{ path: string }> { return invoke("harness_resolve_opencode"); } diff --git a/src/surfaces/SettingsView.tsx b/src/surfaces/SettingsView.tsx index 1fd326e2..05d02526 100644 --- a/src/surfaces/SettingsView.tsx +++ b/src/surfaces/SettingsView.tsx @@ -15,6 +15,7 @@ import { useSyncExternalStore, type ReactNode, } from "react"; +import { ask, message } from "@tauri-apps/plugin-dialog"; import { HarnessIcon } from "../chrome/HarnessIcon"; import { InboxProviderMark } from "../chrome/InboxProviderMark"; import { RemoveProjectDialog } from "../chrome/RemoveProjectDialog"; @@ -68,6 +69,7 @@ import { subscribeHarnessAvailability, } from "../lib/harness/availability"; import { refreshHarnessCatalogs } from "../lib/harness/registry"; +import { installCodexBinary } from "../lib/harness/child"; import { defaultModelId, getModelSnapshot, @@ -980,6 +982,7 @@ function ProviderRow({ const [inPicker, setInPicker] = useState(() => isPickerProviderVisible(harness), ); + const [installing, setInstalling] = useState(false); useEffect(() => { if (!available || models.length > 0) return; @@ -991,6 +994,32 @@ function ProviderRow({ setInPicker(visible); }; + const onInstallCodex = async () => { + const confirmed = await ask( + "MonoCode will download and run OpenAI's official Codex installer, placing the CLI in ~/.monocode/bin. Continue?", + { title: "Install Codex CLI", kind: "info" }, + ); + if (!confirmed) return; + setInstalling(true); + try { + await installCodexBinary(); + await probeHarnessAvailability({ force: true }); + await refreshHarnessCatalogs(["codex"]); + await message( + "Codex CLI is installed. If you have not signed in before, run `codex` once in a terminal and choose Sign in with ChatGPT.", + { title: "Codex CLI installed", kind: "info" }, + ); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + await message(`Couldn't install Codex CLI.\n\n${detail}`, { + title: "Codex CLI installation failed", + kind: "error", + }); + } finally { + setInstalling(false); + } + }; + return ( ) : null} + {!available && harness === "codex" ? ( + + {installing ? "Installing…" : "Install Codex"} + + ) : null} current && onDefault(harness, current.id)} disabled={isDefault || !current}