marver 0.0.6

A TUI workspace for AI agent sessions: tmux orchestration, git worktree management, and repo control in one place.
Documentation
//! marver's binary.
//!
//! One executable with subcommands rather than a separate `marverd`. The daemon
//! generates hook settings that invoke `marver hook`, and it finds that path via
//! `current_exe` — with two binaries it would have to guess where its sibling
//! was installed, and guess wrong whenever only one of them was on `PATH`.
//!
//! `marver` with no arguments opens the interface, starting a daemon first if
//! none is listening. That is tmux's arrangement — a client starts the server
//! it needs — and marver is built on tmux. `marver daemon` remains for running
//! it under a supervisor, or watching it in the foreground.

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;

fn usage() -> ExitCode {
    eprintln!(
        "usage:
  marver [options]                   open the interface, starting a daemon if needed
  marver status [options]            report whether a daemon is running
  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"
    );
    ExitCode::FAILURE
}

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("scan") => scan_command(args.get(1).map(PathBuf::from)),
        Some("-h" | "--help") => usage(),
        // No subcommand opens the interface; a leading flag is for it too.
        None => tui_command(&args),
        Some(arg) if arg.starts_with('-') => tui_command(&args),
        Some(other) => {
            eprintln!("marver: unknown command {other:?}");
            usage()
        }
    }
}

/// Build a configuration from the options every subcommand shares.
///
/// One reader for all of them, because the daemon and the interface find each
/// other through the paths these produce: a `--data-dir` understood by one and
/// ignored by the other would leave two processes talking past each other with
/// nothing to show for it.
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);

    // Before the store, and before the screen: an interface with no daemon
    // behind it looks entirely healthy and schedules nothing for ever.
    //
    // Silent when it works. The message would be erased by the alternate screen
    // a moment later, and a client quietly starting the server it needs is what
    // tmux does too. `marver status` is there for anyone who wants to look.
    match daemon::ensure_running(&config) {
        Ok(_) => {}
        Err(err) => {
            eprintln!("marver: {err}");
            return ExitCode::FAILURE;
        }
    }

    // Opens the same database the daemon writes to. WAL allows that; see the
    // note at the top of `tui`.
    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
}

/// Read `--name value` pairs, ignoring anything unrecognised.
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;
        }
    };

    // Printed once there is something to report, not before. The banner used to
    // come first, so a refused start announced four lines of healthy-looking
    // configuration and only contradicted itself on the fifth.
    println!("marverd: database {}", config.db.display());
    println!("marverd: socket   {}", config.socket.display());
    println!("marverd: scanning {}", config.scan_root.display());
    println!("marverd: cap      {}", config.cap);

    // No signal handler: an unclean exit leaves the socket file behind, and
    // binding already removes a stale socket that nothing is listening on. A
    // crash therefore costs nothing a restart does not fix.
    if let Err(err) = daemon.run(Arc::new(AtomicBool::new(false))) {
        eprintln!("marverd: {err}");
        return ExitCode::FAILURE;
    }
    ExitCode::SUCCESS
}

/// Forward a hook payload to the daemon.
///
/// **Always exits 0.** Claude Code treats a non-zero exit as an error on the
/// agent's critical path, and a marver outage must never interfere with the
/// agent it is only observing. Failures go to stderr, which lands in the hook
/// debug log.
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
}

/// Report whether a daemon is running, and what it has to work with.
///
/// Exits non-zero when nothing is listening, so a shell can ask too.
fn status_command(args: &[String]) -> ExitCode {
    let config = config_from(args);
    let running = daemon::is_running(&config);

    println!(
        "daemon    {}",
        if running { "running" } else { "not running" }
    );
    println!("socket    {}", config.socket.display());
    println!("database  {}", config.db.display());
    println!("log       {}", config.log.display());

    // Only read a database that exists. `Store::open` would otherwise create
    // one, and a command that reports on the system should not build part of it.
    if !config.db.exists() {
        println!("tasks     no database yet");
    } 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();
                println!(
                    "tasks     {}",
                    if counts.is_empty() {
                        "none".to_string()
                    } else {
                        counts.join(", ")
                    }
                );
            }
            Err(err) => println!("tasks     unreadable: {err}"),
        }
    }

    if running {
        ExitCode::SUCCESS
    } 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();

    println!("scanning {}", root.display());
    for repo in &scan.repos {
        println!("  {:<24} {}", repo.name, repo.path.display());
    }
    if !scan.unreadable.is_empty() {
        println!("\n{} unreadable:", scan.unreadable.len());
        for path in &scan.unreadable {
            println!("  {}", path.display());
        }
    }
    println!("\n{} repos in {:.0?}", scan.repos.len(), elapsed);
    ExitCode::SUCCESS
}