use std::collections::HashMap;
use std::path::PathBuf;
use terraphim_types::capability::{Provider, ProviderType};
#[derive(Debug, Clone, Default)]
pub struct ResourceLimits {
pub max_memory_bytes: Option<u64>,
pub max_cpu_seconds: Option<u64>,
pub max_file_size_bytes: Option<u64>,
pub max_open_files: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct AgentConfig {
pub agent_id: String,
pub cli_command: String,
pub args: Vec<String>,
pub working_dir: Option<PathBuf>,
pub env_vars: HashMap<String, String>,
pub required_api_keys: Vec<String>,
pub resource_limits: ResourceLimits,
pub use_stdin: bool,
}
impl AgentConfig {
pub fn from_provider(provider: &Provider) -> Result<Self, ValidationError> {
match &provider.provider_type {
ProviderType::Agent {
agent_id,
cli_command,
working_dir,
} => Ok(Self {
agent_id: agent_id.clone(),
cli_command: cli_command.clone(),
args: Self::infer_args(cli_command),
working_dir: Some(working_dir.clone()),
env_vars: HashMap::new(),
required_api_keys: Self::infer_api_keys(cli_command),
resource_limits: ResourceLimits::default(),
use_stdin: false,
}),
ProviderType::Llm { .. } => Err(ValidationError::NotAnAgent(provider.id.clone())),
}
}
pub fn with_model(mut self, model: &str) -> Self {
let model_args = Self::model_args(&self.cli_command, model);
self.args.extend(model_args);
self
}
pub fn with_stdin(mut self, use_stdin: bool) -> Self {
self.use_stdin = use_stdin;
self
}
pub fn with_resource_limits(mut self, limits: ResourceLimits) -> Self {
self.resource_limits = limits;
self
}
fn cli_name(cli_command: &str) -> &str {
std::path::Path::new(cli_command)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(cli_command)
}
fn infer_args(cli_command: &str) -> Vec<String> {
match Self::cli_name(cli_command) {
"codex" => vec!["exec".to_string(), "--full-auto".to_string()],
"claude" | "claude-code" => vec![
"-p".to_string(),
"--allowedTools".to_string(),
"Bash,Read,Write,Edit,Glob,Grep".to_string(),
],
"opencode" => vec![
"run".to_string(),
"--format".to_string(),
"json".to_string(),
],
_ => Vec::new(),
}
}
fn normalise_claude_model(model: &str) -> String {
if model.starts_with("claude-") {
return model.to_string();
}
if model.contains('-') {
format!("claude-{}", model)
} else {
model.to_string()
}
}
fn model_args(cli_command: &str, model: &str) -> Vec<String> {
match Self::cli_name(cli_command) {
"codex" => vec!["-m".to_string(), model.to_string()],
"claude" | "claude-code" => {
let normalised = Self::normalise_claude_model(model);
vec!["--model".to_string(), normalised]
}
"opencode" => vec!["-m".to_string(), model.to_string()],
_ => vec![],
}
}
fn infer_api_keys(cli_command: &str) -> Vec<String> {
match Self::cli_name(cli_command) {
"claude" | "claude-code" => Vec::new(),
"opencode" => Vec::new(),
_ => Vec::new(),
}
}
}
#[derive(thiserror::Error, Debug)]
pub enum ValidationError {
#[error("Provider {0} is not an agent")]
NotAnAgent(String),
#[error("CLI command not found: {0}")]
CliNotFound(String),
#[error("Required API key not set: {0}")]
ApiKeyNotSet(String),
#[error("Working directory does not exist: {0}")]
WorkingDirNotFound(PathBuf),
}
pub struct AgentValidator {
config: AgentConfig,
}
impl AgentValidator {
pub fn new(config: &AgentConfig) -> Self {
Self {
config: config.clone(),
}
}
pub async fn validate(&self) -> Result<(), ValidationError> {
self.validate_cli().await?;
self.validate_api_keys().await?;
self.validate_working_dir().await?;
Ok(())
}
async fn validate_cli(&self) -> Result<(), ValidationError> {
let cmd = &self.config.cli_command;
let path = std::path::Path::new(cmd);
if path.is_absolute() {
if path.exists() {
return Ok(());
}
return Err(ValidationError::CliNotFound(cmd.clone()));
}
let check = tokio::process::Command::new("which")
.arg(cmd)
.output()
.await;
match check {
Ok(output) if output.status.success() => Ok(()),
_ => Err(ValidationError::CliNotFound(cmd.clone())),
}
}
async fn validate_api_keys(&self) -> Result<(), ValidationError> {
for key in &self.config.required_api_keys {
if std::env::var(key).is_err() {
return Err(ValidationError::ApiKeyNotSet(key.clone()));
}
}
Ok(())
}
async fn validate_working_dir(&self) -> Result<(), ValidationError> {
if let Some(dir) = &self.config.working_dir {
if !dir.exists() {
return Err(ValidationError::WorkingDirNotFound(dir.clone()));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resource_limits_default() {
let limits = ResourceLimits::default();
assert!(limits.max_memory_bytes.is_none());
assert!(limits.max_cpu_seconds.is_none());
assert!(limits.max_file_size_bytes.is_none());
assert!(limits.max_open_files.is_none());
}
#[test]
fn test_infer_api_keys() {
let keys = AgentConfig::infer_api_keys("claude");
assert!(
keys.is_empty(),
"claude uses OAuth, should not require API key"
);
let keys = AgentConfig::infer_api_keys("opencode");
assert!(
keys.is_empty(),
"opencode manages its own per-provider auth"
);
let keys = AgentConfig::infer_api_keys("unknown");
assert!(keys.is_empty());
}
#[test]
fn test_infer_api_keys_full_path() {
let keys = AgentConfig::infer_api_keys("/home/alex/.local/bin/claude");
assert!(keys.is_empty(), "claude via full path uses OAuth");
let keys = AgentConfig::infer_api_keys("/home/alex/.bun/bin/opencode");
assert!(
keys.is_empty(),
"opencode via full path manages its own auth"
);
}
#[test]
fn test_normalise_claude_model() {
assert_eq!(
AgentConfig::normalise_claude_model("claude-opus-4-6"),
"claude-opus-4-6"
);
assert_eq!(
AgentConfig::normalise_claude_model("claude-sonnet-4-6"),
"claude-sonnet-4-6"
);
assert_eq!(
AgentConfig::normalise_claude_model("opus-4-6"),
"claude-opus-4-6"
);
assert_eq!(
AgentConfig::normalise_claude_model("sonnet-4-6"),
"claude-sonnet-4-6"
);
assert_eq!(AgentConfig::normalise_claude_model("opus"), "opus");
assert_eq!(AgentConfig::normalise_claude_model("sonnet"), "sonnet");
assert_eq!(AgentConfig::normalise_claude_model("haiku"), "haiku");
}
#[test]
fn test_model_args_claude_normalises() {
let args = AgentConfig::model_args("claude", "opus-4-6");
assert_eq!(
args,
vec!["--model".to_string(), "claude-opus-4-6".to_string()]
);
let args = AgentConfig::model_args("claude", "claude-opus-4-6");
assert_eq!(
args,
vec!["--model".to_string(), "claude-opus-4-6".to_string()]
);
let args = AgentConfig::model_args("claude", "sonnet");
assert_eq!(args, vec!["--model".to_string(), "sonnet".to_string()]);
}
#[test]
fn test_cli_name_extraction() {
assert_eq!(
AgentConfig::cli_name("/home/alex/.local/bin/claude"),
"claude"
);
assert_eq!(
AgentConfig::cli_name("/home/alex/.bun/bin/opencode"),
"opencode"
);
assert_eq!(AgentConfig::cli_name("claude"), "claude");
assert_eq!(AgentConfig::cli_name("/usr/bin/codex"), "codex");
}
#[test]
fn test_infer_args_opencode() {
let args = AgentConfig::infer_args("opencode");
assert_eq!(args, vec!["run", "--format", "json"]);
}
#[test]
fn test_infer_args_opencode_full_path() {
let args = AgentConfig::infer_args("/home/alex/.bun/bin/opencode");
assert_eq!(args, vec!["run", "--format", "json"]);
}
#[test]
fn test_model_args_opencode() {
let args = AgentConfig::model_args("opencode", "kimi-for-coding/k2p5");
assert_eq!(args, vec!["-m", "kimi-for-coding/k2p5"]);
}
#[test]
fn test_model_args_opencode_full_path() {
let args = AgentConfig::model_args("/home/alex/.bun/bin/opencode", "opencode-go/kimi-k2.5");
assert_eq!(args, vec!["-m", "opencode-go/kimi-k2.5"]);
}
}