feat: add OpenAI Codex CLI provider (#6263)

Signed-off-by: RamXX <ramxx@ramirosalas.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
Ramiro Salas
2025-12-28 22:01:21 -08:00
committed by GitHub
parent 614ba5bce1
commit 31eaa47209
8 changed files with 1083 additions and 8 deletions
+4
View File
@@ -824,6 +824,10 @@ impl Config {
config_value!(CLAUDE_CODE_COMMAND, OsString, "claude");
config_value!(GEMINI_CLI_COMMAND, OsString, "gemini");
config_value!(CURSOR_AGENT_COMMAND, OsString, "cursor-agent");
config_value!(CODEX_COMMAND, OsString, "codex");
config_value!(CODEX_REASONING_EFFORT, String, "high");
config_value!(CODEX_ENABLE_SKILLS, String, "true");
config_value!(CODEX_SKIP_GIT_CHECK, String, "false");
config_value!(GOOSE_SEARCH_PATHS, Vec<String>);
config_value!(GOOSE_MODE, GooseMode);
+4 -1
View File
@@ -18,7 +18,10 @@ pub enum ConfigError {
static MODEL_SPECIFIC_LIMITS: Lazy<Vec<(&'static str, usize)>> = Lazy::new(|| {
vec![
// openai
("gpt-5", 272_000),
("gpt-5.2-codex", 400_000), // auto-compacting context
("gpt-5.2", 400_000), // auto-compacting context
("gpt-5.1-codex-max", 256_000),
("gpt-5.1-codex-mini", 256_000),
("gpt-4-turbo", 128_000),
("gpt-4.1", 1_000_000),
("gpt-4-1", 1_000_000),
+955
View File
@@ -0,0 +1,955 @@
use anyhow::Result;
use async_trait::async_trait;
use serde_json::json;
use std::ffi::OsString;
use std::path::PathBuf;
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use super::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage, Usage};
use super::errors::ProviderError;
use super::utils::{filter_extensions_from_system_prompt, RequestLog};
use crate::config::base::{
CodexCommand, CodexEnableSkills, CodexReasoningEffort, CodexSkipGitCheck,
};
use crate::config::search_path::SearchPaths;
use crate::config::{Config, GooseMode};
use crate::conversation::message::{Message, MessageContent};
use crate::model::ModelConfig;
use crate::subprocess::configure_command_no_window;
use rmcp::model::Role;
use rmcp::model::Tool;
pub const CODEX_DEFAULT_MODEL: &str = "gpt-5.2-codex";
pub const CODEX_KNOWN_MODELS: &[&str] = &[
"gpt-5.2-codex",
"gpt-5.2",
"gpt-5.1-codex-max",
"gpt-5.1-codex-mini",
];
pub const CODEX_DOC_URL: &str = "https://developers.openai.com/codex/cli";
/// Valid reasoning effort levels for Codex
pub const CODEX_REASONING_LEVELS: &[&str] = &["low", "medium", "high"];
#[derive(Debug, serde::Serialize)]
pub struct CodexProvider {
command: PathBuf,
model: ModelConfig,
#[serde(skip)]
name: String,
/// Reasoning effort level (low, medium, high)
reasoning_effort: String,
/// Whether to enable skills
enable_skills: bool,
/// Whether to skip git repo check
skip_git_check: bool,
}
impl CodexProvider {
pub async fn from_env(model: ModelConfig) -> Result<Self> {
let config = Config::global();
let command: OsString = config.get_codex_command().unwrap_or_default().into();
let resolved_command = SearchPaths::builder().with_npm().resolve(command)?;
// Get reasoning effort from config, default to "high"
let reasoning_effort = config
.get_codex_reasoning_effort()
.map(|r| r.to_string())
.unwrap_or_else(|_| "high".to_string());
// Validate reasoning effort
let reasoning_effort = if CODEX_REASONING_LEVELS.contains(&reasoning_effort.as_str()) {
reasoning_effort
} else {
tracing::warn!(
"Invalid CODEX_REASONING_EFFORT '{}', using 'high'",
reasoning_effort
);
"high".to_string()
};
// Get enable_skills from config, default to true
let enable_skills = config
.get_codex_enable_skills()
.map(|s| s.to_lowercase() == "true")
.unwrap_or(true);
// Get skip_git_check from config, default to false
let skip_git_check = config
.get_codex_skip_git_check()
.map(|s| s.to_lowercase() == "true")
.unwrap_or(false);
Ok(Self {
command: resolved_command,
model,
name: Self::metadata().name,
reasoning_effort,
enable_skills,
skip_git_check,
})
}
/// Convert goose messages to a simple text prompt format
/// Similar to Gemini CLI, we use Human:/Assistant: prefixes
fn messages_to_prompt(&self, system: &str, messages: &[Message]) -> String {
let mut full_prompt = String::new();
let filtered_system = filter_extensions_from_system_prompt(system);
if !filtered_system.is_empty() {
full_prompt.push_str(&filtered_system);
full_prompt.push_str("\n\n");
}
// Add conversation history
for message in messages.iter().filter(|m| m.is_agent_visible()) {
let role_prefix = match message.role {
Role::User => "Human: ",
Role::Assistant => "Assistant: ",
};
full_prompt.push_str(role_prefix);
for content in &message.content {
if let MessageContent::Text(text_content) = content {
full_prompt.push_str(&text_content.text);
full_prompt.push('\n');
}
}
full_prompt.push('\n');
}
full_prompt.push_str("Assistant: ");
full_prompt
}
/// Apply permission flags based on GOOSE_MODE setting
fn apply_permission_flags(cmd: &mut Command) -> Result<(), ProviderError> {
let config = Config::global();
let goose_mode = config.get_goose_mode().unwrap_or(GooseMode::Auto);
match goose_mode {
GooseMode::Auto => {
// --yolo is shorthand for --dangerously-bypass-approvals-and-sandbox
cmd.arg("--yolo");
}
GooseMode::SmartApprove => {
// --full-auto applies workspace-write sandbox and approvals only on failure
cmd.arg("--full-auto");
}
GooseMode::Approve => {
// Default codex behavior - interactive approvals
// No special flags needed
}
GooseMode::Chat => {
// Read-only sandbox mode
cmd.arg("--sandbox").arg("read-only");
}
}
Ok(())
}
/// Execute codex CLI command
async fn execute_command(
&self,
system: &str,
messages: &[Message],
_tools: &[Tool],
) -> Result<Vec<String>, ProviderError> {
let prompt = self.messages_to_prompt(system, messages);
if std::env::var("GOOSE_CODEX_DEBUG").is_ok() {
println!("=== CODEX PROVIDER DEBUG ===");
println!("Command: {:?}", self.command);
println!("Model: {}", self.model.model_name);
println!("Reasoning effort: {}", self.reasoning_effort);
println!("Enable skills: {}", self.enable_skills);
println!("Skip git check: {}", self.skip_git_check);
println!("Prompt length: {} chars", prompt.len());
println!("Prompt: {}", prompt);
println!("============================");
}
let mut cmd = Command::new(&self.command);
configure_command_no_window(&mut cmd);
// Use 'exec' subcommand for non-interactive mode
cmd.arg("exec");
// Only pass model parameter if it's in the known models list
// This allows users to set GOOSE_PROVIDER=codex without needing to specify a model
if CODEX_KNOWN_MODELS.contains(&self.model.model_name.as_str()) {
cmd.arg("-m").arg(&self.model.model_name);
}
// Reasoning effort configuration
cmd.arg("-c").arg(format!(
"model_reasoning_effort=\"{}\"",
self.reasoning_effort
));
// Enable skills if configured
if self.enable_skills {
cmd.arg("--enable").arg("skills");
}
// JSON output format for structured parsing
cmd.arg("--json");
// Apply permission mode based on GOOSE_MODE
Self::apply_permission_flags(&mut cmd)?;
// Skip git repo check if configured
if self.skip_git_check {
cmd.arg("--skip-git-repo-check");
}
// Pass the prompt via stdin using '-' argument
cmd.arg("-");
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| {
ProviderError::RequestFailed(format!(
"Failed to spawn Codex CLI command '{:?}': {}. \
Make sure the Codex CLI is installed (npm i -g @openai/codex) \
and available in the configured search paths.",
self.command, e
))
})?;
// Write prompt to stdin
if let Some(mut stdin) = child.stdin.take() {
use tokio::io::AsyncWriteExt;
stdin.write_all(prompt.as_bytes()).await.map_err(|e| {
ProviderError::RequestFailed(format!("Failed to write to stdin: {}", e))
})?;
// Close stdin to signal end of input
drop(stdin);
}
let stdout = child
.stdout
.take()
.ok_or_else(|| ProviderError::RequestFailed("Failed to capture stdout".to_string()))?;
let mut reader = BufReader::new(stdout);
let mut lines = Vec::new();
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => break, // EOF
Ok(_) => {
let trimmed = line.trim();
if !trimmed.is_empty() {
lines.push(trimmed.to_string());
}
}
Err(e) => {
return Err(ProviderError::RequestFailed(format!(
"Failed to read output: {}",
e
)));
}
}
}
let exit_status = child.wait().await.map_err(|e| {
ProviderError::RequestFailed(format!("Failed to wait for command: {}", e))
})?;
if !exit_status.success() {
return Err(ProviderError::RequestFailed(format!(
"Codex command failed with exit code: {:?}",
exit_status.code()
)));
}
tracing::debug!("Codex CLI executed successfully, got {} lines", lines.len());
Ok(lines)
}
/// Extract text content from an item.completed event (agent_message only, skip reasoning)
fn extract_text_from_item(item: &serde_json::Value) -> Option<String> {
let item_type = item.get("type").and_then(|t| t.as_str());
if item_type == Some("agent_message") {
item.get("text")
.and_then(|t| t.as_str())
.filter(|text| !text.trim().is_empty())
.map(|s| s.to_string())
} else {
None
}
}
/// Extract usage information from a JSON object
fn extract_usage(usage_info: &serde_json::Value, usage: &mut Usage) {
if usage.input_tokens.is_none() {
usage.input_tokens = usage_info
.get("input_tokens")
.and_then(|v| v.as_i64())
.map(|v| v as i32);
}
if usage.output_tokens.is_none() {
usage.output_tokens = usage_info
.get("output_tokens")
.and_then(|v| v.as_i64())
.map(|v| v as i32);
}
}
/// Extract error message from an error event
fn extract_error(parsed: &serde_json::Value) -> Option<String> {
parsed
.get("message")
.and_then(|m| m.as_str())
.map(|s| s.to_string())
.or_else(|| {
parsed
.get("error")
.and_then(|e| e.get("message"))
.and_then(|m| m.as_str())
.map(|s| s.to_string())
})
}
/// Extract text from legacy message formats
fn extract_legacy_text(parsed: &serde_json::Value) -> Vec<String> {
let mut texts = Vec::new();
if let Some(content) = parsed.get("content").and_then(|c| c.as_array()) {
for item in content {
if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
texts.push(text.to_string());
}
}
}
if let Some(text) = parsed.get("text").and_then(|t| t.as_str()) {
texts.push(text.to_string());
}
if let Some(text) = parsed.get("result").and_then(|r| r.as_str()) {
texts.push(text.to_string());
}
texts
}
/// Build fallback text from non-JSON lines
fn build_fallback_text(lines: &[String]) -> Option<String> {
let response_text: String = lines
.iter()
.filter(|line| {
!line.starts_with('{')
|| serde_json::from_str::<serde_json::Value>(line)
.map(|v| v.get("type").is_none())
.unwrap_or(true)
})
.cloned()
.collect::<Vec<_>>()
.join("\n");
if response_text.trim().is_empty() {
None
} else {
Some(response_text)
}
}
/// Parse newline-delimited JSON response from Codex CLI
fn parse_response(&self, lines: &[String]) -> Result<(Message, Usage), ProviderError> {
let mut all_text_content = Vec::new();
let mut usage = Usage::default();
let mut error_message: Option<String> = None;
for line in lines {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(event_type) = parsed.get("type").and_then(|t| t.as_str()) {
match event_type {
"item.completed" => {
if let Some(item) = parsed.get("item") {
if let Some(text) = Self::extract_text_from_item(item) {
all_text_content.push(text);
}
}
}
"turn.completed" | "result" | "done" => {
if let Some(usage_info) = parsed.get("usage") {
Self::extract_usage(usage_info, &mut usage);
}
all_text_content.extend(Self::extract_legacy_text(&parsed));
}
"error" | "turn.failed" => {
error_message = Self::extract_error(&parsed);
}
"message" | "assistant" => {
all_text_content.extend(Self::extract_legacy_text(&parsed));
}
_ => {}
}
}
}
}
if let Some(err) = error_message {
if all_text_content.is_empty() {
return Err(ProviderError::RequestFailed(format!(
"Codex CLI error: {}",
err
)));
}
}
if all_text_content.is_empty() {
if let Some(fallback) = Self::build_fallback_text(lines) {
all_text_content.push(fallback);
}
}
if let (Some(input), Some(output)) = (usage.input_tokens, usage.output_tokens) {
usage.total_tokens = Some(input + output);
}
let combined_text = all_text_content.join("\n\n");
if combined_text.is_empty() {
return Err(ProviderError::RequestFailed(
"Empty response from Codex CLI".to_string(),
));
}
let message = Message::new(
Role::Assistant,
chrono::Utc::now().timestamp(),
vec![MessageContent::text(combined_text)],
);
Ok((message, usage))
}
/// Generate a simple session description without calling subprocess
fn generate_simple_session_description(
&self,
messages: &[Message],
) -> Result<(Message, ProviderUsage), ProviderError> {
// Extract the first user message text
let description = messages
.iter()
.find(|m| m.role == Role::User)
.and_then(|m| {
m.content.iter().find_map(|c| match c {
MessageContent::Text(text_content) => Some(&text_content.text),
_ => None,
})
})
.map(|text| {
// Take first few words, limit to 4 words
text.split_whitespace()
.take(4)
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_else(|| "Simple task".to_string());
if std::env::var("GOOSE_CODEX_DEBUG").is_ok() {
println!("=== CODEX PROVIDER DEBUG ===");
println!("Generated simple session description: {}", description);
println!("Skipped subprocess call for session description");
println!("============================");
}
let message = Message::new(
Role::Assistant,
chrono::Utc::now().timestamp(),
vec![MessageContent::text(description.clone())],
);
let usage = Usage::default();
Ok((
message,
ProviderUsage::new(self.model.model_name.clone(), usage),
))
}
}
#[async_trait]
impl Provider for CodexProvider {
fn metadata() -> ProviderMetadata {
ProviderMetadata::new(
"codex",
"OpenAI Codex CLI",
"Execute OpenAI models via Codex CLI tool. Requires codex CLI installed.",
CODEX_DEFAULT_MODEL,
CODEX_KNOWN_MODELS.to_vec(),
CODEX_DOC_URL,
vec![
ConfigKey::from_value_type::<CodexCommand>(true, false),
ConfigKey::from_value_type::<CodexReasoningEffort>(false, false),
ConfigKey::from_value_type::<CodexEnableSkills>(false, false),
ConfigKey::from_value_type::<CodexSkipGitCheck>(false, false),
],
)
}
fn get_name(&self) -> &str {
&self.name
}
fn get_model_config(&self) -> ModelConfig {
self.model.clone()
}
#[tracing::instrument(
skip(self, model_config, system, messages, tools),
fields(model_config, input, output, input_tokens, output_tokens, total_tokens)
)]
async fn complete_with_model(
&self,
model_config: &ModelConfig,
system: &str,
messages: &[Message],
tools: &[Tool],
) -> Result<(Message, ProviderUsage), ProviderError> {
// Check if this is a session description request
if system.contains("four words or less") || system.contains("4 words or less") {
return self.generate_simple_session_description(messages);
}
let lines = self.execute_command(system, messages, tools).await?;
let (message, usage) = self.parse_response(&lines)?;
// Create a payload for debug tracing
let payload = json!({
"command": self.command,
"model": model_config.model_name,
"reasoning_effort": self.reasoning_effort,
"enable_skills": self.enable_skills,
"system_length": system.len(),
"messages_count": messages.len()
});
let mut log = RequestLog::start(model_config, &payload).map_err(|e| {
ProviderError::RequestFailed(format!("Failed to start request log: {}", e))
})?;
let response = json!({
"lines": lines.len(),
"usage": usage
});
log.write(&response, Some(&usage)).map_err(|e| {
ProviderError::RequestFailed(format!("Failed to write request log: {}", e))
})?;
Ok((
message,
ProviderUsage::new(model_config.model_name.clone(), usage),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_codex_metadata() {
let metadata = CodexProvider::metadata();
assert_eq!(metadata.name, "codex");
assert_eq!(metadata.default_model, CODEX_DEFAULT_MODEL);
assert!(!metadata.known_models.is_empty());
// Check that the default model is in the known models
assert!(metadata
.known_models
.iter()
.any(|m| m.name == CODEX_DEFAULT_MODEL));
}
#[test]
fn test_messages_to_prompt_empty() {
let provider = CodexProvider {
command: PathBuf::from("codex"),
model: ModelConfig::new("gpt-5.2-codex").unwrap(),
name: "codex".to_string(),
reasoning_effort: "high".to_string(),
enable_skills: true,
skip_git_check: false,
};
let prompt = provider.messages_to_prompt("", &[]);
assert_eq!(prompt, "Assistant: ");
}
#[test]
fn test_messages_to_prompt_with_system() {
let provider = CodexProvider {
command: PathBuf::from("codex"),
model: ModelConfig::new("gpt-5.2-codex").unwrap(),
name: "codex".to_string(),
reasoning_effort: "high".to_string(),
enable_skills: true,
skip_git_check: false,
};
let prompt = provider.messages_to_prompt("You are a helpful assistant.", &[]);
assert!(prompt.starts_with("You are a helpful assistant."));
assert!(prompt.ends_with("Assistant: "));
}
#[test]
fn test_messages_to_prompt_with_messages() {
let provider = CodexProvider {
command: PathBuf::from("codex"),
model: ModelConfig::new("gpt-5.2-codex").unwrap(),
name: "codex".to_string(),
reasoning_effort: "high".to_string(),
enable_skills: true,
skip_git_check: false,
};
let messages = vec![
Message::new(
Role::User,
chrono::Utc::now().timestamp(),
vec![MessageContent::text("Hello")],
),
Message::new(
Role::Assistant,
chrono::Utc::now().timestamp(),
vec![MessageContent::text("Hi there!")],
),
];
let prompt = provider.messages_to_prompt("", &messages);
assert!(prompt.contains("Human: Hello"));
assert!(prompt.contains("Assistant: Hi there!"));
}
#[test]
fn test_parse_response_plain_text() {
let provider = CodexProvider {
command: PathBuf::from("codex"),
model: ModelConfig::new("gpt-5.2-codex").unwrap(),
name: "codex".to_string(),
reasoning_effort: "high".to_string(),
enable_skills: true,
skip_git_check: false,
};
let lines = vec!["Hello, world!".to_string()];
let result = provider.parse_response(&lines);
assert!(result.is_ok());
let (message, _usage) = result.unwrap();
assert_eq!(message.role, Role::Assistant);
assert!(message.content.len() == 1);
}
#[test]
fn test_parse_response_json_events() {
let provider = CodexProvider {
command: PathBuf::from("codex"),
model: ModelConfig::new("gpt-5.2-codex").unwrap(),
name: "codex".to_string(),
reasoning_effort: "high".to_string(),
enable_skills: true,
skip_git_check: false,
};
// Test with actual Codex CLI output format
let lines = vec![
r#"{"type":"thread.started","thread_id":"test-123"}"#.to_string(),
r#"{"type":"turn.started"}"#.to_string(),
r#"{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"Thinking..."}}"#.to_string(),
r#"{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Hello there!"}}"#.to_string(),
r#"{"type":"turn.completed","usage":{"input_tokens":100,"output_tokens":50,"cached_input_tokens":30}}"#.to_string(),
];
let result = provider.parse_response(&lines);
assert!(result.is_ok());
let (message, usage) = result.unwrap();
// Should only contain agent_message text, not reasoning
if let MessageContent::Text(text) = &message.content[0] {
assert!(text.text.contains("Hello there!"));
assert!(!text.text.contains("Thinking"));
}
assert_eq!(usage.input_tokens, Some(100));
assert_eq!(usage.output_tokens, Some(50));
assert_eq!(usage.total_tokens, Some(150));
}
#[test]
fn test_parse_response_empty() {
let provider = CodexProvider {
command: PathBuf::from("codex"),
model: ModelConfig::new("gpt-5.2-codex").unwrap(),
name: "codex".to_string(),
reasoning_effort: "high".to_string(),
enable_skills: true,
skip_git_check: false,
};
let lines: Vec<String> = vec![];
let result = provider.parse_response(&lines);
assert!(result.is_err());
}
#[test]
fn test_reasoning_level_validation() {
assert!(CODEX_REASONING_LEVELS.contains(&"low"));
assert!(CODEX_REASONING_LEVELS.contains(&"medium"));
assert!(CODEX_REASONING_LEVELS.contains(&"high"));
assert!(!CODEX_REASONING_LEVELS.contains(&"invalid"));
}
#[test]
fn test_known_models() {
assert!(CODEX_KNOWN_MODELS.contains(&"gpt-5.2-codex"));
assert!(CODEX_KNOWN_MODELS.contains(&"gpt-5.2"));
assert!(CODEX_KNOWN_MODELS.contains(&"gpt-5.1-codex-max"));
assert!(CODEX_KNOWN_MODELS.contains(&"gpt-5.1-codex-mini"));
}
#[test]
fn test_parse_response_item_completed() {
let provider = CodexProvider {
command: PathBuf::from("codex"),
model: ModelConfig::new("gpt-5.2-codex").unwrap(),
name: "codex".to_string(),
reasoning_effort: "high".to_string(),
enable_skills: true,
skip_git_check: false,
};
let lines = vec![
r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Hello from codex"}}"#.to_string(),
];
let result = provider.parse_response(&lines);
assert!(result.is_ok());
let (message, _usage) = result.unwrap();
if let MessageContent::Text(text) = &message.content[0] {
assert!(text.text.contains("Hello from codex"));
} else {
panic!("Expected text content");
}
}
#[test]
fn test_parse_response_turn_completed_usage() {
let provider = CodexProvider {
command: PathBuf::from("codex"),
model: ModelConfig::new("gpt-5.2-codex").unwrap(),
name: "codex".to_string(),
reasoning_effort: "high".to_string(),
enable_skills: true,
skip_git_check: false,
};
let lines = vec![
r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Response"}}"#.to_string(),
r#"{"type":"turn.completed","usage":{"input_tokens":5000,"output_tokens":100,"cached_input_tokens":3000}}"#.to_string(),
];
let result = provider.parse_response(&lines);
assert!(result.is_ok());
let (_message, usage) = result.unwrap();
assert_eq!(usage.input_tokens, Some(5000));
assert_eq!(usage.output_tokens, Some(100));
assert_eq!(usage.total_tokens, Some(5100));
}
#[test]
fn test_parse_response_error_event() {
let provider = CodexProvider {
command: PathBuf::from("codex"),
model: ModelConfig::new("gpt-5.2-codex").unwrap(),
name: "codex".to_string(),
reasoning_effort: "high".to_string(),
enable_skills: true,
skip_git_check: false,
};
let lines = vec![
r#"{"type":"thread.started","thread_id":"test"}"#.to_string(),
r#"{"type":"error","message":"Model not supported"}"#.to_string(),
];
let result = provider.parse_response(&lines);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Model not supported"));
}
#[test]
fn test_parse_response_skips_reasoning() {
let provider = CodexProvider {
command: PathBuf::from("codex"),
model: ModelConfig::new("gpt-5.2-codex").unwrap(),
name: "codex".to_string(),
reasoning_effort: "high".to_string(),
enable_skills: true,
skip_git_check: false,
};
let lines = vec![
r#"{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"Let me think about this..."}}"#.to_string(),
r#"{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"The answer is 42"}}"#.to_string(),
];
let result = provider.parse_response(&lines);
assert!(result.is_ok());
let (message, _usage) = result.unwrap();
if let MessageContent::Text(text) = &message.content[0] {
assert!(text.text.contains("The answer is 42"));
assert!(!text.text.contains("Let me think"));
} else {
panic!("Expected text content");
}
}
#[test]
fn test_session_description_generation() {
let provider = CodexProvider {
command: PathBuf::from("codex"),
model: ModelConfig::new("gpt-5.2-codex").unwrap(),
name: "codex".to_string(),
reasoning_effort: "high".to_string(),
enable_skills: true,
skip_git_check: false,
};
let messages = vec![Message::new(
Role::User,
chrono::Utc::now().timestamp(),
vec![MessageContent::text(
"This is a very long message that should be truncated to four words",
)],
)];
let result = provider.generate_simple_session_description(&messages);
assert!(result.is_ok());
let (message, _usage) = result.unwrap();
if let MessageContent::Text(text) = &message.content[0] {
// Should be truncated to 4 words
let word_count = text.text.split_whitespace().count();
assert!(word_count <= 4);
} else {
panic!("Expected text content");
}
}
#[test]
fn test_session_description_empty_messages() {
let provider = CodexProvider {
command: PathBuf::from("codex"),
model: ModelConfig::new("gpt-5.2-codex").unwrap(),
name: "codex".to_string(),
reasoning_effort: "high".to_string(),
enable_skills: true,
skip_git_check: false,
};
let messages: Vec<Message> = vec![];
let result = provider.generate_simple_session_description(&messages);
assert!(result.is_ok());
let (message, _usage) = result.unwrap();
if let MessageContent::Text(text) = &message.content[0] {
assert_eq!(text.text, "Simple task");
} else {
panic!("Expected text content");
}
}
#[test]
fn test_config_keys() {
let metadata = CodexProvider::metadata();
assert_eq!(metadata.config_keys.len(), 4);
// First key should be CODEX_COMMAND (required)
assert_eq!(metadata.config_keys[0].name, "CODEX_COMMAND");
assert!(metadata.config_keys[0].required);
assert!(!metadata.config_keys[0].secret);
// Second key should be CODEX_REASONING_EFFORT (optional)
assert_eq!(metadata.config_keys[1].name, "CODEX_REASONING_EFFORT");
assert!(!metadata.config_keys[1].required);
// Third key should be CODEX_ENABLE_SKILLS (optional)
assert_eq!(metadata.config_keys[2].name, "CODEX_ENABLE_SKILLS");
assert!(!metadata.config_keys[2].required);
// Fourth key should be CODEX_SKIP_GIT_CHECK (optional)
assert_eq!(metadata.config_keys[3].name, "CODEX_SKIP_GIT_CHECK");
assert!(!metadata.config_keys[3].required);
}
#[test]
fn test_messages_to_prompt_filters_non_text() {
let provider = CodexProvider {
command: PathBuf::from("codex"),
model: ModelConfig::new("gpt-5.2-codex").unwrap(),
name: "codex".to_string(),
reasoning_effort: "high".to_string(),
enable_skills: true,
skip_git_check: false,
};
// Create messages with both text and non-text content
let messages = vec![Message::new(
Role::User,
chrono::Utc::now().timestamp(),
vec![
MessageContent::text("Hello"),
// Tool requests would be filtered out as they're not text
],
)];
let prompt = provider.messages_to_prompt("System prompt", &messages);
assert!(prompt.contains("System prompt"));
assert!(prompt.contains("Human: Hello"));
}
#[test]
fn test_parse_response_multiple_agent_messages() {
let provider = CodexProvider {
command: PathBuf::from("codex"),
model: ModelConfig::new("gpt-5.2-codex").unwrap(),
name: "codex".to_string(),
reasoning_effort: "high".to_string(),
enable_skills: true,
skip_git_check: false,
};
let lines = vec![
r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"First part"}}"#.to_string(),
r#"{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Second part"}}"#.to_string(),
];
let result = provider.parse_response(&lines);
assert!(result.is_ok());
let (message, _usage) = result.unwrap();
if let MessageContent::Text(text) = &message.content[0] {
assert!(text.text.contains("First part"));
assert!(text.text.contains("Second part"));
} else {
panic!("Expected text content");
}
}
#[test]
fn test_doc_url() {
assert_eq!(CODEX_DOC_URL, "https://developers.openai.com/codex/cli");
}
#[test]
fn test_default_model() {
assert_eq!(CODEX_DEFAULT_MODEL, "gpt-5.2-codex");
}
}
+2
View File
@@ -6,6 +6,7 @@ use super::{
base::{Provider, ProviderMetadata},
bedrock::BedrockProvider,
claude_code::ClaudeCodeProvider,
codex::CodexProvider,
cursor_agent::CursorAgentProvider,
databricks::DatabricksProvider,
gcpvertexai::GcpVertexAIProvider,
@@ -47,6 +48,7 @@ async fn init_registry() -> RwLock<ProviderRegistry> {
registry.register::<BedrockProvider, _>(|m| Box::pin(BedrockProvider::from_env(m)), false);
registry
.register::<ClaudeCodeProvider, _>(|m| Box::pin(ClaudeCodeProvider::from_env(m)), true);
registry.register::<CodexProvider, _>(|m| Box::pin(CodexProvider::from_env(m)), true);
registry.register::<CursorAgentProvider, _>(
|m| Box::pin(CursorAgentProvider::from_env(m)),
false,
+1
View File
@@ -7,6 +7,7 @@ pub mod base;
pub mod bedrock;
pub mod canonical;
pub mod claude_code;
pub mod codex;
pub mod cursor_agent;
pub mod databricks;
pub mod embedding;
@@ -49,6 +49,7 @@ goose also supports special "pass-through" providers that work with existing CLI
| Provider | Description | Requirements |
|-----------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [Claude Code](https://www.anthropic.com/claude-code) (`claude-code`) | Uses Anthropic's Claude CLI tool with your Claude Code subscription. Provides access to Claude with 200K context limit. | Claude CLI installed and authenticated, active Claude Code subscription |
| [OpenAI Codex](https://developers.openai.com/codex/cli) (`codex`) | Uses OpenAI's Codex CLI tool with your ChatGPT Plus/Pro subscription. Provides access to GPT-5 models with up to 400K context limit. | Codex CLI installed and authenticated, active ChatGPT Plus/Pro subscription |
| [Cursor Agent](https://docs.cursor.com/en/cli/overview) (`cursor-agent`) | Uses Cursor's AI CLI tool with your Cursor subscription. Provides access to GPT-5, Claude 4, and other models through the cursor-agent command-line interface. | cursor-agent CLI installed and authenticated |
| [Gemini CLI](https://ai.google.dev/gemini-api/docs) (`gemini-cli`) | Uses Google's Gemini CLI tool with your Google AI subscription. Provides access to Gemini with 1M context limit. | Gemini CLI installed and authenticated |
+114 -6
View File
@@ -2,12 +2,12 @@
sidebar_position: 45
title: CLI Providers
sidebar_label: CLI Providers
description: Use Claude Code, Cursor Agent, or Gemini CLI subscriptions in goose
description: Use Claude Code, Codex, Cursor Agent, or Gemini CLI subscriptions in goose
---
# CLI Providers
goose can make use of pass-through providers that integrate with existing CLI tools from Anthropic, Cursor, and Google. These providers allow you to use your existing Claude Code, Cursor Agent, and Google Gemini CLI subscriptions through goose's interface, adding session management, persistence, and workflow integration capabilities to these tools.
goose can make use of pass-through providers that integrate with existing CLI tools from Anthropic, OpenAI, Cursor, and Google. These providers allow you to use your existing Claude Code, Codex, Cursor Agent, and Google Gemini CLI subscriptions through goose's interface, adding session management, persistence, and workflow integration capabilities to these tools.
:::warning Limitations
These providers dont fully support all goose features, may have platform or capability limitations, and can sometimes require advanced debugging if issues arise. Theyre included here purely as a convenience.
@@ -17,7 +17,7 @@ These providers dont fully support all goose features, may have platform or c
CLI providers are useful if you:
- already have a Claude Code, Cursor, or Google Gemini CLI subscription and want to use it through goose instead of paying per token
- already have a Claude Code, Codex, Cursor, or Google Gemini CLI subscription and want to use it through goose instead of paying per token
- need session persistence to save, resume, and export conversation history
- want to use goose recipes and scheduled tasks to create repeatable workflows
- prefer unified commands across different AI providers
@@ -61,6 +61,23 @@ The Claude Code provider integrates with Anthropic's [Claude CLI tool](https://c
- Active Claude Code subscription
- CLI tool authenticated with your Anthropic account
### OpenAI Codex
The Codex provider integrates with OpenAI's [Codex CLI tool](https://developers.openai.com/codex/cli), allowing you to use OpenAI models through your existing ChatGPT Plus/Pro subscription or API credits.
**Features:**
- Uses OpenAI's GPT-5 series models (gpt-5.2-codex, gpt-5.2, gpt-5.1-codex-max, gpt-5.1-codex-mini)
- Configurable reasoning effort levels (low, medium, high)
- Optional skills support for enhanced capabilities
- JSON output parsing for structured responses
- Automatic filtering of goose extensions from system prompts
**Requirements:**
- Codex CLI tool installed (`npm i -g @openai/codex` or `brew install --cask codex`)
- Active ChatGPT Plus/Pro subscription or OpenAI API credits
- CLI tool authenticated with your OpenAI account
- By default, Codex requires running from a git repository. Set `CODEX_SKIP_GIT_CHECK=true` to bypass this requirement
### Cursor Agent
The Cursor provider integrates with Cursor's [CLI agent](https://docs.cursor.com/en/cli/installation), providing access to through your existing subscription.
@@ -121,7 +138,46 @@ The Gemini CLI provider integrates with Google's [Gemini CLI tool](https://ai.go
◇ Enter a model from that provider:
│ default
```
## Cursor Agent
### OpenAI Codex
1. **Install Codex CLI Tool**
Install the Codex CLI using npm or Homebrew:
```bash
npm i -g @openai/codex
# or
brew install --cask codex
```
2. **Authenticate with OpenAI**
Run `codex` and follow the authentication prompts. You can use your ChatGPT account or API key.
3. **Configure goose**
Set the provider environment variable:
```bash
export GOOSE_PROVIDER=codex
```
Or configure through the goose CLI using `goose configure`:
```bash
┌ goose-configure
◇ What would you like to configure?
│ Configure Providers
◇ Which model provider should we use?
│ OpenAI Codex CLI
◇ Model fetch complete
◇ Enter a model from that provider:
│ gpt-5.2-codex
```
### Cursor Agent
1. **Install Cursor agent Tool**
@@ -136,7 +192,7 @@ The Gemini CLI provider integrates with Google's [Gemini CLI tool](https://ai.go
Set the provider environment variable:
```bash
export goose_provider=cursor-agent
export GOOSE_PROVIDER=cursor-agent
```
Or configure through the goose CLI using `goose configure`:
@@ -145,7 +201,7 @@ The Gemini CLI provider integrates with Google's [Gemini CLI tool](https://ai.go
┌ goose-configure
◇ What would you like to configure?
configure providers
Configure Providers
◇ Which model provider should we use?
│ Cursor Agent
@@ -221,8 +277,24 @@ goose session
| Environment Variable | Description | Default |
|---------------------|-------------|---------|
| `GOOSE_PROVIDER` | Set to `claude-code` to use this provider | None |
| `GOOSE_MODEL` | Model to use (only `sonnet` or `opus` are passed to CLI) | `claude-sonnet-4-20250514` |
| `CLAUDE_CODE_COMMAND` | Path to the Claude CLI command | `claude` |
**Known Models:**
The following models are recognized and passed to the Claude CLI via the `--model` flag. If `GOOSE_MODEL` is set to a value not in this list, no model flag is passed and Claude Code uses its default:
- `sonnet`
- `opus`
**Permission Modes (`GOOSE_MODE`):**
| Mode | Claude Code Flag | Behavior |
|------|------------------|----------|
| `auto` | `--dangerously-skip-permissions` | Bypasses all permission prompts |
| `smart-approve` | `--permission-mode acceptEdits` | Auto-accepts edits, prompts for other actions |
| `approve` | Not supported | Returns an error |
| `chat` | (none) | Default Claude Code behavior |
### Cursor Agent Configuration
@@ -231,6 +303,39 @@ goose session
| `GOOSE_PROVIDER` | Set to `cursor-agent` to use this provider | None |
| `CURSOR_AGENT_COMMAND` | Path to the Cursor Agent command | `cursor-agent` |
### OpenAI Codex Configuration
| Environment Variable | Description | Default |
|---------------------|-------------|---------|
| `GOOSE_PROVIDER` | Set to `codex` to use this provider | None |
| `GOOSE_MODEL` | Model to use (only known models are passed to CLI) | `gpt-5.2-codex` |
| `CODEX_COMMAND` | Path to the Codex CLI command | `codex` |
| `CODEX_REASONING_EFFORT` | Reasoning effort level: `low`, `medium`, or `high` | `high` |
| `CODEX_ENABLE_SKILLS` | Enable Codex skills: `true` or `false` | `true` |
| `CODEX_SKIP_GIT_CHECK` | Skip git repository requirement: `true` or `false` | `false` |
**Known Models:**
The following models are recognized and passed to the Codex CLI via the `-m` flag. If `GOOSE_MODEL` is set to a value not in this list, no model flag is passed and Codex uses its default:
- `gpt-5.2-codex` (400K context, auto-compacting)
- `gpt-5.2` (400K context, auto-compacting)
- `gpt-5.1-codex-max` (256K context)
- `gpt-5.1-codex-mini` (256K context)
:::note Legacy Models
These are the default models supported by Codex CLI v0.77.0. To access older or legacy models, you can run `codex -m <model_name>` directly or configure them in Codex's `config.toml`. See the [Codex CLI documentation](https://developers.openai.com/codex/cli) for details.
:::
**Permission Modes (`GOOSE_MODE`):**
| Mode | Codex Flag | Behavior |
|------|------------|----------|
| `auto` | `--yolo` | Bypasses all approvals and sandbox restrictions |
| `smart-approve` | `--full-auto` | Workspace-write sandbox, approvals only on failure |
| `approve` | (none) | Interactive approvals (Codex default behavior) |
| `chat` | `--sandbox read-only` | Read-only sandbox mode |
### Gemini CLI Configuration
| Environment Variable | Description | Default |
@@ -247,12 +352,14 @@ The CLI providers automatically filter out goose's extension information from sy
### Message Translation
- **Claude Code**: Converts goose messages to Claude's JSON message format, handling tool calls and responses appropriately
- **Codex**: Converts messages to simple text prompts with role prefixes (Human:/Assistant:), similar to Gemini CLI
- **Cursor Agent**: Converts goose messages to Cursor's JSON message format, handling tool calls and responses appropriately
- **Gemini CLI**: Converts messages to simple text prompts with role prefixes (Human:/Assistant:)
### Response Processing
- **Claude Code**: Parses JSON responses to extract text content and usage information
- **Codex**: Parses newline-delimited JSON events to extract text content and usage information
- **Cursor Agent**: Parses JSON responses to extract text content and usage information
- **Gemini CLI**: Processes plain text responses from the CLI tool
@@ -263,6 +370,7 @@ CLI providers depend on external tools, so ensure:
- CLI tools are properly installed and in your PATH
- Authentication is maintained and valid
- Subscription limits are not exceeded
- For Codex: you're in a git repository, or set `CODEX_SKIP_GIT_CHECK=true`
---
+2 -1
View File
@@ -160,7 +160,8 @@ goose works with [supported LLM providers](/docs/getting-started/providers) that
│ ○ Anthropic
│ ○ Azure OpenAI
│ ○ Amazon Bedrock
│ ○ Claude Code
│ ○ Claude Code
│ ○ Codex CLI
│ ○ Databricks
│ ○ Gemini CLI
| ● Tetrate Agent Router Service (Enterprise router for AI models)