pub mod aider;
pub mod claude_api;
pub mod claude_code;
pub mod codex;
pub mod custom;
use anyhow::Result;
use async_trait::async_trait;
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use tokio::process::Command;
use crate::agent::{Task, TaskResult};
use crate::identity::AgentIdentity;
use std::path::Path;
#[derive(Clone)]
pub struct SensitiveString(SecretString);
impl SensitiveString {
pub fn new(value: impl Into<String>) -> Self {
Self(SecretString::new(value.into().into()))
}
pub fn expose(&self) -> &str {
self.0.expose_secret()
}
pub fn is_empty(&self) -> bool {
self.0.expose_secret().is_empty()
}
}
impl std::fmt::Debug for SensitiveString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "SensitiveString(****)")
}
}
impl std::fmt::Display for SensitiveString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "****")
}
}
impl Serialize for SensitiveString {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str("[REDACTED]")
}
}
impl<'de> Deserialize<'de> for SensitiveString {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
if s == "[REDACTED]" {
Ok(Self::new(""))
} else {
Ok(Self::new(s))
}
}
}
impl Default for SensitiveString {
fn default() -> Self {
Self::new("")
}
}
impl From<String> for SensitiveString {
fn from(s: String) -> Self {
Self::new(s)
}
}
impl From<&str> for SensitiveString {
fn from(s: &str) -> Self {
Self::new(s)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum AIProvider {
#[default]
ClaudeCode,
Aider,
Codex,
Custom,
}
impl AIProvider {
pub fn display_name(&self) -> &'static str {
match self {
AIProvider::ClaudeCode => "Claude Code",
AIProvider::Aider => "Aider",
AIProvider::Codex => "OpenAI Codex",
AIProvider::Custom => "Custom",
}
}
pub fn color(&self) -> &'static str {
match self {
AIProvider::ClaudeCode => "blue",
AIProvider::Aider => "green",
AIProvider::Codex => "purple",
AIProvider::Custom => "gray",
}
}
pub fn icon(&self) -> &'static str {
match self {
AIProvider::ClaudeCode => "🤖",
AIProvider::Aider => "🔧",
AIProvider::Codex => "🧠",
AIProvider::Custom => "⚙️",
}
}
}
#[async_trait]
pub trait ProviderConfig: Send + Sync + Clone {
async fn validate(&self) -> Result<()>;
fn get_env_vars(&self) -> HashMap<String, String>;
fn get_working_directory(&self) -> Option<PathBuf>;
async fn is_available(&self) -> bool;
}
#[async_trait]
pub trait ProviderExecutor: Send + Sync {
async fn execute_prompt(
&self,
prompt: &str,
identity: &AgentIdentity,
working_dir: &Path,
) -> Result<String>;
async fn execute_task(
&self,
task: &Task,
identity: &AgentIdentity,
working_dir: &Path,
) -> Result<TaskResult>;
async fn health_check(&self, working_dir: &Path) -> Result<ProviderHealthStatus>;
fn get_capabilities(&self) -> ProviderCapabilities;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderHealthStatus {
pub is_healthy: bool,
pub version: Option<String>,
pub last_check: chrono::DateTime<chrono::Utc>,
pub error_message: Option<String>,
pub response_time_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderCapabilities {
pub supports_json_output: bool,
pub supports_streaming: bool,
pub supports_file_operations: bool,
pub supports_git_operations: bool,
pub supports_code_execution: bool,
pub max_context_length: Option<usize>,
pub supported_languages: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub enum OutputFormat {
#[default]
Text,
Json,
StreamJson,
}
impl OutputFormat {
pub fn as_cli_arg(&self) -> &'static str {
match self {
OutputFormat::Text => "text",
OutputFormat::Json => "json",
OutputFormat::StreamJson => "stream-json",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaudeCodeConfig {
pub model: String,
pub dangerous_skip: bool,
pub output_format: OutputFormat,
pub append_system_prompt: Option<String>,
pub custom_commands: Vec<String>,
pub mcp_servers: HashMap<String, serde_json::Value>,
pub api_key: Option<SensitiveString>,
pub session_id: Option<String>,
pub resume_session: Option<String>,
pub continue_session: bool,
pub fork_session: bool,
pub max_turns: Option<u32>,
pub fallback_model: Option<String>,
pub allowed_tools: Vec<String>,
pub disallowed_tools: Vec<String>,
pub verbose: bool,
pub mcp_debug: bool,
}
impl Default for ClaudeCodeConfig {
fn default() -> Self {
Self {
model: "sonnet".to_string(), dangerous_skip: false,
output_format: OutputFormat::Json,
append_system_prompt: None,
custom_commands: Vec::new(),
mcp_servers: HashMap::new(),
api_key: None,
session_id: None,
resume_session: None,
continue_session: false,
fork_session: false,
max_turns: None,
fallback_model: None,
allowed_tools: Vec::new(),
disallowed_tools: Vec::new(),
verbose: false,
mcp_debug: false,
}
}
}
#[async_trait]
impl ProviderConfig for ClaudeCodeConfig {
async fn validate(&self) -> Result<()> {
if !self.is_available().await {
return Err(anyhow::anyhow!("Claude Code CLI not found in PATH"));
}
if self.model.is_empty() {
return Err(anyhow::anyhow!("Model name cannot be empty"));
}
Ok(())
}
fn get_env_vars(&self) -> HashMap<String, String> {
let mut env_vars = HashMap::new();
if let Some(api_key) = &self.api_key {
env_vars.insert(
"ANTHROPIC_API_KEY".to_string(),
api_key.expose().to_string(),
);
}
env_vars
}
fn get_working_directory(&self) -> Option<PathBuf> {
None }
async fn is_available(&self) -> bool {
Command::new("claude")
.arg("--version")
.env_remove("CLAUDECODE")
.env_remove("CLAUDE_CODE_ENTRYPOINT")
.output()
.await
.map(|output| output.status.success())
.unwrap_or(false)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AiderConfig {
pub model: String,
pub openai_api_key: Option<SensitiveString>,
pub anthropic_api_key: Option<SensitiveString>,
pub auto_commit: bool,
pub git: bool,
pub additional_args: Vec<String>,
pub executable_path: Option<PathBuf>,
}
impl Default for AiderConfig {
fn default() -> Self {
Self {
model: "gpt-4".to_string(),
openai_api_key: None,
anthropic_api_key: None,
auto_commit: true,
git: true,
additional_args: Vec::new(),
executable_path: None,
}
}
}
#[async_trait]
impl ProviderConfig for AiderConfig {
async fn validate(&self) -> Result<()> {
if !self.is_available().await {
return Err(anyhow::anyhow!("Aider not found in PATH"));
}
if self.model.starts_with("gpt-") && self.openai_api_key.is_none() {
return Err(anyhow::anyhow!("OpenAI API key required for GPT models"));
}
if self.model.starts_with("claude-") && self.anthropic_api_key.is_none() {
return Err(anyhow::anyhow!(
"Anthropic API key required for Claude models"
));
}
Ok(())
}
fn get_env_vars(&self) -> HashMap<String, String> {
let mut env_vars = HashMap::new();
if let Some(openai_key) = &self.openai_api_key {
env_vars.insert(
"OPENAI_API_KEY".to_string(),
openai_key.expose().to_string(),
);
}
if let Some(anthropic_key) = &self.anthropic_api_key {
env_vars.insert(
"ANTHROPIC_API_KEY".to_string(),
anthropic_key.expose().to_string(),
);
}
env_vars
}
fn get_working_directory(&self) -> Option<PathBuf> {
None }
async fn is_available(&self) -> bool {
let cmd = if let Some(path) = &self.executable_path {
path.to_string_lossy().to_string()
} else {
"aider".to_string()
};
Command::new(&cmd)
.arg("--version")
.output()
.await
.map(|output| output.status.success())
.unwrap_or(false)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodexConfig {
pub api_key: SensitiveString,
pub model: String,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
pub api_base: Option<String>,
pub organization: Option<String>,
pub json_mode: Option<bool>,
pub stream: Option<bool>,
}
impl Default for CodexConfig {
fn default() -> Self {
Self {
api_key: SensitiveString::new(std::env::var("OPENAI_API_KEY").unwrap_or_default()),
model: "gpt-4".to_string(), max_tokens: Some(2048),
temperature: Some(0.1),
api_base: None,
organization: None,
json_mode: None,
stream: None,
}
}
}
#[async_trait]
impl ProviderConfig for CodexConfig {
async fn validate(&self) -> Result<()> {
if self.api_key.is_empty() {
return Err(anyhow::anyhow!("OpenAI API key is required"));
}
if self.model.is_empty() {
return Err(anyhow::anyhow!("Model name cannot be empty"));
}
if let Some(temp) = self.temperature
&& !(0.0..=1.0).contains(&temp)
{
return Err(anyhow::anyhow!("Temperature must be between 0.0 and 1.0"));
}
Ok(())
}
fn get_env_vars(&self) -> HashMap<String, String> {
let mut env_vars = HashMap::new();
env_vars.insert(
"OPENAI_API_KEY".to_string(),
self.api_key.expose().to_string(),
);
if let Some(org) = &self.organization {
env_vars.insert("OPENAI_ORGANIZATION".to_string(), org.clone());
}
if let Some(base) = &self.api_base {
env_vars.insert("OPENAI_API_BASE".to_string(), base.clone());
}
env_vars
}
fn get_working_directory(&self) -> Option<PathBuf> {
None
}
async fn is_available(&self) -> bool {
!self.api_key.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomConfig {
pub command: String,
pub args: Vec<String>,
pub env_vars: HashMap<String, String>,
pub working_directory: Option<PathBuf>,
pub timeout_seconds: Option<u64>,
pub supports_json: bool,
}
impl Default for CustomConfig {
fn default() -> Self {
Self {
command: "echo".to_string(),
args: vec!["{prompt}".to_string()],
env_vars: HashMap::new(),
working_directory: None,
timeout_seconds: Some(300), supports_json: false,
}
}
}
#[async_trait]
impl ProviderConfig for CustomConfig {
async fn validate(&self) -> Result<()> {
if self.command.is_empty() {
return Err(anyhow::anyhow!("Command cannot be empty"));
}
if !self.is_available().await {
return Err(anyhow::anyhow!("Command '{}' not found", self.command));
}
Ok(())
}
fn get_env_vars(&self) -> HashMap<String, String> {
self.env_vars.clone()
}
fn get_working_directory(&self) -> Option<PathBuf> {
self.working_directory.clone()
}
async fn is_available(&self) -> bool {
Command::new(&self.command)
.arg("--help")
.output()
.await
.is_ok()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderConfiguration {
pub provider_type: AIProvider,
pub claude_code: Option<ClaudeCodeConfig>,
pub aider: Option<AiderConfig>,
pub codex: Option<CodexConfig>,
pub custom: Option<CustomConfig>,
}
impl Default for ProviderConfiguration {
fn default() -> Self {
Self {
provider_type: AIProvider::ClaudeCode,
claude_code: Some(ClaudeCodeConfig::default()),
aider: None,
codex: None,
custom: None,
}
}
}
impl ProviderConfiguration {
pub fn claude_code(config: ClaudeCodeConfig) -> Self {
Self {
provider_type: AIProvider::ClaudeCode,
claude_code: Some(config),
aider: None,
codex: None,
custom: None,
}
}
pub fn aider(config: AiderConfig) -> Self {
Self {
provider_type: AIProvider::Aider,
claude_code: None,
aider: Some(config),
codex: None,
custom: None,
}
}
pub fn codex(config: CodexConfig) -> Self {
Self {
provider_type: AIProvider::Codex,
claude_code: None,
aider: None,
codex: Some(config),
custom: None,
}
}
pub fn custom(config: CustomConfig) -> Self {
Self {
provider_type: AIProvider::Custom,
claude_code: None,
aider: None,
codex: None,
custom: Some(config),
}
}
pub async fn validate(&self) -> Result<()> {
match self.provider_type {
AIProvider::ClaudeCode => {
if let Some(config) = &self.claude_code {
config.validate().await
} else {
Err(anyhow::anyhow!("Claude Code configuration missing"))
}
}
AIProvider::Aider => {
if let Some(config) = &self.aider {
config.validate().await
} else {
Err(anyhow::anyhow!("Aider configuration missing"))
}
}
AIProvider::Codex => {
if let Some(config) = &self.codex {
config.validate().await
} else {
Err(anyhow::anyhow!("Codex configuration missing"))
}
}
AIProvider::Custom => {
if let Some(config) = &self.custom {
config.validate().await
} else {
Err(anyhow::anyhow!("Custom configuration missing"))
}
}
}
}
pub fn get_env_vars(&self) -> HashMap<String, String> {
match self.provider_type {
AIProvider::ClaudeCode => self
.claude_code
.as_ref()
.map(|c| c.get_env_vars())
.unwrap_or_default(),
AIProvider::Aider => self
.aider
.as_ref()
.map(|c| c.get_env_vars())
.unwrap_or_default(),
AIProvider::Codex => self
.codex
.as_ref()
.map(|c| c.get_env_vars())
.unwrap_or_default(),
AIProvider::Custom => self
.custom
.as_ref()
.map(|c| c.get_env_vars())
.unwrap_or_default(),
}
}
pub async fn is_available(&self) -> bool {
match self.provider_type {
AIProvider::ClaudeCode => {
if let Some(config) = &self.claude_code {
config.is_available().await
} else {
false
}
}
AIProvider::Aider => {
if let Some(config) = &self.aider {
config.is_available().await
} else {
false
}
}
AIProvider::Codex => {
if let Some(config) = &self.codex {
config.is_available().await
} else {
false
}
}
AIProvider::Custom => {
if let Some(config) = &self.custom {
config.is_available().await
} else {
false
}
}
}
}
}
pub struct ProviderFactory;
impl ProviderFactory {
pub fn create_executor(config: &ProviderConfiguration) -> Result<Box<dyn ProviderExecutor>> {
match config.provider_type {
AIProvider::ClaudeCode => {
if let Some(claude_config) = &config.claude_code {
Ok(Box::new(claude_code::ClaudeCodeExecutor::new(
claude_config.clone(),
)))
} else {
Err(anyhow::anyhow!("Claude Code configuration missing"))
}
}
AIProvider::Aider => {
if let Some(aider_config) = &config.aider {
Ok(Box::new(aider::AiderExecutor::new(aider_config.clone())))
} else {
Err(anyhow::anyhow!("Aider configuration missing"))
}
}
AIProvider::Codex => {
if let Some(codex_config) = &config.codex {
Ok(Box::new(codex::CodexExecutor::new(codex_config.clone())?))
} else {
Err(anyhow::anyhow!("Codex configuration missing"))
}
}
AIProvider::Custom => {
if let Some(custom_config) = &config.custom {
Ok(Box::new(custom::CustomExecutor::new(custom_config.clone())))
} else {
Err(anyhow::anyhow!("Custom configuration missing"))
}
}
}
}
}