use std::io::Read;
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::time::{Duration, Instant};
use marver::Branches;
use marver::Scanner;
use marver::daemon::{self, Config, Daemon};
use marver::harness::Harness;
use marver::hook::{self, Delivery, Payload};
use marver::notify::SystemNotifier;
use marver::tmux::Tmux;
use marver::tui::plural;
const USAGE: &str = "usage:
marver [options] open the interface, starting a daemon if needed
marver status is a daemon running, and what does it hold
marver restart [--force] swap the daemon for this version
marver upgrade [--restart] install the newest published marver
marver cleanup [--dry-run] [--branches] [--force]
remove the worktrees of finished tasks
marver daemon run the daemon in the foreground
marver scan [root] list git repos under a root
marver hook --task <id> --socket <path> [--from <harness>]
forward an agent hook to the daemon
options:
--data-dir <path> where the database, socket, and log live
--scan-root <path> directory scanned for repos
--harness <name> claude (default), codex, or name:program args
--cap <n> how many agents may run at once
-V, --version print the version and exit";
fn print_out(text: &str) -> ExitCode {
use std::io::Write;
let mut out = std::io::stdout().lock();
match out.write_all(text.as_bytes()).and_then(|()| out.flush()) {
Ok(()) => ExitCode::SUCCESS,
Err(err) if err.kind() == std::io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
Err(err) => {
eprintln!("marver: could not write output: {err}");
ExitCode::FAILURE
}
}
}
fn help() -> ExitCode {
print_out(&format!("{USAGE}\n"))
}
fn usage() -> ExitCode {
eprintln!("{USAGE}");
ExitCode::FAILURE
}
const OPTIONS: &[&str] = &["--data-dir", "--scan-root", "--cap", "--harness"];
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
match args.first().map(String::as_str) {
Some("hook") => hook_command(&args[1..]),
Some("daemon") => daemon_command(&args[1..]),
Some("status") => status_command(&args[1..]),
Some("restart") => restart_command(&args[1..]),
Some("upgrade") => upgrade_command(&args[1..]),
Some("cleanup") => cleanup_command(&args[1..]),
Some("scan") => scan_command(args.get(1).map(PathBuf::from)),
Some("-h" | "--help") => help(),
Some("-v" | "-V" | "--version") => version(),
None => tui_command(&args),
Some(arg) if arg.starts_with('-') => match unknown_option(&args) {
Some(bad) => {
eprintln!("marver: unknown option {bad:?}");
usage()
}
None => tui_command(&args),
},
Some(other) => {
eprintln!("marver: unknown command {other:?}");
usage()
}
}
}
fn unknown_option(args: &[String]) -> Option<String> {
let mut rest = args.iter();
while let Some(arg) = rest.next() {
if !arg.starts_with('-') {
continue;
}
if !OPTIONS.contains(&arg.as_str()) {
return Some(arg.clone());
}
rest.next();
}
None
}
fn version() -> ExitCode {
print_out(&format!("marver {}\n", env!("CARGO_PKG_VERSION")))
}
fn config_from(args: &[String]) -> Config {
let data_dir = flag(args, "--data-dir")
.map(PathBuf::from)
.unwrap_or_else(Config::default_data_dir);
let scan_root = flag(args, "--scan-root")
.map(PathBuf::from)
.unwrap_or_else(Config::default_scan_root);
let mut config = Config::new(&data_dir, scan_root);
if let Some(cap) = flag(args, "--cap").and_then(|v| v.parse().ok()) {
if cap == 0 {
eprintln!("marver: --cap 0, so no task will be started");
}
config.cap = cap;
}
if let Some(spec) = flag(args, "--harness") {
match Harness::parse(&spec) {
Ok(harness) => config.harness = harness,
Err(err) => {
eprintln!("marver: {err}");
std::process::exit(2);
}
}
}
config
}
fn tui_command(args: &[String]) -> ExitCode {
let config = config_from(args);
match daemon::ensure_running(&config) {
Ok(daemon::Startup::Outdated { running }) => eprintln!(
"marver: this is {}, but the daemon already running is {running}; \
run `marver restart` when nothing is mid-turn",
daemon::VERSION
),
Ok(_) => {}
Err(err) => {
eprintln!("marver: {err}");
return ExitCode::FAILURE;
}
}
let store = match marver::Store::open(&config.db) {
Ok(store) => store,
Err(err) => {
eprintln!("marver: could not open {}: {err}", config.db.display());
return ExitCode::FAILURE;
}
};
if let Err(err) = marver::tui::run(store, config) {
eprintln!("marver: {err}");
return ExitCode::FAILURE;
}
ExitCode::SUCCESS
}
fn flag(args: &[String], name: &str) -> Option<String> {
args.iter()
.position(|a| a == name)
.and_then(|i| args.get(i + 1))
.cloned()
}
fn daemon_command(args: &[String]) -> ExitCode {
let config = config_from(args);
let mut daemon = match Daemon::new(config.clone(), Tmux::new(), SystemNotifier) {
Ok(daemon) => daemon,
Err(err) => {
eprintln!("marverd: {err}");
return ExitCode::FAILURE;
}
};
print_out(&format!(
"marverd: version {}\nmarverd: database {}\nmarverd: socket {}\nmarverd: scanning {}\nmarverd: cap {}\n",
daemon::VERSION,
config.db.display(),
config.socket.display(),
config.scan_root.display(),
config.cap,
));
if let Err(err) = daemon.run(Arc::new(AtomicBool::new(false))) {
eprintln!("marverd: {err}");
return ExitCode::FAILURE;
}
ExitCode::SUCCESS
}
fn hook_command(args: &[String]) -> ExitCode {
let task_id: Option<i64> = flag(args, "--task").and_then(|v| v.parse().ok());
let socket = flag(args, "--socket").map(PathBuf::from);
let (Some(task_id), Some(socket)) = (task_id, socket) else {
eprintln!("marver hook: --task and --socket are both required");
return ExitCode::SUCCESS;
};
let body = match flag(args, "--from").as_deref() {
Some("codex") => match args.iter().rev().find(|a| a.starts_with('{')) {
Some(json) => json.clone().into_bytes(),
None => {
eprintln!("marver hook: --from codex expects the payload as an argument");
return ExitCode::SUCCESS;
}
},
_ => {
let mut body = Vec::new();
if let Err(err) = std::io::stdin().read_to_end(&mut body) {
eprintln!("marver hook: could not read the payload: {err}");
return ExitCode::SUCCESS;
}
body
}
};
match Payload::parse(&body) {
Ok(payload) => {
let delivery = Delivery { task_id, payload };
if let Err(err) = hook::send(&socket, &delivery) {
eprintln!("marver hook: could not reach the daemon: {err}");
}
}
Err(err) => eprintln!("marver hook: {err}"),
}
ExitCode::SUCCESS
}
fn upgrade_command(args: &[String]) -> ExitCode {
let restart_after = args.iter().any(|arg| arg == "--restart");
let before = daemon::VERSION;
let cargo = std::env::var("MARVER_CARGO").unwrap_or_else(|_| "cargo".to_string());
eprintln!("marver: {cargo} install marver --force");
let status = std::process::Command::new(&cargo)
.args(["install", "marver", "--force"])
.status();
match status {
Ok(status) if status.success() => {}
Ok(status) => {
eprintln!("marver: {cargo} exited with {status}; nothing was changed");
return ExitCode::FAILURE;
}
Err(err) => {
eprintln!(
"marver: could not run {cargo}: {err}\n\
\x20 marver upgrades itself only when installed with cargo;\n\
\x20 otherwise upgrade it the way you installed it"
);
return ExitCode::FAILURE;
}
}
match installed_version() {
Some(now) if now == before => eprintln!("marver: already on {now}"),
Some(now) => eprintln!("marver: upgraded {before} -> {now}"),
None => eprintln!("marver: upgraded from {before}"),
}
if restart_after {
return restart_command(args);
}
let config = config_from(args);
if daemon::is_running(&config) {
eprintln!(
"marver: the daemon is still running {}; `marver restart` swaps it\n\
\x20 when nothing is mid-turn, or `marver upgrade --restart` next time",
daemon::running_version(&config).unwrap_or_else(|| "an older version".to_string())
);
}
ExitCode::SUCCESS
}
fn installed_version() -> Option<String> {
let exe = std::env::current_exe().ok()?;
let out = std::process::Command::new(exe)
.arg("--version")
.output()
.ok()?;
String::from_utf8_lossy(&out.stdout)
.trim()
.strip_prefix("marver ")
.map(str::to_string)
}
fn agents_mid_turn(config: &Config) -> Vec<(marver::TaskState, usize)> {
let Ok(store) = marver::Store::open(&config.db) else {
return Vec::new();
};
[marver::TaskState::Running, marver::TaskState::Blocked]
.into_iter()
.filter_map(|state| match store.list_tasks_in_state(state) {
Ok(tasks) if !tasks.is_empty() => Some((state, tasks.len())),
_ => None,
})
.collect()
}
fn restart_command(args: &[String]) -> ExitCode {
let config = config_from(args);
let force = args.iter().any(|arg| arg == "--force");
let was = daemon::running_version(&config);
if !force {
let busy = agents_mid_turn(&config);
if !busy.is_empty() {
let counts: Vec<String> = busy
.iter()
.map(|(state, count)| format!("{count} {state}"))
.collect();
eprintln!(
"marver: {} may still report ({})\n\
\x20 a hook arriving while nothing is listening is lost, and the task\n\
\x20 is left running behind an agent that has already finished\n\
\x20 wait for review, or restart anyway with --force",
if busy.iter().map(|(_, n)| n).sum::<usize>() == 1 {
"a task"
} else {
"tasks"
},
counts.join(", "),
);
return ExitCode::FAILURE;
}
}
match daemon::stop(&config) {
Ok(true) => eprintln!(
"marver: stopped the daemon ({})",
was.as_deref().unwrap_or("unknown version")
),
Ok(false) => eprintln!("marver: no daemon was running"),
Err(err) => {
eprintln!("marver: {err}");
return ExitCode::FAILURE;
}
}
match daemon::ensure_running(&config) {
Ok(daemon::Startup::Started { pid }) => {
match daemon::announced_version(&config, pid) {
Some(version) => eprintln!("marver: started a daemon ({version}), pid {pid}"),
None => eprintln!("marver: started a daemon, pid {pid}"),
}
ExitCode::SUCCESS
}
Ok(daemon::Startup::AlreadyRunning) => ExitCode::SUCCESS,
Ok(daemon::Startup::Outdated { running }) => {
eprintln!("marver: the daemon is still {running}; it was not replaced");
ExitCode::FAILURE
}
Err(err) => {
eprintln!("marver: {err}");
ExitCode::FAILURE
}
}
}
fn cleanup_command(args: &[String]) -> ExitCode {
let config = config_from(args);
let dry_run = args.iter().any(|arg| arg == "--dry-run");
let force = args.iter().any(|arg| arg == "--force");
let branches = match (args.iter().any(|arg| arg == "--branches"), force) {
(false, _) => Branches::Keep,
(true, false) => Branches::DeleteMerged,
(true, true) => Branches::Discard,
};
if !config.db.exists() {
eprintln!("marver: no database at {}", config.db.display());
return ExitCode::SUCCESS;
}
let store = match marver::Store::open(&config.db) {
Ok(store) => store,
Err(err) => {
eprintln!("marver: could not open {}: {err}", config.db.display());
return ExitCode::FAILURE;
}
};
let candidates = match marver::reclaimable(&store) {
Ok(candidates) => candidates,
Err(err) => {
eprintln!("marver: {err}");
return ExitCode::FAILURE;
}
};
if candidates.is_empty() {
eprintln!("marver: no finished task is holding a worktree");
return ExitCode::SUCCESS;
}
let launcher = marver::launcher::Launcher::new(
Tmux::new(),
marver::WorktreeManager::new(&config.workspace_root),
&config.marver_bin,
&config.socket,
);
let mut report = String::new();
let mut removed = 0usize;
let mut skipped = 0usize;
let mut kept_branches = Vec::new();
let mut failed = false;
for candidate in &candidates {
let task = &candidate.task;
let held = plural(candidate.worktrees.len(), "worktree");
let label = format!("{:<4} {}", task.id, first_line(&task.title, 32));
if !candidate.is_clean() && !force {
skipped += 1;
report.push_str(&format!(
"keep {label} {}, {} with changes\n",
task.state,
candidate.dirty.len()
));
continue;
}
if dry_run {
removed += 1;
report.push_str(&format!("remove {label} {}, {held}\n", task.state));
continue;
}
match launcher.shut_down(&store, task, branches) {
Ok(outcome) if outcome.is_clean() => {
removed += 1;
kept_branches.extend(outcome.kept_branches);
report.push_str(&format!("removed {label} {held}\n"));
}
Ok(outcome) => {
failed = true;
kept_branches.extend(outcome.kept_branches);
for (path, why) in &outcome.failed {
report.push_str(&format!("failed {label} {}: {why}\n", path.display()));
}
}
Err(err) => {
failed = true;
report.push_str(&format!("failed {label} {err}\n"));
}
}
}
report.push('\n');
report.push_str(&match (dry_run, removed, skipped) {
(true, n, 0) => format!("{} would be removed\n", plural(n, "task")),
(true, n, s) => format!(
"{} would be removed, {s} left alone — --force removes those too\n",
plural(n, "task")
),
(false, n, 0) => format!("{} cleaned up\n", plural(n, "task")),
(false, n, s) => format!(
"{} cleaned up, {s} left alone — --force removes those too\n",
plural(n, "task")
),
});
if !kept_branches.is_empty() {
report.push_str(&format!(
"{} kept: the only commits on them are their own — --force deletes those too\n",
plural(kept_branches.len(), "branch"),
));
}
let code = print_out(&report);
if failed { ExitCode::FAILURE } else { code }
}
fn first_line(title: &str, width: usize) -> String {
let line = title.lines().next().unwrap_or("").trim();
let mut out: String = line.chars().take(width).collect();
if line.chars().count() > width {
out.push('…');
}
format!("{out:<width$}", width = width + 1)
}
fn status_command(args: &[String]) -> ExitCode {
let config = config_from(args);
let running = daemon::is_running(&config);
let mut out = String::new();
let version = running
.then(|| daemon::running_version(&config))
.flatten()
.unwrap_or_else(|| "unknown".to_string());
out.push_str(&if running {
format!("daemon running ({version})\n")
} else {
"daemon not running\n".to_string()
});
out.push_str(&format!("socket {}\n", config.socket.display()));
out.push_str(&format!("database {}\n", config.db.display()));
out.push_str(&format!("log {}\n", config.log.display()));
if !config.db.exists() {
out.push_str("tasks no database yet\n");
} else {
match marver::Store::open(&config.db) {
Ok(store) => {
let counts: Vec<String> = marver::TaskState::ALL
.iter()
.filter_map(|&state| match store.list_tasks_in_state(state) {
Ok(tasks) if !tasks.is_empty() => Some(format!("{} {state}", tasks.len())),
_ => None,
})
.collect();
out.push_str(&format!(
"tasks {}\n",
if counts.is_empty() {
"none".to_string()
} else {
counts.join(", ")
}
));
}
Err(err) => out.push_str(&format!("tasks unreadable: {err}\n")),
}
}
if running && version != daemon::VERSION {
out.push_str(&format!(
"\nwarning this marver is {}, the daemon is {version}\n\
\x20 it keeps running until restarted: marver restart\n",
daemon::VERSION
));
}
let wrote = print_out(&out);
if running { wrote } else { ExitCode::FAILURE }
}
fn scan_command(root: Option<PathBuf>) -> ExitCode {
let root = root.unwrap_or_else(Config::default_scan_root);
let started = Instant::now();
let scan = match Scanner::new(&root).walk() {
Ok(scan) => scan,
Err(err) => {
eprintln!("marver: {err}");
return ExitCode::FAILURE;
}
};
let elapsed: Duration = started.elapsed();
let mut out = format!("scanning {}\n", root.display());
for repo in &scan.repos {
out.push_str(&format!(" {:<24} {}\n", repo.name, repo.path.display()));
}
if !scan.unreadable.is_empty() {
out.push_str(&format!("\n{} unreadable:\n", scan.unreadable.len()));
for path in &scan.unreadable {
out.push_str(&format!(" {}\n", path.display()));
}
}
out.push_str(&format!(
"\n{} repos in {:.0?}\n",
scan.repos.len(),
elapsed
));
print_out(&out)
}