use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::Arc;
use anyhow::{anyhow, Result};
use nexo_driver_claude::{MemoryBindingStore, SessionBindingStore, SqliteBindingStore};
use nexo_driver_loop::{
AcceptanceEvaluator, BindingStoreKind, DeciderConfig, DefaultAcceptanceEvaluator, DriverConfig,
DriverOrchestrator, GitWorktreeMode, NoopEventSink, WorkspaceManager,
};
use nexo_driver_permission::{AllowAllDecider, DenyAllDecider, PermissionDecider};
use nexo_driver_types::DefaultCompactPolicy;
use nexo_driver_types::Goal;
#[tokio::main]
async fn main() -> ExitCode {
tracing_subscriber::fmt::init();
match run().await {
Ok(code) => code,
Err(e) => {
eprintln!("nexo-driver: {e:#}");
ExitCode::from(2)
}
}
}
async fn run() -> Result<ExitCode> {
let mut args = std::env::args().skip(1);
let cmd = args
.next()
.ok_or_else(|| anyhow!("missing subcommand (run | list-active)"))?;
match cmd.as_str() {
"run" => cmd_run(args).await,
"list-active" => cmd_list_active(args).await,
"list-worktrees" => cmd_list_worktrees(args).await,
"rollback" => cmd_rollback(args).await,
"-h" | "--help" => {
print_help();
Ok(ExitCode::SUCCESS)
}
other => Err(anyhow!("unknown subcommand: {other}")),
}
}
fn print_help() {
eprintln!(
"nexo-driver run <goal-yaml> [--config <claude.yaml>] [--no-events]\n\
nexo-driver list-active [--config <claude.yaml>]\n\
nexo-driver list-worktrees [--config <claude.yaml>]\n\
nexo-driver rollback <goal-id> --to <sha> [--config <claude.yaml>]"
);
}
async fn cmd_list_worktrees(mut args: impl Iterator<Item = String>) -> Result<ExitCode> {
let mut config_path: Option<PathBuf> = None;
while let Some(a) = args.next() {
match a.as_str() {
"--config" => {
config_path = Some(
args.next()
.ok_or_else(|| anyhow!("--config requires path"))?
.into(),
);
}
other => return Err(anyhow!("unknown flag: {other}")),
}
}
let cfg = load_config(config_path.as_deref())?;
if !cfg.workspace.git.enabled {
println!("git mode disabled in config — nothing to list");
return Ok(ExitCode::SUCCESS);
}
let Some(source_repo) = cfg.workspace.git.source_repo.clone() else {
println!("workspace.git.source_repo missing — nothing to list");
return Ok(ExitCode::SUCCESS);
};
use nexo_driver_loop::ShellRunner;
let shell = ShellRunner::default();
let res = shell
.run(
&format!(
"git -C {} worktree list --porcelain",
shell_escape(&source_repo.display().to_string())
),
&source_repo,
std::time::Duration::from_secs(15),
)
.await
.map_err(|e| anyhow!("git worktree list failed: {e}"))?;
if res.exit_code != Some(0) {
return Err(anyhow!("git worktree list exit {:?}", res.exit_code));
}
let mut worktree: Option<String> = None;
let mut head: Option<String> = None;
for line in res.stdout.lines() {
if let Some(p) = line.strip_prefix("worktree ") {
worktree = Some(p.to_string());
head = None;
} else if let Some(b) = line.strip_prefix("branch refs/heads/") {
if b.starts_with("nexo-driver/") {
if let (Some(w), Some(h)) = (worktree.as_ref(), head.as_ref()) {
println!("{w}\t{b}\t{}", &h[..h.len().min(7)]);
}
}
} else if let Some(h) = line.strip_prefix("HEAD ") {
head = Some(h.to_string());
}
}
Ok(ExitCode::SUCCESS)
}
async fn cmd_rollback(mut args: impl Iterator<Item = String>) -> Result<ExitCode> {
let goal_id = args
.next()
.ok_or_else(|| anyhow!("rollback: <goal-id> required"))?;
let mut to_sha: Option<String> = None;
let mut config_path: Option<PathBuf> = None;
while let Some(a) = args.next() {
match a.as_str() {
"--to" => {
to_sha = Some(args.next().ok_or_else(|| anyhow!("--to requires <sha>"))?);
}
"--config" => {
config_path = Some(
args.next()
.ok_or_else(|| anyhow!("--config requires path"))?
.into(),
);
}
other => return Err(anyhow!("unknown flag: {other}")),
}
}
let sha = to_sha.ok_or_else(|| anyhow!("rollback: --to <sha> required"))?;
let cfg = load_config(config_path.as_deref())?;
let wm = build_workspace_manager(&cfg)?;
let workspace = cfg.workspace.root.join(&goal_id);
wm.rollback(&workspace, &sha).await?;
println!("rollback ok: {goal_id} → {sha}");
Ok(ExitCode::SUCCESS)
}
fn shell_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('\'');
for ch in s.chars() {
if ch == '\'' {
out.push_str("'\\''");
} else {
out.push(ch);
}
}
out.push('\'');
out
}
async fn cmd_run(mut args: impl Iterator<Item = String>) -> Result<ExitCode> {
let goal_path = args
.next()
.ok_or_else(|| anyhow!("run: <goal-yaml> required"))?;
let mut config_path: Option<PathBuf> = None;
let mut no_events = false;
while let Some(a) = args.next() {
match a.as_str() {
"--config" => {
config_path = Some(
args.next()
.ok_or_else(|| anyhow!("--config requires path"))?
.into(),
);
}
"--no-events" => no_events = true,
other => return Err(anyhow!("unknown flag: {other}")),
}
}
let cfg = load_config(config_path.as_deref())?;
let goal_raw = std::fs::read_to_string(&goal_path)?;
let goal: Goal = serde_yaml::from_str(&goal_raw).map_err(|e| anyhow!("goal yaml: {e}"))?;
if goal.id.0.is_nil() {
return Err(anyhow!("goal id must not be the nil UUID"));
}
let orchestrator = build_orchestrator(&cfg, no_events).await?;
let outcome = orchestrator.run_goal(goal).await?;
let json = serde_json::to_string_pretty(&outcome)?;
println!("{json}");
let code = match outcome.outcome {
nexo_driver_types::AttemptOutcome::Done => ExitCode::SUCCESS,
nexo_driver_types::AttemptOutcome::BudgetExhausted { .. }
| nexo_driver_types::AttemptOutcome::Escalate { .. }
| nexo_driver_types::AttemptOutcome::NeedsRetry { .. } => ExitCode::from(1),
_ => ExitCode::from(2),
};
let _ = orchestrator.shutdown().await;
Ok(code)
}
async fn cmd_list_active(mut args: impl Iterator<Item = String>) -> Result<ExitCode> {
let mut config_path: Option<PathBuf> = None;
while let Some(a) = args.next() {
match a.as_str() {
"--config" => {
config_path = Some(
args.next()
.ok_or_else(|| anyhow!("--config requires path"))?
.into(),
);
}
other => return Err(anyhow!("unknown flag: {other}")),
}
}
let cfg = load_config(config_path.as_deref())?;
let store = open_binding_store(&cfg.binding_store).await?;
let active = store.list_active().await?;
println!("{}", serde_json::to_string_pretty(&active)?);
Ok(ExitCode::SUCCESS)
}
fn build_workspace_manager(cfg: &DriverConfig) -> Result<WorkspaceManager> {
let mut wm = WorkspaceManager::new(&cfg.workspace.root);
if cfg.workspace.git.enabled {
let path = cfg
.workspace
.git
.source_repo
.clone()
.ok_or_else(|| anyhow!("workspace.git.enabled=true but source_repo missing"))?;
wm = wm.with_git(GitWorktreeMode::SourceRepo {
path,
base_ref: cfg.workspace.git.base_ref.clone(),
});
}
Ok(wm)
}
fn load_config(path: Option<&std::path::Path>) -> Result<DriverConfig> {
let path = path
.map(PathBuf::from)
.or_else(|| std::env::var_os("NEXO_DRIVER_CONFIG").map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("config/driver/claude.yaml"));
Ok(DriverConfig::from_yaml_file(&path)?)
}
async fn open_binding_store(
cfg: &nexo_driver_loop::BindingStoreConfig,
) -> Result<Arc<dyn SessionBindingStore>> {
match cfg.kind {
BindingStoreKind::Memory => {
let s: Arc<dyn SessionBindingStore> = Arc::new(MemoryBindingStore::new());
Ok(s)
}
BindingStoreKind::Sqlite => {
let path = cfg
.path
.clone()
.ok_or_else(|| anyhow!("sqlite binding_store requires path"))?;
let mut store =
SqliteBindingStore::open(path.to_str().ok_or_else(|| anyhow!("bad path"))?).await?;
if let Some(t) = cfg.idle_ttl {
store = store.with_idle_ttl(t);
}
if let Some(a) = cfg.max_age {
store = store.with_max_age(a);
}
let s: Arc<dyn SessionBindingStore> = Arc::new(store);
Ok(s)
}
}
}
async fn build_orchestrator(cfg: &DriverConfig, no_events: bool) -> Result<DriverOrchestrator> {
let binding_store = open_binding_store(&cfg.binding_store).await?;
let workspace_manager = Arc::new(build_workspace_manager(cfg)?);
let decider: Arc<dyn PermissionDecider> = match &cfg.permission.decider {
DeciderConfig::AllowAll => Arc::new(AllowAllDecider),
DeciderConfig::DenyAll { reason } => Arc::new(DenyAllDecider {
reason: reason.clone(),
}),
DeciderConfig::Llm { .. } => {
return Err(anyhow!(
"DeciderConfig::Llm not yet wired here (needs llm config from broker layer)"
));
}
};
let event_sink: Arc<dyn nexo_driver_loop::DriverEventSink> =
if no_events || !cfg.driver.emit_nats_events {
Arc::new(NoopEventSink)
} else {
Arc::new(NoopEventSink)
};
let mut acceptance = DefaultAcceptanceEvaluator::new();
if let Some(t) = cfg.acceptance.default_shell_timeout {
acceptance = acceptance.with_default_shell_timeout(t);
}
if let Some(n) = cfg.acceptance.evidence_byte_limit {
acceptance = acceptance.with_evidence_byte_limit(n);
}
let acceptance: Arc<dyn AcceptanceEvaluator> = Arc::new(acceptance);
let compact_policy: Arc<dyn nexo_driver_types::CompactPolicy> =
Arc::new(DefaultCompactPolicy {
enabled: cfg.compact_policy.enabled,
threshold: cfg.compact_policy.threshold,
min_turns_between: cfg.compact_policy.min_turns_between_compacts,
max_consecutive_failures: cfg
.compact_policy
.auto
.as_ref()
.map(|a| a.max_consecutive_failures)
.unwrap_or(3),
});
let mut orch_builder = DriverOrchestrator::builder()
.claude_config(cfg.claude.clone())
.binding_store(binding_store)
.acceptance(acceptance)
.decider(decider)
.workspace_manager(workspace_manager)
.event_sink(event_sink)
.compact_policy(compact_policy)
.compact_context_window(cfg.compact_policy.context_window)
.bin_path(cfg.driver.bin_path.clone())
.socket_path(cfg.permission.socket.clone());
if let Some(ref auto) = cfg.compact_policy.auto {
orch_builder = orch_builder.auto_config(auto.clone());
}
let orch = orch_builder.build().await?;
Ok(orch)
}