pub mod claude_code;
pub mod codex;
pub mod opencode;
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::time::{Duration, Instant};
const AVAILABILITY_TIMEOUT: Duration = Duration::from_secs(3);
fn run_with_timeout(command: &mut Command, timeout: Duration) -> Option<std::process::ExitStatus> {
let mut child = command.spawn().ok()?;
let deadline = Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) => return Some(status),
Ok(None) => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return None;
}
std::thread::sleep(Duration::from_millis(25));
}
Err(_) => {
let _ = child.kill();
let _ = child.wait();
return None;
}
}
}
}
fn cli_reports_version(cli_name: &str) -> bool {
let mut command = Command::new(cli_name);
command
.arg("--version")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
matches!(run_with_timeout(&mut command, AVAILABILITY_TIMEOUT), Some(status) if status.success())
}
pub fn pre_trust_mise(dir: &str) {
if cfg!(test) {
return;
}
const CONFIGS: &[&str] = &[
"mise.toml",
".mise.toml",
"mise/config.toml",
".tool-versions",
];
for name in CONFIGS {
let path = format!("{dir}/{name}");
if !std::path::Path::new(&path).exists() {
continue;
}
let _ = std::process::Command::new("mise")
.args(["trust", &path])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
}
#[derive(Debug)]
pub struct BackendRegistry {
backends: Vec<Arc<dyn CodingAssistant>>,
default_name: String,
}
impl BackendRegistry {
pub fn new(backends: Vec<Arc<dyn CodingAssistant>>, default: &str) -> Self {
Self {
backends,
default_name: default.to_string(),
}
}
pub fn default_registry() -> Self {
Self::new(
vec![
Arc::new(claude_code::ClaudeCode) as _,
Arc::new(opencode::OpenCode) as _,
Arc::new(codex::Codex) as _,
],
"claude-code",
)
}
pub fn get(&self, name: &str) -> Option<Arc<dyn CodingAssistant>> {
self.backends.iter().find(|b| b.name() == name).cloned()
}
pub fn default(&self) -> Arc<dyn CodingAssistant> {
self.get(&self.default_name)
.expect("default backend must exist")
}
pub fn available(&self) -> Vec<&str> {
self.backends
.iter()
.filter(|b| b.is_available())
.map(|b| b.name())
.collect()
}
pub fn all_process_names(&self) -> Vec<String> {
self.backends
.iter()
.flat_map(|b| b.process_names().iter().map(|s| s.to_string()))
.collect()
}
pub fn all_backend_process_names(&self) -> Vec<(String, Vec<String>)> {
self.backends
.iter()
.map(|b| {
(
b.name().to_string(),
b.process_names().iter().map(|s| s.to_string()).collect(),
)
})
.collect()
}
pub fn uses_http_delivery(&self, backend_name: &str) -> bool {
self.get(backend_name)
.is_some_and(|b| matches!(b.delivery_mode(), DeliveryMode::HttpApi { .. }))
}
}
#[derive(Debug, Clone)]
pub enum DeliveryMode {
TuiInjection,
HttpApi {
#[allow(dead_code)]
serve_command: String,
#[allow(dead_code)]
attach_command: String,
},
}
#[derive(Debug)]
pub struct StartOpts {
pub project_dir: String,
pub worktree: Option<WorktreeMode>,
pub model: Option<String>,
pub effort: Option<String>,
pub permission_mode: Option<String>,
}
#[derive(Debug)]
pub struct ResumeOpts {
pub project_dir: String,
pub session_id: Option<String>,
pub worktree: Option<WorktreeMode>,
pub model: Option<String>,
pub effort: Option<String>,
pub permission_mode: Option<String>,
}
#[derive(Debug, Clone)]
pub enum WorktreeMode {
Named(String),
Disposable,
}
#[derive(Debug, Clone, Copy)]
pub struct InjectConfig {
pub paste_settle_ms: u64,
pub use_inner_bracketed_paste: bool,
pub startup_inject_delay_secs: u64,
}
#[allow(dead_code)]
pub trait CodingAssistant: Send + Sync + std::fmt::Debug + 'static {
fn name(&self) -> &str;
fn cli_name(&self) -> &str;
fn process_names(&self) -> &[&str];
fn delivery_mode(&self) -> DeliveryMode;
fn build_start_command(&self, opts: &StartOpts) -> String;
fn build_resume_command(&self, opts: &ResumeOpts) -> Option<String>;
fn detect_session_id(&self, project_dir: &str) -> Option<String>;
fn tui_ready_pattern(&self) -> Option<&str>;
fn inject_config(&self) -> InjectConfig;
fn config_dir_name(&self) -> &str;
fn resolve_project_root<'a>(&self, path: &'a str) -> &'a str {
path
}
fn has_project_history(&self, dir: &Path) -> bool;
fn compact_command(&self) -> Option<&str> {
None
}
fn exit_command(&self) -> Option<&str>;
fn install(&self) -> anyhow::Result<()>;
fn is_available(&self) -> bool {
cli_reports_version(self.cli_name())
}
fn description_file_priority(&self) -> &[&str] {
&["README.md"]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_available_returns_backends_with_binaries() {
let registry = BackendRegistry::default_registry();
let available = registry.available();
assert!(available.iter().all(|name| !name.is_empty()));
}
#[test]
fn run_with_timeout_returns_status_for_fast_success() {
let status = run_with_timeout(&mut Command::new("true"), Duration::from_secs(3));
assert!(status.is_some_and(|s| s.success()));
}
#[test]
fn run_with_timeout_returns_status_for_fast_failure() {
let status = run_with_timeout(&mut Command::new("false"), Duration::from_secs(3));
assert!(status.is_some_and(|s| !s.success()));
}
#[test]
fn run_with_timeout_kills_and_returns_none_when_deadline_exceeded() {
let start = Instant::now();
let status = run_with_timeout(
Command::new("sleep").arg("5"),
Duration::from_millis(200),
);
assert!(status.is_none(), "timed-out process must return None");
assert!(
start.elapsed() < Duration::from_secs(2),
"helper must return near the deadline, not wait for the process"
);
}
#[test]
fn run_with_timeout_returns_none_for_missing_binary() {
let status = run_with_timeout(
&mut Command::new("ouija-nonexistent-binary-xyz"),
Duration::from_secs(3),
);
assert!(status.is_none());
}
#[test]
fn cli_reports_version_true_for_command_that_exits_zero() {
assert!(cli_reports_version("true"));
}
#[test]
fn cli_reports_version_false_for_missing_binary() {
assert!(!cli_reports_version("ouija-nonexistent-binary-xyz"));
}
#[test]
fn uses_http_delivery_distinguishes_backends() {
let registry = BackendRegistry::default_registry();
assert!(registry.uses_http_delivery("opencode"));
assert!(!registry.uses_http_delivery("claude-code"));
assert!(!registry.uses_http_delivery("codex-cli"));
assert!(!registry.uses_http_delivery("nonexistent"));
}
#[test]
fn registry_includes_codex_backend() {
let registry = BackendRegistry::default_registry();
let codex = registry
.get("codex-cli")
.expect("codex-cli backend must be registered");
assert_eq!(codex.cli_name(), "codex");
assert!(
registry
.all_process_names()
.iter()
.any(|n| n == "codex")
);
}
#[derive(Debug)]
struct UnavailableBackend;
impl CodingAssistant for UnavailableBackend {
fn name(&self) -> &str {
"ghost"
}
fn cli_name(&self) -> &str {
"ouija-nonexistent-binary-xyz"
}
fn process_names(&self) -> &[&str] {
&["ghostproc"]
}
fn delivery_mode(&self) -> DeliveryMode {
DeliveryMode::TuiInjection
}
fn build_start_command(&self, _: &StartOpts) -> String {
String::new()
}
fn build_resume_command(&self, _: &ResumeOpts) -> Option<String> {
None
}
fn detect_session_id(&self, _: &str) -> Option<String> {
None
}
fn tui_ready_pattern(&self) -> Option<&str> {
None
}
fn inject_config(&self) -> InjectConfig {
InjectConfig {
paste_settle_ms: 0,
use_inner_bracketed_paste: false,
startup_inject_delay_secs: 0,
}
}
fn config_dir_name(&self) -> &str {
".ghost"
}
fn has_project_history(&self, _: &Path) -> bool {
false
}
fn exit_command(&self) -> Option<&str> {
None
}
fn install(&self) -> anyhow::Result<()> {
Ok(())
}
}
#[test]
fn all_backend_process_names_ignores_availability() {
let registry = BackendRegistry::new(vec![Arc::new(UnavailableBackend) as _], "ghost");
assert!(registry.available().is_empty());
let names = registry.all_backend_process_names();
assert_eq!(names.len(), 1);
assert_eq!(names[0].0, "ghost");
assert_eq!(names[0].1, vec!["ghostproc".to_string()]);
}
#[test]
fn all_backend_process_names_covers_every_default_backend() {
let registry = BackendRegistry::default_registry();
let names = registry.all_backend_process_names();
for backend in ["claude-code", "opencode", "codex-cli"] {
assert!(
names.iter().any(|(n, _)| n == backend),
"{backend} missing from detection candidate set: {names:?}"
);
}
}
}