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::Scanner;
use marver::daemon::{self, Config, Daemon};
use marver::hook::{self, Delivery, Payload};
use marver::notify::SystemNotifier;
use marver::tmux::Tmux;
const USAGE: &str = "usage:
marver [options] open the interface, starting a daemon if needed
marver status [options] report whether a daemon is running
marver restart [options] stop the running daemon and start this version
--force restart even with agents mid-turn
marver upgrade [options] install the newest published marver (cargo only)
--restart restart the daemon afterwards too
marver daemon [options] run the scheduler and hook receiver in the foreground
marver scan [root] list git repos under a root
marver hook --task <id> --socket <path>
forward a Claude Code hook to the daemon
options:
--data-dir <path> where the database, socket, and log live
--scan-root <path> directory scanned for repos
--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"];
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("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()) {
config.cap = cap;
}
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 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;
}
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 }) => {
eprintln!("marver: started a daemon ({}), pid {pid}", daemon::VERSION);
ExitCode::SUCCESS
}
Ok(_) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("marver: {err}");
ExitCode::FAILURE
}
}
}
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)
}