marver 0.0.5

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`.
//!
//! The TUI does not exist yet, so `marver` with no arguments prints a scan.

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::{Config, Daemon};
use marver::hook::{self, Delivery, Payload};
use marver::notify::SystemNotifier;
use marver::tmux::Tmux;

fn usage() -> ExitCode {
    eprintln!(
        "usage:
  marver [--data-dir <path>]         open the interface
  marver daemon [options]            run the scheduler and hook receiver
      --data-dir <path>              where the database and socket live
      --scan-root <path>             directory scanned for repos
      --cap <n>                      how many agents may run at once
  marver scan [root]                 list git repos under a root
  marver hook --task <id> --socket <path>
                                     forward a Claude Code hook to the daemon"
    );
    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("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()
        }
    }
}

fn tui_command(args: &[String]) -> ExitCode {
    let data_dir = flag(args, "--data-dir")
        .map(PathBuf::from)
        .unwrap_or_else(Config::default_data_dir);
    let config = Config::new(&data_dir, Config::default_scan_root());

    // 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 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;
    }

    println!("marverd: database {}", config.db.display());
    println!("marverd: socket   {}", config.socket.display());
    println!("marverd: scanning {}", config.scan_root.display());
    println!("marverd: cap      {}", config.cap);

    let mut daemon = match Daemon::new(config, Tmux::new(), SystemNotifier) {
        Ok(daemon) => daemon,
        Err(err) => {
            eprintln!("marverd: {err}");
            return ExitCode::FAILURE;
        }
    };

    // 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
}

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
}