Skip to content
Merged
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
234 changes: 234 additions & 0 deletions src-tauri/src/commands/codex_cli.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
//! Codex CLI subprocess transport.
//!
//! This mirrors the Claude Code CLI transport, but treats `codex` as a
//! local completion engine via `codex exec --json`. The webview can only
//! spawn this fixed command; it cannot execute arbitrary shell commands.

use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;

use serde::Serialize;
use tauri::{AppHandle, Emitter, State};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::Mutex;

#[derive(Default)]
pub struct CodexCliState {
children: Arc<Mutex<HashMap<String, Child>>>,
}

#[derive(Serialize)]
pub struct DetectResult {
installed: bool,
version: Option<String>,
path: Option<String>,
error: Option<String>,
}

fn find_codex_command() -> Result<PathBuf, String> {
#[cfg(windows)]
{
if let Ok(path) = which::which("codex.cmd") {
return Ok(path);
}
if let Ok(path) = which::which("codex.exe") {
return Ok(path);
}
}

which::which("codex").map_err(|_| "`codex` not found on PATH".to_string())
}

#[tauri::command]
pub async fn codex_cli_detect() -> Result<DetectResult, String> {
let path = match find_codex_command() {
Ok(p) => p,
Err(error) => {
return Ok(DetectResult {
installed: false,
version: None,
path: None,
error: Some(error),
});
}
};

let path_str = path.to_string_lossy().to_string();
let output = tokio::time::timeout(
Duration::from_secs(3),
Command::new(&path).arg("--version").output(),
)
.await;

match output {
Ok(Ok(out)) if out.status.success() => {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
Ok(DetectResult {
installed: true,
version: Some(stdout),
path: Some(path_str),
error: None,
})
}
Ok(Ok(out)) => {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
Ok(DetectResult {
installed: false,
version: None,
path: Some(path_str),
error: Some(if stderr.is_empty() {
format!("`codex --version` exited with {}", out.status)
} else {
stderr
}),
})
}
Ok(Err(e)) => Ok(DetectResult {
installed: false,
version: None,
path: Some(path_str),
error: Some(format!("Failed to spawn `codex`: {e}")),
}),
Err(_) => Ok(DetectResult {
installed: false,
version: None,
path: Some(path_str),
error: Some("`codex --version` timed out after 3s".to_string()),
}),
}
}

#[tauri::command]
pub async fn codex_cli_spawn(
app: AppHandle,
state: State<'_, CodexCliState>,
stream_id: String,
model: String,
prompt: String,
) -> Result<(), String> {
if prompt.trim().is_empty() {
return Err("No prompt to send to codex CLI".to_string());
}

let codex = find_codex_command()?;
let mut cmd = Command::new(&codex);
cmd.arg("-a")
.arg("never")
.arg("exec")
.arg("--json")
.arg("--skip-git-repo-check")
.arg("--sandbox")
.arg("read-only")
.arg("--ephemeral")
.arg("--model")
.arg(&model)
.arg("-");

cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);

let mut child = cmd
.spawn()
.map_err(|e| format!("Failed to spawn codex: {e}"))?;

let mut stdin = child
.stdin
.take()
.ok_or_else(|| "Missing stdin handle".to_string())?;
let stdout = child
.stdout
.take()
.ok_or_else(|| "Missing stdout handle".to_string())?;
let stderr = child
.stderr
.take()
.ok_or_else(|| "Missing stderr handle".to_string())?;

stdin
.write_all(prompt.as_bytes())
.await
.map_err(|e| format!("Failed to write to codex stdin: {e}"))?;
stdin
.flush()
.await
.map_err(|e| format!("Failed to flush codex stdin: {e}"))?;
drop(stdin);

state.children.lock().await.insert(stream_id.clone(), child);

let children = Arc::clone(&state.children);
let app_for_task = app.clone();
let stream_id_task = stream_id.clone();
let topic = format!("codex-cli:{stream_id}");
let done_topic = format!("codex-cli:{stream_id}:done");

tokio::spawn(async move {
let mut reader = BufReader::new(stdout).lines();
let mut stderr_reader = BufReader::new(stderr).lines();
let app = app_for_task;

let stderr_task = tokio::spawn(async move {
let mut collected = String::new();
while let Ok(Some(line)) = stderr_reader.next_line().await {
eprintln!("[codex-cli stderr] {line}");
collected.push_str(&line);
collected.push('\n');
}
collected
});

loop {
match reader.next_line().await {
Ok(Some(line)) => {
if app.emit(&topic, line).is_err() {
break;
}
}
Ok(None) => break,
Err(e) => {
eprintln!("[codex-cli stdout] read error: {e}");
break;
}
}
}

let child_opt = children.lock().await.remove(&stream_id_task);
let exit_code = if let Some(mut child) = child_opt {
match child.wait().await {
Ok(status) => status.code(),
Err(_) => None,
}
} else {
None
};

let stderr_text = stderr_task.await.unwrap_or_default();

let _ = app.emit(
&done_topic,
serde_json::json!({
"code": exit_code,
"stderr": stderr_text,
}),
);
});

Ok(())
}

#[tauri::command]
pub async fn codex_cli_kill(
state: State<'_, CodexCliState>,
stream_id: String,
) -> Result<(), String> {
if let Some(mut child) = state.children.lock().await.remove(&stream_id) {
let _ = child.start_kill();
}
Ok(())
}
1 change: 1 addition & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod claude_cli;
pub mod codex_cli;
pub mod extract_images;
pub mod file_sync;
pub mod fs;
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ pub fn run() {
// frontend-generated stream id. Populated by claude_cli_spawn,
// drained on process exit or by claude_cli_kill.
app.manage(commands::claude_cli::ClaudeCliState::default());
app.manage(commands::codex_cli::CodexCliState::default());
app.manage(commands::file_sync::FileSyncState::default());
Ok(())
})
Expand Down Expand Up @@ -105,6 +106,9 @@ pub fn run() {
commands::claude_cli::claude_cli_detect,
commands::claude_cli::claude_cli_spawn,
commands::claude_cli::claude_cli_kill,
commands::codex_cli::codex_cli_detect,
commands::codex_cli::codex_cli_spawn,
commands::codex_cli::codex_cli_kill,
commands::extract_images::extract_pdf_images_cmd,
commands::extract_images::extract_office_images_cmd,
commands::extract_images::extract_and_save_pdf_images_cmd,
Expand Down
16 changes: 16 additions & 0 deletions src/components/settings/llm-presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type Provider =
| "custom"
| "minimax"
| "claude-code"
| "codex-cli"

export interface LlmPreset {
/** Stable id used as the dropdown value. */
Expand Down Expand Up @@ -91,6 +92,21 @@ export const LLM_PRESETS: LlmPreset[] = [
],
suggestedContextSize: 200000,
},
{
id: "codex-cli",
label: "Codex CLI (local)",
hint: "Uses the local `codex` binary — no API key needed",
provider: "codex-cli",
defaultModel: "gpt-5.4-mini",
suggestedModels: [
"gpt-5.4-mini",
"gpt-5.4",
"gpt-5.3-codex",
"gpt-5.3-codex-spark",
"gpt-5.2",
],
suggestedContextSize: 200000,
},
{
id: "openai",
label: "OpenAI (GPT)",
Expand Down
6 changes: 3 additions & 3 deletions src/components/settings/preset-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,11 @@ export function resolveConfig(
}
}

if (preset.provider === "claude-code") {
if (preset.provider === "claude-code" || preset.provider === "codex-cli") {
// Subprocess transport — no apiKey, no endpoint URL. Model id is
// passed straight to `claude --model`.
// passed straight to the local CLI's model flag.
return {
provider: "claude-code",
provider: preset.provider,
apiKey: "",
model,
ollamaUrl: fallback.ollamaUrl,
Expand Down
Loading
Loading