Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
117 changes: 112 additions & 5 deletions src-tauri/src/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -223,11 +225,95 @@ pub fn harness_resolve_codex() -> Result<CursorBinary, String> {
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<CursorBinary, String> {
tauri::async_runtime::spawn_blocking(install_codex_sync)
.await
.map_err(|error| error.to_string())?
}

fn install_codex_sync() -> Result<CursorBinary, String> {
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::<u64>().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<CursorBinary, String> {
Expand Down Expand Up @@ -1168,10 +1254,7 @@ fn resolve_codex() -> Option<PathBuf> {
let mut candidates: Vec<PathBuf> = 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"));
Expand All @@ -1194,6 +1277,21 @@ fn resolve_codex() -> Option<PathBuf> {
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<PathBuf> {
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<PathBuf> {
let home = dirs_home().map(PathBuf::from);
let mut candidates: Vec<PathBuf> = Vec::new();
Expand Down Expand Up @@ -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()));
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/lib/harness/child.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down
34 changes: 34 additions & 0 deletions src/surfaces/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -980,6 +982,7 @@ function ProviderRow({
const [inPicker, setInPicker] = useState(() =>
isPickerProviderVisible(harness),
);
const [installing, setInstalling] = useState(false);

useEffect(() => {
if (!available || models.length > 0) return;
Expand All @@ -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 (
<Row
label={
Expand Down Expand Up @@ -1021,6 +1050,11 @@ function ProviderRow({
}))}
/>
) : null}
{!available && harness === "codex" ? (
<SecondaryButton onClick={onInstallCodex} disabled={installing}>
{installing ? "Installing…" : "Install Codex"}
</SecondaryButton>
) : null}
<SecondaryButton
onClick={() => current && onDefault(harness, current.id)}
disabled={isDefault || !current}
Expand Down