use std::io::{self, IsTerminal, Write};
use std::path::Path;
use std::process::Command;
use crate::config::Config;
const KNOWN_AGENTS: [&str; 4] = ["claude", "codex", "opencode", "aider"];
pub struct AgentOpts {
pub prompt: Option<String>,
pub cmd: Vec<String>,
pub no_refresh: bool,
pub yes: bool,
pub no_input: bool,
}
pub fn confirm(question: &str, yes: bool, no_input: bool) -> bool {
if yes {
return true;
}
if no_input || !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
eprintln!("radar: {question} - proceeding (non-interactive default)");
return true;
}
eprint!("radar: {question} [Y/n] ");
let _ = io::stderr().flush();
let mut line = String::new();
if io::stdin().read_line(&mut line).is_err() {
return true;
}
let ans = line.trim().to_lowercase();
ans.is_empty() || ans == "y" || ans == "yes"
}
fn which(program: &str) -> bool {
let Some(paths) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&paths).any(|dir| {
let candidate = dir.join(program);
if is_executable(&candidate) {
return true;
}
#[cfg(windows)]
{
["exe", "cmd", "bat"]
.iter()
.any(|ext| is_executable(&dir.join(format!("{program}.{ext}"))))
}
#[cfg(not(windows))]
false
})
}
fn is_executable(path: &Path) -> bool {
if !path.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
path.metadata()
.is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
true
}
pub fn resolve_cmd(opts: &AgentOpts, config: &Config) -> Option<Vec<String>> {
if !opts.cmd.is_empty() {
return Some(opts.cmd.clone());
}
if let Some(cmd) = &config.agent_cmd {
return Some(cmd.split_whitespace().map(str::to_string).collect());
}
KNOWN_AGENTS
.iter()
.find(|a| which(a))
.map(|a| vec![a.to_string()])
}
pub fn router_prompt(task: Option<&str>) -> String {
match task {
Some(task) => {
format!("Knowledge base is fresh. Start at ./MAP.md and navigate by maps. Task: {task}")
}
None => "Knowledge base is fresh. Start at ./MAP.md and navigate by maps.".to_string(),
}
}
pub fn launch(root: &Path, cmd: &[String], prompt: Option<&str>) -> io::Result<i32> {
let (program, args) = cmd
.split_first()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "empty agent command"))?;
let mut command = Command::new(program);
command.args(args).current_dir(root);
if let Some(p) = prompt {
command.arg(p);
}
let status = command.status()?;
Ok(status.code().unwrap_or(1))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn explicit_cmd_wins_over_config() {
let opts = AgentOpts {
prompt: None,
cmd: vec!["my-agent".into(), "--flag".into()],
no_refresh: true,
yes: true,
no_input: true,
};
let config = Config {
agent_cmd: Some("other".into()),
..Config::default()
};
assert_eq!(
resolve_cmd(&opts, &config),
Some(vec!["my-agent".to_string(), "--flag".to_string()])
);
}
#[test]
fn config_cmd_is_split_on_whitespace() {
let opts = AgentOpts {
prompt: None,
cmd: vec![],
no_refresh: true,
yes: true,
no_input: true,
};
let config = Config {
agent_cmd: Some("claude --model opus".into()),
..Config::default()
};
assert_eq!(
resolve_cmd(&opts, &config),
Some(vec![
"claude".to_string(),
"--model".to_string(),
"opus".to_string()
])
);
}
#[test]
fn router_prompt_includes_task() {
assert!(router_prompt(Some("fix the bug")).contains("Task: fix the bug"));
assert!(router_prompt(None).contains("./MAP.md"));
}
#[cfg(unix)]
#[test]
fn executable_detection_rejects_plain_files() {
use std::os::unix::fs::PermissionsExt;
let path = std::env::temp_dir().join(format!(
"radar-agent-executable-test-{}",
std::process::id()
));
std::fs::write(&path, "#!/bin/sh\nexit 0\n").expect("write fixture");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
.expect("set plain permissions");
assert!(!is_executable(&path));
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
.expect("set executable permissions");
assert!(is_executable(&path));
let _ = std::fs::remove_file(path);
}
}