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 {
Enroll { name: String },
Tick { name: Option<String> },
}
fn templates_root() -> PathBuf {
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 })
}
}
}