agent-fleet 0.1.0

Autonomous OSS-repo health for solo maintainers (Rust port of @p-vbordei/agent-fleet)
Documentation
//! CLI entry point (SPEC ยง3).

use std::path::PathBuf;
use std::process::ExitCode;

use agent_fleet::{
    enroll, load_fleet_config, tick_one, EnrollError, ExecFn, ExecResult, FleetConfigError,
    TickOutcome,
};
use agent_fleet::tick::ReqwestAnthropicClient;
use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(name = "agent-fleet", about = "Autonomous OSS-repo health for solo maintainers")]
struct Cli {
    #[command(subcommand)]
    command: Option<Command>,
}

#[derive(Subcommand)]
enum Command {
    /// Install the template kit into a fleet entry's target path.
    Enroll { name: String },
    /// Run the weekly health check, optionally scoped to one fleet entry.
    Tick { name: Option<String> },
}

fn templates_root() -> PathBuf {
    // Look for templates relative to the binary, climbing a few parents
    // (release / debug builds nest a few dirs deep below the manifest).
    if let Ok(exe) = std::env::current_exe() {
        let mut cursor: Option<&std::path::Path> = exe.parent();
        for _ in 0..6 {
            if let Some(dir) = cursor {
                let p = dir.join("templates");
                if p.exists() {
                    return p;
                }
                cursor = dir.parent();
            }
        }
    }
    if let Ok(manifest) = std::env::var("CARGO_MANIFEST_DIR") {
        let p = PathBuf::from(manifest).join("templates");
        if p.exists() {
            return p;
        }
    }
    PathBuf::from("templates")
}

fn main() -> ExitCode {
    let cli = Cli::parse();
    let cmd = match cli.command {
        Some(c) => c,
        None => {
            eprintln!("Usage: agent-fleet <enroll|tick> [args]");
            return ExitCode::from(64);
        }
    };

    let cfg_path = std::env::current_dir()
        .map(|d| d.join("fleet.yaml"))
        .unwrap_or_else(|_| PathBuf::from("fleet.yaml"));
    if !cfg_path.exists() {
        eprintln!("fleet.yaml not found at {}", cfg_path.display());
        return ExitCode::from(1);
    }
    let cfg = match load_fleet_config(&cfg_path) {
        Ok(c) => c,
        Err(FleetConfigError::Io { source, .. })
            if source.kind() == std::io::ErrorKind::NotFound =>
        {
            eprintln!("fleet.yaml not found at {}", cfg_path.display());
            return ExitCode::from(1);
        }
        Err(e) => {
            eprintln!("{}", e);
            return ExitCode::from(1);
        }
    };

    match cmd {
        Command::Enroll { name } => {
            let entry = match cfg.fleet.iter().find(|e| e.name == name) {
                Some(e) => e,
                None => {
                    eprintln!("fleet entry not found: {}", name);
                    return ExitCode::from(1);
                }
            };
            match enroll(entry, &templates_root()) {
                Ok(written) => {
                    println!("enrolled {}: {} files written", name, written.len());
                    ExitCode::from(0)
                }
                Err(e) => match e {
                    EnrollError::BadTarget(_) => {
                        eprintln!("{}", e);
                        ExitCode::from(2)
                    }
                    _ => {
                        eprintln!("{}", e);
                        ExitCode::from(3)
                    }
                },
            }
        }
        Command::Tick { name } => {
            let entries: Vec<_> = if let Some(n) = &name {
                cfg.fleet.iter().filter(|e| &e.name == n).cloned().collect()
            } else {
                cfg.fleet.clone()
            };
            if entries.is_empty() {
                eprintln!("no fleet entries match: {}", name.unwrap_or_default());
                return ExitCode::from(1);
            }
            let api_key = match std::env::var("ANTHROPIC_API_KEY") {
                Ok(v) if !v.is_empty() => v,
                _ => {
                    eprintln!("ANTHROPIC_API_KEY not set");
                    return ExitCode::from(4);
                }
            };
            let client = ReqwestAnthropicClient::new(api_key);
            let exec: ExecFn = Box::new(|cmd: &str| {
                let r = std::process::Command::new("sh")
                    .arg("-c")
                    .arg(cmd)
                    .output();
                match r {
                    Ok(out) => ExecResult {
                        stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
                        stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
                        code: out.status.code().unwrap_or(-1),
                    },
                    Err(e) => ExecResult {
                        stdout: String::new(),
                        stderr: e.to_string(),
                        code: -1,
                    },
                }
            });
            let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
            let iso_date = chrono::Utc::now().format("%Y-%m-%d").to_string();
            let mut any_error = false;
            for entry in entries.iter() {
                let r =
                    rt.block_on(async { tick_one(entry, &client, &exec, &iso_date).await });
                match r {
                    TickOutcome::IssueCreated { url } => {
                        println!("tick {}: issue-created {}", entry.name, url);
                    }
                    TickOutcome::NoFindings => {
                        println!("tick {}: no-findings", entry.name);
                    }
                    TickOutcome::Error { message } => {
                        any_error = true;
                        println!("tick {}: error {}", entry.name, message);
                    }
                }
            }
            ExitCode::from(if any_error { 1 } else { 0 })
        }
    }
}