use std::collections::HashMap;
use std::path::Path;
#[cfg(test)]
use std::path::PathBuf;
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct AgentConfig {
pub identity: String,
pub name: String,
pub short_name: String,
#[serde(default = "default_protocol")]
pub protocol: String,
#[serde(default = "default_type")]
pub r#type: String,
#[serde(default)]
pub active: Option<bool>,
pub run_command: HashMap<String, String>,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
pub install_command: Option<String>,
#[serde(default)]
pub actions: HashMap<String, HashMap<String, ActionConfig>>,
#[serde(skip)]
pub connector_installed: bool,
}
fn default_protocol() -> String {
"acp".to_string()
}
fn default_type() -> String {
"coding".to_string()
}
#[derive(Debug, Clone, Deserialize)]
pub struct ActionConfig {
pub command: Option<String>,
pub description: Option<String>,
}
impl AgentConfig {
pub fn run_command_for_platform(&self) -> Option<&str> {
let platform = if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "windows") {
"windows"
} else {
"linux"
};
self.run_command
.get(platform)
.or_else(|| self.run_command.get("*"))
.map(|s| s.as_str())
}
pub fn is_active(&self) -> bool {
self.active.unwrap_or(true)
}
pub fn detect_connector(&mut self) {
self.connector_installed = self
.run_command_for_platform()
.map(|cmd| {
let binary = cmd.split_whitespace().next().unwrap_or("");
binary_in_path(binary)
})
.unwrap_or(false);
}
}
fn binary_in_path(binary: &str) -> bool {
resolve_binary_in_path(binary).is_some()
}
pub fn resolve_binary_in_path(binary: &str) -> Option<std::path::PathBuf> {
resolve_binary_in_path_str(binary, &std::env::var("PATH").ok()?)
}
pub fn resolve_binary_in_path_str(binary: &str, path_var: &str) -> Option<std::path::PathBuf> {
if binary.is_empty() {
return None;
}
let path = std::path::Path::new(binary);
if path.is_absolute() {
return if path.is_file() {
Some(path.to_path_buf())
} else {
None
};
}
for dir in std::env::split_paths(path_var) {
let candidate = dir.join(binary);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
const KNOWN_SHELLS: &[&str] = &[
"sh", "bash", "zsh", "fish", "dash", "ksh", "tcsh", "csh", "elvish", "nu",
];
pub fn is_known_shell(shell_path: &str) -> bool {
let basename = std::path::Path::new(shell_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
KNOWN_SHELLS.contains(&basename)
}
pub fn resolve_shell_path() -> Option<String> {
let raw_shell = std::env::var("SHELL").unwrap_or_default();
let shell = if !raw_shell.is_empty() && is_known_shell(&raw_shell) {
raw_shell
} else {
if !raw_shell.is_empty() {
log::warn!(
"resolve_shell_path: $SHELL={raw_shell:?} is not in the known-shells allowlist; \
falling back to /bin/sh"
);
}
"/bin/sh".to_string()
};
let output = std::process::Command::new(&shell)
.args(["-lic", r#"printf "%s" "$PATH""#])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Some(path);
}
}
None
}
const EMBEDDED_AGENTS: &[&str] = &[
r#"
identity = "claude.com"
name = "Claude Code"
short_name = "claude"
protocol = "acp"
type = "coding"
install_command = "npm install -g @zed-industries/claude-agent-acp"
[run_command]
"*" = "claude-agent-acp"
"#,
r#"
identity = "openai.com"
name = "Codex CLI"
short_name = "codex"
protocol = "acp"
type = "coding"
install_command = "npm install -g @zed-industries/codex-acp"
[run_command]
"*" = "npx @zed-industries/codex-acp"
"#,
r#"
identity = "geminicli.com"
name = "Gemini CLI"
short_name = "gemini"
protocol = "acp"
type = "coding"
[run_command]
"*" = "gemini --experimental-acp"
"#,
r#"
identity = "copilot.github.com"
name = "Copilot"
short_name = "copilot"
protocol = "acp"
type = "coding"
[run_command]
"*" = "copilot --acp"
"#,
r#"
identity = "ampcode.com"
name = "Amp (AmpCode)"
short_name = "amp"
protocol = "acp"
type = "coding"
[run_command]
"*" = "npx -y amp-acp"
"#,
r#"
identity = "augmentcode.com"
name = "Auggie (Augment Code)"
short_name = "auggie"
protocol = "acp"
type = "coding"
[run_command]
"*" = "auggie --acp"
"#,
r#"
identity = "docker.com"
name = "Docker cagent"
short_name = "cagent"
protocol = "acp"
type = "coding"
[run_command]
"*" = "cagent acp"
"#,
r#"
identity = "openhands.dev"
name = "OpenHands"
short_name = "openhands"
protocol = "acp"
type = "coding"
[run_command]
"*" = "openhands acp"
"#,
];
const BUILT_IN_IDENTITIES: &[&str] = &[
"claude.com",
"openai.com",
"geminicli.com",
"copilot.github.com",
"ampcode.com",
"augmentcode.com",
"docker.com",
"openhands.dev",
];
pub fn discover_agents(user_config_dir: &Path) -> Vec<AgentConfig> {
let mut agents = Vec::new();
for embedded in EMBEDDED_AGENTS {
if let Ok(config) = toml::from_str::<AgentConfig>(embedded) {
agents.push(config);
}
}
let bundled_dir = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|p| p.join("agents")));
if let Some(ref dir) = bundled_dir {
load_agents_from_dir(dir, &mut agents, false);
}
let user_agents_dir = user_config_dir.join("agents");
load_agents_from_dir(&user_agents_dir, &mut agents, true);
agents.retain(|a| a.is_active());
for agent in &mut agents {
agent.detect_connector();
}
agents
}
fn load_agents_from_dir(dir: &Path, agents: &mut Vec<AgentConfig>, is_user_config: bool) {
if !dir.exists() {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "toml") {
match std::fs::read_to_string(&path) {
Ok(content) => match toml::from_str::<AgentConfig>(&content) {
Ok(config) => {
if is_user_config && BUILT_IN_IDENTITIES.contains(&config.identity.as_str())
{
log::warn!(
"ACP agent config '{}' overrides built-in identity '{}'.\n\
User-config-dir agents are executed with par-term's privileges.\n\
Verify that '{}' is a trusted file you created intentionally.",
path.display(),
config.identity,
path.display(),
);
}
agents.retain(|a| a.identity != config.identity);
agents.push(config);
}
Err(e) => {
log::error!("Failed to parse agent config {}: {e}", path.display());
}
},
Err(e) => log::error!("Failed to read agent config {}: {e}", path.display()),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_agent_toml() {
let toml_str = r#"
identity = "claude.com"
name = "Claude Code"
short_name = "claude"
protocol = "acp"
type = "coding"
[run_command]
"*" = "claude-agent-acp"
macos = "claude-agent-acp"
"#;
let config: AgentConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.identity, "claude.com");
assert_eq!(config.name, "Claude Code");
assert_eq!(config.short_name, "claude");
assert_eq!(config.protocol, "acp");
assert_eq!(config.r#type, "coding");
assert!(config.is_active());
assert!(config.run_command_for_platform().is_some());
}
#[test]
fn test_inactive_agent() {
let toml_str = r#"
identity = "test.agent"
name = "Test"
short_name = "test"
active = false
[run_command]
"*" = "test-agent"
"#;
let config: AgentConfig = toml::from_str(toml_str).unwrap();
assert!(!config.is_active());
}
#[test]
fn test_default_protocol_and_type() {
let toml_str = r#"
identity = "minimal.agent"
name = "Minimal"
short_name = "min"
[run_command]
"*" = "minimal-agent"
"#;
let config: AgentConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.protocol, "acp");
assert_eq!(config.r#type, "coding");
}
#[test]
fn test_platform_fallback_to_wildcard() {
let toml_str = r#"
identity = "wildcard.agent"
name = "Wildcard"
short_name = "wc"
[run_command]
"*" = "wildcard-cmd"
"#;
let config: AgentConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.run_command_for_platform(), Some("wildcard-cmd"));
}
#[test]
fn test_all_embedded_agents_parse() {
for (i, toml_str) in EMBEDDED_AGENTS.iter().enumerate() {
let config = toml::from_str::<AgentConfig>(toml_str)
.unwrap_or_else(|e| panic!("Embedded agent {i} failed to parse: {e}"));
assert!(!config.identity.is_empty(), "Agent {i} has empty identity");
assert!(!config.name.is_empty(), "Agent {i} has empty name");
assert!(
!config.short_name.is_empty(),
"Agent {i} has empty short_name"
);
assert!(
config.run_command_for_platform().is_some(),
"Agent {} ({}) has no run command for this platform",
i,
config.identity
);
}
}
#[test]
fn test_embedded_agents_include_known_identities() {
let agents: Vec<AgentConfig> = EMBEDDED_AGENTS
.iter()
.map(|s| toml::from_str(s).unwrap())
.collect();
let identities: Vec<&str> = agents.iter().map(|a| a.identity.as_str()).collect();
assert!(identities.contains(&"claude.com"), "Missing claude.com");
assert!(
identities.contains(&"openai.com"),
"Missing openai.com (codex)"
);
assert!(
identities.contains(&"geminicli.com"),
"Missing geminicli.com (gemini)"
);
}
#[test]
fn test_discover_agents_nonexistent_dir() {
let dir = PathBuf::from("/tmp/par_term_test_nonexistent_agents_dir");
let agents = discover_agents(&dir);
for agent in &agents {
assert!(agent.is_active());
}
}
#[test]
fn test_discover_agents_from_temp_dir() {
let tmp_dir = tempfile::tempdir().unwrap();
let agents_dir = tmp_dir.path().join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
let toml_content = r#"
identity = "test.disco"
name = "Discovery Test"
short_name = "disco"
[run_command]
"*" = "disco-agent"
"#;
std::fs::write(agents_dir.join("test.disco.toml"), toml_content).unwrap();
let agents = discover_agents(tmp_dir.path());
let disco = agents.iter().find(|a| a.identity == "test.disco");
assert!(
disco.is_some(),
"Expected test.disco agent to be discovered"
);
assert_eq!(disco.unwrap().name, "Discovery Test");
}
#[test]
fn test_discover_agents_filters_inactive() {
let tmp_dir = tempfile::tempdir().unwrap();
let agents_dir = tmp_dir.path().join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
let active_toml = r#"
identity = "active.agent"
name = "Active"
short_name = "act"
[run_command]
"*" = "active-cmd"
"#;
let inactive_toml = r#"
identity = "inactive.agent"
name = "Inactive"
short_name = "inact"
active = false
[run_command]
"*" = "inactive-cmd"
"#;
std::fs::write(agents_dir.join("active.toml"), active_toml).unwrap();
std::fs::write(agents_dir.join("inactive.toml"), inactive_toml).unwrap();
let agents = discover_agents(tmp_dir.path());
assert!(
agents.iter().any(|a| a.identity == "active.agent"),
"Expected active.agent to be present"
);
assert!(
!agents.iter().any(|a| a.identity == "inactive.agent"),
"Expected inactive.agent to be filtered out"
);
}
#[test]
fn test_binary_in_path_finds_common_binary() {
assert!(binary_in_path("ls"));
}
#[test]
fn test_binary_in_path_not_found() {
assert!(!binary_in_path("nonexistent-binary-12345"));
}
#[test]
fn test_binary_in_path_empty() {
assert!(!binary_in_path(""));
}
#[test]
fn test_detect_connector_for_known_binary() {
let mut config: AgentConfig = toml::from_str(
r#"
identity = "test.agent"
name = "Test"
short_name = "test"
[run_command]
"*" = "ls"
"#,
)
.unwrap();
config.detect_connector();
assert!(config.connector_installed);
}
#[test]
fn test_detect_connector_for_unknown_binary() {
let mut config: AgentConfig = toml::from_str(
r#"
identity = "test.agent"
name = "Test"
short_name = "test"
[run_command]
"*" = "nonexistent-binary-12345"
"#,
)
.unwrap();
config.detect_connector();
assert!(!config.connector_installed);
}
#[test]
fn test_detect_connector_extracts_first_token() {
let mut config: AgentConfig = toml::from_str(
r#"
identity = "test.agent"
name = "Test"
short_name = "test"
[run_command]
"*" = "ls --some-flag"
"#,
)
.unwrap();
config.detect_connector();
assert!(config.connector_installed);
}
}