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(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,
pub supports_stdin: bool,
}
fn is_sensitive_key(key: &str) -> bool {
let upper = key.to_uppercase();
upper.contains("TOKEN")
|| upper.contains("KEY")
|| upper.contains("SECRET")
|| upper.contains("PASSWORD")
}
impl std::fmt::Debug for AgentConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let redacted: std::collections::HashMap<&str, &str> = self
.env_vars
.iter()
.map(|(k, v)| {
let val: &str = if is_sensitive_key(k) {
"***REDACTED***"
} else {
v.as_str()
};
(k.as_str(), val)
})
.collect();
f.debug_struct("AgentConfig")
.field("agent_id", &self.agent_id)
.field("cli_command", &self.cli_command)
.field("args", &self.args)
.field("working_dir", &self.working_dir)
.field("env_vars", &redacted)
.field("required_api_keys", &self.required_api_keys)
.field("resource_limits", &self.resource_limits)
.field("use_stdin", &self.use_stdin)
.field("supports_stdin", &self.supports_stdin)
.finish()
}
}
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,
supports_stdin: Self::infer_supports_stdin(cli_command),
}),
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_supports_stdin(cli_command: &str) -> bool {
!matches!(Self::cli_name(cli_command), "opencode")
}
pub fn skill_dir_name(cli_command: &str) -> Option<&'static str> {
match Self::cli_name(cli_command) {
"opencode" => Some(".opencode/skill"),
"claude" | "claude-code" => Some(".claude/skills"),
_ => None,
}
}
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=Bash,Read,Write,Edit,Glob,Grep".to_string(),
],
"opencode" => vec![
"run".to_string(),
"--format".to_string(),
"json".to_string(),
],
"pi-rust" => vec!["-p".to_string(), "--mode".to_string(), "json".to_string()],
"pi" => vec!["prompt".to_string()],
"bash" | "sh" => vec!["-c".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()],
"pi-rust" => {
let mut args = Vec::new();
if let Some((provider, model_id)) = model.split_once('/') {
args.push("--provider".to_string());
args.push(provider.to_string());
args.push("--model".to_string());
args.push(model_id.to_string());
} else {
args.push("--model".to_string());
args.push(model.to_string());
}
args
}
"pi" => vec![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),
#[error("pi CLI requires a model alias for `pi prompt <model> <prompt>`")]
PiModelRequired,
}
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_cli_contract()?;
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(())
}
fn validate_cli_contract(&self) -> Result<(), ValidationError> {
if AgentConfig::cli_name(&self.config.cli_command) == "pi"
&& self.config.args == ["prompt".to_string()]
{
return Err(ValidationError::PiModelRequired);
}
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_supports_stdin() {
assert!(!AgentConfig::infer_supports_stdin("opencode"));
assert!(!AgentConfig::infer_supports_stdin(
"/home/alex/.bun/bin/opencode"
));
assert!(AgentConfig::infer_supports_stdin("claude"));
assert!(AgentConfig::infer_supports_stdin("claude-code"));
assert!(AgentConfig::infer_supports_stdin("/usr/local/bin/claude"));
assert!(AgentConfig::infer_supports_stdin("codex"));
assert!(AgentConfig::infer_supports_stdin("unknown-tool"));
}
#[test]
fn test_skill_dir_name() {
assert_eq!(
AgentConfig::skill_dir_name("opencode"),
Some(".opencode/skill")
);
assert_eq!(
AgentConfig::skill_dir_name("/home/alex/.bun/bin/opencode"),
Some(".opencode/skill")
);
assert_eq!(
AgentConfig::skill_dir_name("claude"),
Some(".claude/skills")
);
assert_eq!(
AgentConfig::skill_dir_name("claude-code"),
Some(".claude/skills")
);
assert_eq!(AgentConfig::skill_dir_name("codex"), None);
}
#[test]
fn test_from_provider_sets_supports_stdin() {
let provider = terraphim_types::capability::Provider {
id: "test-opencode".into(),
name: "test-opencode".into(),
provider_type: terraphim_types::capability::ProviderType::Agent {
agent_id: "test".into(),
cli_command: "opencode".into(),
working_dir: std::env::current_dir().unwrap(),
},
capabilities: vec![],
cost_level: terraphim_types::capability::CostLevel::Cheap,
latency: terraphim_types::capability::Latency::Medium,
keywords: vec![],
};
let config = AgentConfig::from_provider(&provider).unwrap();
assert!(
!config.supports_stdin,
"opencode config should have supports_stdin=false"
);
let provider_claude = terraphim_types::capability::Provider {
id: "test-claude".into(),
name: "test-claude".into(),
provider_type: terraphim_types::capability::ProviderType::Agent {
agent_id: "test".into(),
cli_command: "claude".into(),
working_dir: std::env::current_dir().unwrap(),
},
capabilities: vec![],
cost_level: terraphim_types::capability::CostLevel::Cheap,
latency: terraphim_types::capability::Latency::Medium,
keywords: vec![],
};
let config_claude = AgentConfig::from_provider(&provider_claude).unwrap();
assert!(
config_claude.supports_stdin,
"claude config should have supports_stdin=true"
);
}
#[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"]);
}
#[test]
fn test_infer_args_bash_uses_dash_c() {
assert_eq!(AgentConfig::infer_args("/bin/bash"), vec!["-c"]);
assert_eq!(AgentConfig::infer_args("bash"), vec!["-c"]);
assert_eq!(AgentConfig::infer_args("/usr/bin/sh"), vec!["-c"]);
}
#[test]
fn test_infer_args_pi_rust() {
let args = AgentConfig::infer_args("pi-rust");
assert_eq!(args, vec!["-p", "--mode", "json"]);
}
#[test]
fn test_infer_args_pi_rust_full_path() {
let args = AgentConfig::infer_args("/home/alex/.local/bin/pi-rust");
assert_eq!(args, vec!["-p", "--mode", "json"]);
}
#[test]
fn test_infer_args_pi_alias() {
let args = AgentConfig::infer_args("pi");
assert_eq!(args, vec!["prompt"]);
}
#[test]
fn test_model_args_pi_badlogic() {
let args = AgentConfig::model_args("pi", "phi3");
assert_eq!(args, vec!["phi3"]);
}
#[test]
fn test_model_args_pi_badlogic_full_path() {
let args = AgentConfig::model_args("/home/alex/.npm/bin/pi", "qwen");
assert_eq!(args, vec!["qwen"]);
}
#[tokio::test]
async fn test_validate_pi_requires_model_alias() {
let provider = terraphim_types::capability::Provider {
id: "test-pi".into(),
name: "test-pi".into(),
provider_type: terraphim_types::capability::ProviderType::Agent {
agent_id: "test".into(),
cli_command: "/bin/pi".into(),
working_dir: std::env::current_dir().unwrap(),
},
capabilities: vec![],
cost_level: terraphim_types::capability::CostLevel::Cheap,
latency: terraphim_types::capability::Latency::Medium,
keywords: vec![],
};
let config = AgentConfig::from_provider(&provider).unwrap();
let validator = AgentValidator::new(&config);
let result = validator.validate_cli_contract();
assert!(matches!(result, Err(ValidationError::PiModelRequired)));
let config_with_model = config.with_model("phi3");
let validator = AgentValidator::new(&config_with_model);
assert!(validator.validate_cli_contract().is_ok());
}
#[test]
fn test_model_args_pi_rust_composed() {
let args = AgentConfig::model_args("pi-rust", "zai-coding-plan/glm-5.1");
assert_eq!(
args,
vec!["--provider", "zai-coding-plan", "--model", "glm-5.1"]
);
}
#[test]
fn test_is_sensitive_key_detects_known_patterns() {
assert!(is_sensitive_key("ANTHROPIC_API_KEY"));
assert!(is_sensitive_key("OPENAI_API_KEY"));
assert!(is_sensitive_key("GITHUB_TOKEN"));
assert!(is_sensitive_key("DB_PASSWORD"));
assert!(is_sensitive_key("APP_SECRET"));
assert!(is_sensitive_key("api_key"));
assert!(is_sensitive_key("auth_token"));
}
#[test]
fn test_is_sensitive_key_allows_safe_keys() {
assert!(!is_sensitive_key("HOME"));
assert!(!is_sensitive_key("PATH"));
assert!(!is_sensitive_key("LOG_LEVEL"));
assert!(!is_sensitive_key("RUST_LOG"));
assert!(!is_sensitive_key("AGENT_ID"));
}
#[test]
fn test_debug_redacts_sensitive_env_vars() {
let mut env_vars = HashMap::new();
env_vars.insert(
"ANTHROPIC_API_KEY".to_string(),
"sk-ant-real-secret".to_string(),
);
env_vars.insert("GITHUB_TOKEN".to_string(), "ghp_real_token".to_string());
env_vars.insert("LOG_LEVEL".to_string(), "debug".to_string());
env_vars.insert("HOME".to_string(), "/home/agent".to_string());
let config = AgentConfig {
agent_id: "test-agent".to_string(),
cli_command: "claude".to_string(),
args: vec![],
working_dir: None,
env_vars,
required_api_keys: vec![],
resource_limits: ResourceLimits::default(),
use_stdin: false,
supports_stdin: true,
};
let debug_output = format!("{:?}", config);
assert!(
!debug_output.contains("sk-ant-real-secret"),
"API key value must be redacted"
);
assert!(
!debug_output.contains("ghp_real_token"),
"Token value must be redacted"
);
assert!(
debug_output.contains("***REDACTED***"),
"Redaction marker must appear for sensitive keys"
);
assert!(
debug_output.contains("debug"),
"Non-sensitive env var value must be visible"
);
assert!(
debug_output.contains("/home/agent"),
"Non-sensitive env var value must be visible"
);
}
#[test]
fn test_model_args_pi_rust_bare() {
let args = AgentConfig::model_args("pi-rust", "glm-5.1");
assert_eq!(args, vec!["--model", "glm-5.1"]);
}
#[test]
fn test_model_args_pi_rust_full_path() {
let args = AgentConfig::model_args("/home/alex/.local/bin/pi-rust", "kimi-for-coding/k2p6");
assert_eq!(
args,
vec!["--provider", "kimi-for-coding", "--model", "k2p6"]
);
}
#[test]
fn test_infer_supports_stdin_pi_rust() {
assert!(AgentConfig::infer_supports_stdin("pi-rust"));
assert!(AgentConfig::infer_supports_stdin(
"/home/alex/.local/bin/pi-rust"
));
assert!(AgentConfig::infer_supports_stdin("pi"));
}
#[test]
fn test_infer_api_keys_pi_rust() {
let keys = AgentConfig::infer_api_keys("pi-rust");
assert!(keys.is_empty(), "pi-rust manages its own per-provider auth");
let keys = AgentConfig::infer_api_keys("/home/alex/.local/bin/pi-rust");
assert!(keys.is_empty());
}
#[test]
fn test_debug_empty_env_vars_shows_nothing_redacted() {
let config = AgentConfig {
agent_id: "test".to_string(),
cli_command: "claude".to_string(),
args: vec![],
working_dir: None,
env_vars: HashMap::new(),
required_api_keys: vec![],
resource_limits: ResourceLimits::default(),
use_stdin: false,
supports_stdin: true,
};
let debug_output = format!("{:?}", config);
assert!(!debug_output.contains("***REDACTED***"));
assert!(debug_output.contains("AgentConfig"));
}
}