mod agent;
mod compose;
mod config;
mod deps;
mod garden;
mod provider;
mod providers;
mod secret;
mod security;
mod ui;
mod wizard;
use anyhow::Result;
use clap::{Parser, Subcommand};
use inquire::Confirm;
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 {
name: Option<String>,
},
Doctor {
name: Option<String>,
},
Remove {
name: Option<String>,
#[arg(short, long)]
delete_files: bool,
},
Up {
name: Option<String>,
#[arg(long)]
build: bool,
},
Down {
name: Option<String>,
},
Restart {
name: Option<String>,
},
Logs {
name: Option<String>,
#[arg(short, long)]
follow: bool,
#[arg(short, long, default_value = "100")]
lines: usize,
},
Exec {
name: Option<String>,
#[arg(trailing_var_arg = true)]
command: Vec<String>,
},
Shell {
name: Option<String>,
#[arg(short, long, default_value = "bash")]
shell: String,
},
Deps {},
Config {
name: Option<String>,
},
Agent {
#[command(subcommand)]
command: AgentCommands,
},
Provider {
#[command(subcommand)]
command: ProviderCommands,
},
Version {},
Secret {
#[command(subcommand)]
command: SecretCommands,
},
Update {
#[arg(long)]
cli_only: bool,
name: Option<String>,
},
}
#[derive(Subcommand)]
enum AgentCommands {
Add {
name: Option<String>,
},
List {
name: Option<String>,
},
Edit {
name: Option<String>,
},
Remove {
name: Option<String>,
agent: Option<String>,
},
}
#[derive(Subcommand)]
enum ProviderCommands {
Add {
name: Option<String>,
},
List {
name: Option<String>,
},
Edit {
name: Option<String>,
},
Remove {
name: Option<String>,
},
Refresh {
provider_id: Option<String>,
},
}
#[derive(Subcommand)]
enum SecretCommands {
List {
name: Option<String>,
},
Get {
key: String,
#[arg(short, long)]
name: Option<String>,
},
Set {
key: String,
value: Option<String>,
#[arg(short, long)]
name: Option<String>,
},
Remove {
key: String,
#[arg(short, long)]
name: Option<String>,
},
Migrate {
name: Option<String>,
},
Env {
name: Option<String>,
},
}
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.as_deref(), delete_files),
Commands::Up { name, build } => cmd_up(name.as_deref(), build),
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::Agent { command } => cmd_agent(command),
Commands::Provider { command } => cmd_provider(command),
Commands::Secret { command } => cmd_secret(command),
Commands::Version {} => cmd_version(),
Commands::Update { cli_only, name } => cmd_update(name.as_deref(), cli_only),
};
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!();
println!(" {} Checking security...", "\x1b[1m\x1b[38;5;255m");
println!();
let workspace_dir = registry.workspace_dir(&garden_name);
match security::scan_workspace_for_blocked_paths(&workspace_dir) {
Ok(violations) => {
if violations.is_empty() {
ui::success("No blocked paths in workspace");
} else {
for v in &violations {
ui::error(&format!("Blocked path: {:?}", v));
}
all_ok = false;
}
}
Err(e) => {
ui::warn(&format!("Could not scan workspace: {}", e));
}
}
let env_warnings = security::check_env_security(&env_file);
if env_warnings.is_empty() {
ui::success(".env file looks secure");
} else {
for w in &env_warnings {
ui::warn(w);
}
}
let allowlist_path = workspace_dir.join("agents/.allowlist");
match security::load_allowlist(&allowlist_path) {
Ok(allowed) => {
ui::success(&format!("Allowlist: {} agent(s) permitted", allowed.len()));
}
Err(_) => {
ui::warn("No allowlist file found");
}
}
}
}
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: Option<&str>, delete_files: bool) -> Result<ExitCode> {
let name = garden::resolve_garden_name(name)?;
println!();
ui::section_header_no_step("🗑️", &format!("Remove Garden · {}", name));
println!();
if delete_files {
ui::warn("This will remove the garden AND delete all files.");
} else {
ui::warn("This will remove the garden from the registry.");
ui::hint("Files will be kept. Use --delete-files to remove them too.");
}
println!();
let confirm = ui::retry_prompt(|| {
Confirm::new(&format!(" Remove garden '{}'?", name))
.with_default(false)
.prompt()
})?;
if !confirm {
ui::warn("Cancelled.");
return Ok(ExitCode::SUCCESS);
}
garden::remove_garden(&name, delete_files)?;
Ok(ExitCode::SUCCESS)
}
fn cmd_up(name: Option<&str>, build: bool) -> Result<ExitCode> {
let name = resolve_garden_name(name)?;
let should_build = build || needs_rebuild(&name);
if should_build && !build {
println!();
ui::hint(&format!(
"CLI v{} is newer than container — auto-rebuilding. Use --no-build to skip.",
VERSION
));
println!();
}
compose::start_garden(&name, should_build)?;
if should_build {
stamp_version(&name);
}
Ok(ExitCode::SUCCESS)
}
fn needs_rebuild(name: &str) -> bool {
let registry = match garden::load_gardens() {
Ok(r) => r,
Err(_) => return false,
};
let stamp_file = registry.garden_dir(name).join(".build-version");
if !stamp_file.exists() {
let _ = std::fs::write(&stamp_file, VERSION);
return true;
}
let stamped = std::fs::read_to_string(&stamp_file).unwrap_or_default();
let stamped = stamped.trim();
stamped != VERSION
}
fn stamp_version(name: &str) {
let registry = match garden::load_gardens() {
Ok(r) => r,
Err(_) => return,
};
let stamp_file = registry.garden_dir(name).join(".build-version");
let _ = std::fs::write(&stamp_file, VERSION);
}
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_agent(command: AgentCommands) -> Result<ExitCode> {
match command {
AgentCommands::Add { name } => {
agent::cmd_add(name.as_deref())?;
}
AgentCommands::List { name } => {
agent::cmd_list(name.as_deref())?;
}
AgentCommands::Edit { name } => {
agent::cmd_edit(name.as_deref())?;
}
AgentCommands::Remove { name, agent } => {
agent::cmd_remove(name.as_deref(), agent.as_deref())?;
}
}
Ok(ExitCode::SUCCESS)
}
fn cmd_provider(command: ProviderCommands) -> Result<ExitCode> {
match command {
ProviderCommands::Add { name } => {
provider::cmd_add(name.as_deref())?;
}
ProviderCommands::List { name } => {
provider::cmd_list(name.as_deref())?;
}
ProviderCommands::Edit { name } => {
provider::cmd_edit(name.as_deref())?;
}
ProviderCommands::Remove { name } => {
provider::cmd_remove(name.as_deref())?;
}
ProviderCommands::Refresh { provider_id } => {
provider::cmd_refresh(provider_id.as_deref())?;
}
}
Ok(ExitCode::SUCCESS)
}
fn cmd_secret(command: SecretCommands) -> Result<ExitCode> {
match command {
SecretCommands::List { name } => {
secret::cmd_list(name.as_deref())?;
}
SecretCommands::Get { key, name } => {
secret::cmd_get(name.as_deref(), &key)?;
}
SecretCommands::Set { key, value, name } => {
secret::cmd_set(name.as_deref(), &key, value.as_deref())?;
}
SecretCommands::Remove { key, name } => {
secret::cmd_remove(name.as_deref(), &key)?;
}
SecretCommands::Migrate { name } => {
secret::cmd_migrate(name.as_deref())?;
}
SecretCommands::Env { name } => {
secret::cmd_env(name.as_deref())?;
}
}
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_update(name: Option<&str>, cli_only: bool) -> Result<ExitCode> {
println!();
println!(
" {}C L A W G A R D E N U P D A T E{}",
"\x1b[38;5;77m\x1b[1m", "\x1b[0m",
);
println!();
let before_version = VERSION.to_string();
ui::spinner("Checking for CLI updates...", 300);
let has_cargo = std::process::Command::new("cargo")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !has_cargo {
ui::warn("cargo not found — cannot auto-update CLI.");
ui::hint("Install manually: cargo install clawgarden-cli");
} else {
ui::spinner("Updating clawgarden-cli via cargo...", 600);
let install_output = std::process::Command::new("cargo")
.args(["install", "clawgarden-cli"])
.output();
match install_output {
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
if output.status.success() {
ui::success("CLI updated to latest version.");
} else if stderr.contains("already installed")
|| stderr.contains("up to date")
|| stderr.contains("is already")
{
ui::success(&format!("CLI is already up to date (v{})", before_version));
} else {
ui::warn(&format!(
"CLI update: {}",
stderr.lines().last().unwrap_or("unknown error")
));
ui::hint("Try: cargo install clawgarden-cli --force");
}
}
Err(e) => {
ui::warn(&format!("Failed to run cargo install: {}", e));
}
}
}
if cli_only {
println!();
ui::hint("Run 'garden update' (without --cli-only) to rebuild containers.");
return Ok(ExitCode::SUCCESS);
}
let registry = garden::load_gardens()?;
let gardens_to_update: Vec<String> = if let Some(n) = name {
if !registry.exists(n) {
anyhow::bail!("Garden '{}' not found.", n);
}
vec![n.to_string()]
} else if registry.gardens.is_empty() {
anyhow::bail!("No gardens found. Run 'garden new' first.");
} else {
registry.gardens.iter().map(|g| g.name.clone()).collect()
};
println!();
println!(
" {}Rebuilding {} garden(s)...{}",
"\x1b[1m",
gardens_to_update.len(),
"\x1b[0m"
);
println!();
for garden_name in &gardens_to_update {
match compose::start_garden(garden_name, true) {
Ok(_) => {
stamp_version(garden_name);
}
Err(e) => {
ui::warn(&format!("Garden '{}': {}", garden_name, e));
}
}
}
println!();
ui::success("Update complete!");
println!();
Ok(ExitCode::SUCCESS)
}
fn resolve_garden_name(name: Option<&str>) -> Result<String> {
garden::resolve_garden_name(name)
}