mod compose;
mod config;
mod deps;
mod garden;
mod providers;
mod security;
mod ui;
mod wizard;
use anyhow::Result;
use clap::{Parser, Subcommand};
use std::process::ExitCode;
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Parser)]
#[command(name = "garden")]
#[command(about = "ClawGarden CLI - Manage multi-bot/multi-agent Garden", long_about = None)]
#[command(version = VERSION)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
New {
#[arg(long)]
skip_deps: bool,
},
List {},
Status {
#[arg(short, long)]
name: Option<String>,
},
Doctor {
#[arg(short, long)]
name: Option<String>,
},
Remove {
name: String,
#[arg(short, long)]
delete_files: bool,
},
Up {
#[arg(short, long)]
name: Option<String>,
},
Down {
#[arg(short, long)]
name: Option<String>,
},
Restart {
#[arg(short, long)]
name: Option<String>,
},
Logs {
#[arg(short, long)]
name: Option<String>,
#[arg(short, long)]
follow: bool,
#[arg(short, long, default_value = "100")]
lines: usize,
},
Exec {
#[arg(short, long)]
name: Option<String>,
command: Vec<String>,
},
Shell {
#[arg(short, long)]
name: Option<String>,
#[arg(short, long, default_value = "bash")]
shell: String,
},
Deps {},
Config {
#[arg(short, long)]
name: Option<String>,
},
Version {},
Init {
#[arg(long)]
force: bool,
},
}
fn main() -> ExitCode {
let cli = Cli::parse();
let result = match cli.command {
Commands::New { skip_deps } => cmd_new(skip_deps),
Commands::List {} => cmd_list(),
Commands::Status { name } => cmd_status(name.as_deref()),
Commands::Doctor { name } => cmd_doctor(name.as_deref()),
Commands::Remove { name, delete_files } => cmd_remove(&name, delete_files),
Commands::Up { name } => cmd_up(name.as_deref()),
Commands::Down { name } => cmd_down(name.as_deref()),
Commands::Restart { name } => cmd_restart(name.as_deref()),
Commands::Logs {
name,
follow,
lines,
} => cmd_logs(name.as_deref(), follow, lines),
Commands::Exec { name, command } => cmd_exec(name.as_deref(), &command),
Commands::Shell { name, shell } => cmd_shell(name.as_deref(), &shell),
Commands::Deps {} => cmd_deps(),
Commands::Config { name } => cmd_config(name.as_deref()),
Commands::Version {} => cmd_version(),
Commands::Init { force } => cmd_init(force),
};
match result {
Ok(exit_code) => exit_code,
Err(e) => {
eprintln!("\n \x1b[38;5;203m✘\x1b[0m {}", e);
ExitCode::FAILURE
}
}
}
fn cmd_new(skip_deps: bool) -> Result<ExitCode> {
if !skip_deps {
deps::check()?;
wizard::run_wizard_skip_deps()?;
} else {
wizard::run_wizard()?;
}
Ok(ExitCode::SUCCESS)
}
fn cmd_list() -> Result<ExitCode> {
garden::list_gardens()?;
Ok(ExitCode::SUCCESS)
}
fn cmd_status(name: Option<&str>) -> Result<ExitCode> {
let name = resolve_garden_name(name)?;
println!();
ui::section_header_no_step("🔍", &format!("Garden Status · {}", name));
let container = compose::inspect_container(&name)?;
let health_icon = match container.healthy {
Some(true) => "\x1b[38;5;77m● healthy\x1b[0m".to_string(),
Some(false) => "\x1b[38;5;203m● unhealthy\x1b[0m".to_string(),
None => "\x1b[2m○ no healthcheck\x1b[0m".to_string(),
};
let state_icon = if container.running {
"\x1b[38;5;77m▶ running\x1b[0m"
} else {
"\x1b[38;5;203m■ stopped\x1b[0m"
};
let mut rows = vec![
(
"🐳".to_string(),
"Container".to_string(),
container.name.clone(),
),
(
"📡".to_string(),
"State".to_string(),
format!("{}", state_icon),
),
("💊".to_string(), "Health".to_string(), health_icon),
(
"📋".to_string(),
"Status".to_string(),
container.status.clone(),
),
];
if !container.image.is_empty() {
rows.push((
"🖼️ ".to_string(),
"Image".to_string(),
container.image.clone(),
));
}
if let Some(ref started) = container.started_at {
if !started.is_empty() {
rows.push(("🕐".to_string(), "Started".to_string(), started.clone()));
}
}
if container.running {
if let Some(stats) = compose::container_stats(&name) {
rows.push(("📊".to_string(), "Resources".to_string(), stats));
}
}
let registry = garden::load_gardens()?;
let compose_file = registry.compose_file(&name);
let env_file = registry.env_file(&name);
rows.push((
"📄".to_string(),
"Compose".to_string(),
if compose_file.exists() {
"✓ present".to_string()
} else {
"✗ missing".to_string()
},
));
rows.push((
"🔐".to_string(),
".env".to_string(),
if env_file.exists() {
"✓ present".to_string()
} else {
"✗ missing".to_string()
},
));
ui::summary_box(&format!("🌱 {}", name), &rows);
Ok(ExitCode::SUCCESS)
}
fn cmd_doctor(name: Option<&str>) -> Result<ExitCode> {
println!();
ui::section_header_no_step("🩺", "Garden Doctor");
let mut all_ok = true;
println!(" {} Checking dependencies...", "\x1b[1m\x1b[38;5;255m");
println!();
let dep_check = deps::DependencyCheck::check_all();
dep_check.print_report();
if !dep_check.is_ready() {
all_ok = false;
}
println!();
println!(" {} Checking garden registry...", "\x1b[1m\x1b[38;5;255m");
println!();
let registry = garden::load_gardens()?;
if registry.gardens.is_empty() {
ui::warn("No gardens registered. Run 'garden new' first.");
all_ok = false;
} else {
let garden_name = match name {
Some(n) => n.to_string(),
None => resolve_garden_name(None)?,
};
if !registry.exists(&garden_name) {
ui::error(&format!("Garden '{}' not found in registry.", garden_name));
all_ok = false;
} else {
ui::success(&format!("Garden '{}' found in registry.", garden_name));
println!();
println!(" {} Checking garden files...", "\x1b[1m\x1b[38;5;255m");
println!();
let compose_file = registry.compose_file(&garden_name);
let env_file = registry.env_file(&garden_name);
let auth_file = registry.garden_dir(&garden_name).join("pi-auth.json");
let reg_file = registry
.workspace_dir(&garden_name)
.join("agents/registry.json");
let files = [
("docker-compose.yml", compose_file.exists()),
(".env", env_file.exists()),
("pi-auth.json", auth_file.exists()),
("agents/registry.json", reg_file.exists()),
];
for (fname, exists) in &files {
if *exists {
ui::success(&format!("{:<25} present", fname));
} else {
ui::error(&format!("{:<25} missing!", fname));
all_ok = false;
}
}
println!();
println!(" {} Checking container...", "\x1b[1m\x1b[38;5;255m");
println!();
let container = compose::inspect_container(&garden_name)?;
if container.running {
ui::success(&format!("Container is running ({})", container.status));
match container.healthy {
Some(true) => ui::success("Healthcheck: healthy"),
Some(false) => {
ui::error("Healthcheck: unhealthy!");
all_ok = false;
}
None => ui::warn("No healthcheck defined"),
}
} else {
ui::warn(&format!("Container is not running ({})", container.status));
}
println!();
println!(" {} Checking configuration...", "\x1b[1m\x1b[38;5;255m");
println!();
if env_file.exists() {
let env_content = std::fs::read_to_string(&env_file)?;
let has_bot_token = env_content
.lines()
.any(|l| l.starts_with("TELEGRAM_BOT_TOKEN_"));
let has_group_id = env_content
.lines()
.any(|l| l.starts_with("TELEGRAM_GROUP_ID="));
if has_bot_token {
ui::success("Bot token(s) found in .env");
} else {
ui::error("No bot tokens in .env");
all_ok = false;
}
if has_group_id {
ui::success("Telegram group ID configured");
} else {
ui::warn("No Telegram group ID in .env");
}
}
}
}
println!();
ui::divider();
if all_ok {
ui::success("All checks passed — your garden is healthy! 🌿");
} else {
ui::warn("Some issues found. Fix them and run 'garden doctor' again.");
}
println!();
if all_ok {
Ok(ExitCode::SUCCESS)
} else {
Ok(ExitCode::FAILURE)
}
}
fn cmd_remove(name: &str, delete_files: bool) -> Result<ExitCode> {
garden::remove_garden(name, delete_files)?;
Ok(ExitCode::SUCCESS)
}
fn cmd_up(name: Option<&str>) -> Result<ExitCode> {
let name = resolve_garden_name(name)?;
compose::start_garden(&name)?;
Ok(ExitCode::SUCCESS)
}
fn cmd_down(name: Option<&str>) -> Result<ExitCode> {
let name = resolve_garden_name(name)?;
compose::stop_garden(&name)?;
Ok(ExitCode::SUCCESS)
}
fn cmd_restart(name: Option<&str>) -> Result<ExitCode> {
let name = resolve_garden_name(name)?;
compose::restart_garden(&name)?;
Ok(ExitCode::SUCCESS)
}
fn cmd_logs(name: Option<&str>, follow: bool, lines: usize) -> Result<ExitCode> {
let name = resolve_garden_name(name)?;
let status = compose::garden_logs(&name, follow, lines)?;
Ok(ExitCode::from(status.code().unwrap_or(1) as u8))
}
fn cmd_exec(name: Option<&str>, command: &[String]) -> Result<ExitCode> {
let name = resolve_garden_name(name)?;
let status = compose::garden_exec(&name, command)?;
Ok(ExitCode::from(status.code().unwrap_or(1) as u8))
}
fn cmd_shell(name: Option<&str>, shell: &str) -> Result<ExitCode> {
let name = resolve_garden_name(name)?;
println!("\n Entering garden '{}' via {}...", name, shell);
println!(" {} Type 'exit' to leave.\n", "\x1b[2m");
let status = compose::garden_shell(&name, shell)?;
Ok(ExitCode::from(status.code().unwrap_or(1) as u8))
}
fn cmd_deps() -> Result<ExitCode> {
deps::check()?;
Ok(ExitCode::SUCCESS)
}
fn cmd_config(name: Option<&str>) -> Result<ExitCode> {
let name = resolve_garden_name(name)?;
config::run_config(&name)?;
Ok(ExitCode::SUCCESS)
}
fn cmd_version() -> Result<ExitCode> {
println!();
println!(
" {}C L A W G A R D E N{} {}v{}{}",
"\x1b[38;5;77m\x1b[1m", "\x1b[0m", "\x1b[2m", VERSION, "\x1b[0m",
);
println!();
println!(" {}Multi-Agent Garden CLI", "\x1b[2m");
println!(" {}https://github.com/a7garden/clawgarden", "\x1b[2m");
println!();
Ok(ExitCode::SUCCESS)
}
fn cmd_init(_force: bool) -> Result<ExitCode> {
ui::warn("'garden init' is deprecated. Use 'garden new' for interactive onboarding.");
println!();
wizard::run_wizard()?;
Ok(ExitCode::SUCCESS)
}
fn resolve_garden_name(name: Option<&str>) -> Result<String> {
if let Some(n) = name {
return Ok(n.to_string());
}
let registry = garden::load_gardens()?;
if registry.gardens.is_empty() {
anyhow::bail!("No gardens found. Run 'garden new' to create one.");
}
if registry.gardens.len() == 1 {
return Ok(registry.gardens[0].name.clone());
}
anyhow::bail!("Multiple gardens found. Specify --name <garden>.");
}