pub mod builtin;
pub mod conform;
pub mod contract;
use crate::config::{Config, Driver as DriverConfig};
use crate::paths::Paths;
use anyhow::{Context, Result, bail};
pub use contract::{DriverResult, DriverStatus, DriverTask};
use std::io::Write;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
const POLL_INTERVAL: Duration = Duration::from_millis(25);
const DRAIN_GRACE: Duration = Duration::from_millis(500);
pub struct Invocation {
pub result: DriverResult,
pub elapsed: Duration,
pub stderr: String,
}
pub fn run(paths: &Paths, driver: &DriverConfig, task: &DriverTask) -> Invocation {
run_in(paths, paths, driver, task)
}
pub fn run_in(
home: &Paths,
cwd: &Paths,
driver: &DriverConfig,
task: &DriverTask,
) -> Invocation {
let started = Instant::now();
let timeout = Duration::from_secs(driver.timeout_secs);
let mut parts = driver.cmd.split_whitespace().map(|s| s.to_string()).collect::<Vec<_>>();
if parts.is_empty() {
return blocked(started, format!("driver `{}` has an empty cmd", driver.id));
}
let program = resolve_program(home, &parts.remove(0));
let payload = match serde_json::to_string(task) {
Ok(p) => p,
Err(e) => return blocked(started, format!("could not serialise the task: {e}")),
};
let mut command = Command::new(&program);
command
.args(&parts)
.current_dir(&cwd.repo)
.env("KEEL_REPO", &cwd.repo)
.env("KEEL_STORE", cwd.store())
.env("KEEL_RUN", &task.run)
.env("KEEL_SPEC", &task.spec)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.process_group(0);
}
let mut child = match command.spawn() {
Ok(c) => c,
Err(e) => {
return blocked(started, format!("could not start `{}`: {e}", driver.cmd));
}
};
let pid = child.id();
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(payload.as_bytes());
}
let mut stdout_pipe = child.stdout.take();
let mut stderr_pipe = child.stderr.take();
let (out_tx, out_rx) = std::sync::mpsc::channel();
let (err_tx, err_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = out_tx.send(read_all(&mut stdout_pipe));
});
std::thread::spawn(move || {
let _ = err_tx.send(read_all(&mut stderr_pipe));
});
let deadline = started + timeout;
let mut timed_out = false;
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if Instant::now() >= deadline {
timed_out = true;
kill_group(&mut child, pid);
let _ = child.wait();
break;
}
std::thread::sleep(POLL_INTERVAL);
}
Err(e) => return blocked(started, format!("could not wait on the driver: {e}")),
}
}
let (stdout, stderr) = if timed_out {
(
out_rx.recv_timeout(DRAIN_GRACE).unwrap_or_default(),
err_rx.recv_timeout(DRAIN_GRACE).unwrap_or_default(),
)
} else {
(out_rx.recv().unwrap_or_default(), err_rx.recv().unwrap_or_default())
};
let stderr = stderr.trim().to_string();
if timed_out {
let mut inv = blocked(
started,
format!("driver `{}` exceeded its {}s timeout and was terminated", driver.id, driver.timeout_secs),
);
inv.stderr = stderr;
return inv;
}
match contract::parse_result(&stdout) {
Ok(result) => Invocation { result, elapsed: started.elapsed(), stderr },
Err(why) => {
let mut inv = blocked(started, format!("invalid driver result — {why}"));
inv.stderr = stderr;
inv
}
}
}
fn read_all<R: std::io::Read>(pipe: &mut Option<R>) -> String {
let Some(p) = pipe.as_mut() else { return String::new() };
let mut buf = Vec::new();
let _ = p.read_to_end(&mut buf);
String::from_utf8_lossy(&buf).to_string()
}
#[cfg(unix)]
fn kill_group(child: &mut std::process::Child, pid: u32) {
unsafe {
libc::kill(-(pid as i32), libc::SIGKILL);
}
let _ = child.kill();
}
#[cfg(not(unix))]
fn kill_group(child: &mut std::process::Child, pid: u32) {
let _ = Command::new("taskkill").args(["/F", "/T", "/PID", &pid.to_string()]).output();
let _ = child.kill();
}
fn resolve_program(home: &Paths, program: &str) -> String {
let p = std::path::Path::new(program);
if p.is_absolute() || !program.contains('/') {
return program.to_string();
}
let candidate = home.repo.join(p);
if candidate.exists() {
candidate.to_string_lossy().to_string()
} else {
program.to_string()
}
}
fn blocked(started: Instant, detail: String) -> Invocation {
Invocation {
result: DriverResult::blocked(detail),
elapsed: started.elapsed(),
stderr: String::new(),
}
}
pub fn scaffold(paths: &Paths, cfg: &Config, force: bool) -> Result<Vec<String>> {
let dir = paths.keel().join("drivers");
std::fs::create_dir_all(&dir)?;
let mut lines = Vec::new();
let mut to_register: Vec<(&'static str, &'static str, bool, u64)> = Vec::new();
for b in builtin::ALL {
let path = dir.join(b.filename);
if path.exists() && !force {
lines.push(format!(" kept drivers/{}", b.filename));
} else {
std::fs::write(&path, b.content)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))?;
}
lines.push(format!(" created drivers/{}", b.filename));
}
if let Some(id) = b.driver_id
&& !cfg.drivers.iter().any(|d| d.id == id)
{
to_register.push((id, b.filename, b.default, b.timeout_secs));
lines.push(format!(" added [[driver]] {id} to keel.toml"));
}
}
if !to_register.is_empty() {
append_driver_entries(&paths.config(), &to_register)?;
}
for a in builtin::ASSETS {
let path = paths.keel().join(a.rel);
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
if path.exists() && !force {
lines.push(format!(" kept {}", a.rel));
} else {
std::fs::write(&path, a.content)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))?;
}
lines.push(format!(" created {}", a.rel));
}
}
Ok(lines)
}
fn append_driver_entries(
cfg_path: &std::path::Path,
entries: &[(&'static str, &'static str, bool, u64)],
) -> Result<()> {
let mut existing = std::fs::read_to_string(cfg_path)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", cfg_path.display()))?;
if !existing.ends_with('\n') {
existing.push('\n');
}
for (id, filename, default, timeout_secs) in entries {
existing.push_str(&format!(
"\n[[driver]]\nid = \"{id}\"\ncmd = \".keel/drivers/{filename}\"\ndefault = {default}\ntimeout_secs = {timeout_secs}\n"
));
}
std::fs::write(cfg_path, existing)
.map_err(|e| anyhow::anyhow!("writing {}: {e}", cfg_path.display()))
}
pub fn select<'a>(cfg: &'a Config, id: Option<&str>) -> Result<&'a DriverConfig> {
if let Some(id) = id {
return cfg
.drivers
.iter()
.find(|d| d.id == id)
.ok_or_else(|| anyhow::anyhow!("no driver `{id}` in .keel/keel.toml"));
}
if let Some(d) = cfg.drivers.iter().find(|d| d.default) {
return Ok(d);
}
match cfg.drivers.len() {
0 => bail!("no drivers configured — add a [[driver]] block to .keel/keel.toml"),
1 => Ok(&cfg.drivers[0]),
_ => bail!("several drivers configured and none is default — pass --driver <id>"),
}
}
pub fn set_default(paths: &Paths, cfg: &Config, id: &str) -> Result<()> {
if !cfg.drivers.iter().any(|d| d.id == id) {
let known: Vec<&str> = cfg.drivers.iter().map(|d| d.id.as_str()).collect();
bail!("no driver `{id}` in .keel/keel.toml — configured: {}", known.join(", "));
}
let cfg_path = paths.config();
let text = std::fs::read_to_string(&cfg_path)
.with_context(|| format!("reading {}", cfg_path.display()))?;
let lines: Vec<&str> = text.lines().collect();
let starts: Vec<usize> =
lines.iter().enumerate().filter(|(_, l)| l.trim() == "[[driver]]").map(|(i, _)| i).collect();
let mut out: Vec<String> = Vec::with_capacity(lines.len() + 1);
let mut i = 0;
let mut block_idx = 0;
while i < lines.len() {
if block_idx < starts.len() && i == starts[block_idx] {
let end = starts.get(block_idx + 1).copied().unwrap_or_else(|| {
lines[i + 1..]
.iter()
.position(|l| l.trim_start().starts_with('['))
.map(|p| i + 1 + p)
.unwrap_or(lines.len())
});
let block = &lines[i..end];
let is_target = block.iter().any(|l| block_line_id(l).as_deref() == Some(id));
let mut wrote_default = false;
for line in block {
let t = line.trim_start();
if t.starts_with("default ") || t.starts_with("default=") {
out.push(format!("default = {is_target}"));
wrote_default = true;
} else {
out.push((*line).to_string());
}
}
if !wrote_default && is_target {
out.push("default = true".to_string());
}
i = end;
block_idx += 1;
} else {
out.push(lines[i].to_string());
i += 1;
}
}
let mut result = out.join("\n");
if text.ends_with('\n') {
result.push('\n');
}
std::fs::write(&cfg_path, result).with_context(|| format!("writing {}", cfg_path.display()))
}
fn block_line_id(line: &str) -> Option<String> {
let rest = line.trim().strip_prefix("id")?.trim_start();
let rest = rest.strip_prefix('=')?.trim_start();
let rest = rest.strip_prefix('"')?;
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scaffold_writes_every_reference_script_and_registers_it() {
let dir = std::env::temp_dir().join(format!("keel-scaffold-{}", std::process::id()));
std::fs::create_dir_all(dir.join(".keel")).unwrap();
let home = Paths { repo: dir.clone() };
let cfg = Config { drivers: vec![], ..Default::default() };
cfg.save(&home.config()).unwrap();
let lines = scaffold(&home, &cfg, false).unwrap();
let cfg = Config::load(&home.config()).unwrap();
assert!(lines.iter().any(|l| l.contains("created drivers/claude-code")));
assert!(lines.iter().any(|l| l.contains("created drivers/kiro")));
assert!(!cfg.drivers.iter().any(|d| d.id == "_common.sh"));
for id in ["claude-code", "codex", "copilot", "kiro", "noop"] {
let d = cfg.drivers.iter().find(|d| d.id == id).unwrap_or_else(|| panic!("no {id} entry"));
let script = dir.join(&d.cmd);
assert!(script.is_file(), "{id}'s script was not written: {}", script.display());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&script).unwrap().permissions().mode();
assert_ne!(mode & 0o111, 0, "{id}'s script is not executable");
}
}
assert!(cfg.drivers.iter().find(|d| d.id == "claude-code").unwrap().default);
assert!(!cfg.drivers.iter().find(|d| d.id == "kiro").unwrap().default);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn scaffold_neither_overwrites_a_hand_edit_nor_reregisters_it_twice() {
let dir = std::env::temp_dir().join(format!("keel-scaffold-idem-{}", std::process::id()));
std::fs::create_dir_all(dir.join(".keel/drivers")).unwrap();
std::fs::write(dir.join(".keel/drivers/kiro"), "#!/bin/sh\necho mine\n").unwrap();
let home = Paths { repo: dir.clone() };
let cfg = Config {
drivers: vec![DriverConfig {
id: "kiro".into(),
cmd: ".keel/drivers/kiro".into(),
default: false,
timeout_secs: 900,
}],
..Default::default()
};
cfg.save(&home.config()).unwrap();
let toml_before = std::fs::read_to_string(home.config()).unwrap();
scaffold(&home, &cfg, false).unwrap();
assert_eq!(
std::fs::read_to_string(dir.join(".keel/drivers/kiro")).unwrap(),
"#!/bin/sh\necho mine\n",
"an existing script must survive a non-forced scaffold"
);
let toml_after = std::fs::read_to_string(home.config()).unwrap();
assert!(
toml_after.starts_with(&toml_before),
"scaffold must only append; the original file content must survive as a prefix.\nbefore:\n{toml_before}\nafter:\n{toml_after}"
);
let cfg = Config::load(&home.config()).unwrap();
assert_eq!(
cfg.drivers.iter().filter(|d| d.id == "kiro").count(),
1,
"an already-configured driver must not gain a second [[driver]] entry"
);
for id in ["claude-code", "codex", "copilot", "noop"] {
assert_eq!(
cfg.drivers.iter().filter(|d| d.id == id).count(),
1,
"{id} was missing and should have been added exactly once"
);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_relative_adapter_resolves_against_the_configuring_repo() {
let dir = std::env::temp_dir().join(format!("keel-resolve-{}", std::process::id()));
std::fs::create_dir_all(dir.join(".keel/drivers")).unwrap();
std::fs::write(dir.join(".keel/drivers/x"), "#!/bin/sh\n").unwrap();
let home = Paths { repo: dir.clone() };
let resolved = resolve_program(&home, ".keel/drivers/x");
assert!(std::path::Path::new(&resolved).is_absolute(), "{resolved}");
assert!(resolved.ends_with(".keel/drivers/x"));
assert_eq!(resolve_program(&home, "claude"), "claude");
assert_eq!(resolve_program(&home, "/usr/bin/env"), "/usr/bin/env");
assert_eq!(resolve_program(&home, "./nope/x"), "./nope/x");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn set_default_flips_the_flag_and_preserves_hand_edits() {
let dir = std::env::temp_dir().join(format!("keel-set-default-{}", std::process::id()));
std::fs::create_dir_all(dir.join(".keel")).unwrap();
let home = Paths { repo: dir.clone() };
let raw = "# keel configuration\n\n\
[[driver]]\n\
id = \"claude-code\"\n\
cmd = \".keel/drivers/claude-code\"\n\
default = true\n\
timeout_secs = 900\n\n\
# kiro adapter, hand-tuned timeout\n\
[[driver]]\n\
id = \"kiro\"\n\
cmd = \".keel/drivers/kiro\"\n\
timeout_secs = 1200\n\n\
[[driver]]\n\
id = \"noop\"\n\
cmd = \".keel/drivers/noop\"\n\
default = false\n\
timeout_secs = 5\n";
std::fs::write(home.config(), raw).unwrap();
let cfg = Config::load(&home.config()).unwrap();
assert!(cfg.drivers.iter().find(|d| d.id == "claude-code").unwrap().default);
assert!(!cfg.drivers.iter().find(|d| d.id == "kiro").unwrap().default);
set_default(&home, &cfg, "kiro").unwrap();
let text = std::fs::read_to_string(home.config()).unwrap();
assert!(text.contains("# kiro adapter, hand-tuned timeout"), "{text}");
let cfg = Config::load(&home.config()).unwrap();
assert!(cfg.drivers.iter().find(|d| d.id == "kiro").unwrap().default);
assert!(!cfg.drivers.iter().find(|d| d.id == "claude-code").unwrap().default);
assert!(!cfg.drivers.iter().find(|d| d.id == "noop").unwrap().default);
assert_eq!(cfg.drivers.iter().filter(|d| d.default).count(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn set_default_rejects_an_unknown_id() {
let dir = std::env::temp_dir().join(format!("keel-set-default-unknown-{}", std::process::id()));
std::fs::create_dir_all(dir.join(".keel")).unwrap();
let home = Paths { repo: dir.clone() };
let cfg = Config::default();
cfg.save(&home.config()).unwrap();
let err = set_default(&home, &cfg, "does-not-exist").unwrap_err();
assert!(err.to_string().contains("does-not-exist"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
}