use anyhow::{Context, Result};
use std::path::PathBuf;
use crate::garden::load_gardens;
use crate::providers::ProviderPlugin;
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct BotConfig {
pub name: String,
pub role: String,
pub token: String,
}
#[derive(Debug, Clone)]
pub struct GardenConfig {
pub name: String,
pub telegram_group_id: String,
pub bots: Vec<BotConfig>,
pub providers: Vec<(Arc<ProviderPlugin>, String, String)>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct ContainerStatus {
pub name: String,
pub running: bool,
pub status: String, pub healthy: Option<bool>, pub started_at: Option<String>,
pub image: String,
#[allow(dead_code)]
pub ports: Vec<String>,
}
impl GardenConfig {
pub fn generate_compose(&self) -> String {
let mut bot_envs = String::new();
for bot in self.bots.iter() {
let key = format!("TELEGRAM_BOT_TOKEN_{}", bot.name.to_uppercase());
bot_envs.push_str(&format!(" {}: \"${{{}}}\"\n", key, key));
}
let mut provider_envs = String::new();
for (provider, _auth_method, _api_key) in &self.providers {
let key = format!("{}_API_KEY", provider.id.to_uppercase());
provider_envs.push_str(&format!(" {}: \"${{{}}}\"\n", key, key));
}
format!(
r#"version: '3.8'
services:
garden:
container_name: {container_name}
build:
context: {context}
dockerfile: docker/Dockerfile
args:
PI_VERSION: "${{PI_VERSION:-latest}}"
restart: unless-stopped
environment:
# Telegram bot tokens
{bot_envs}
# Telegram group configuration
TELEGRAM_GROUP_ID: "{telegram_group_id}"
TELEGRAM_INGRESS_BOT: "{ingress_bot}"
# Provider API keys (for backwards compat)
{provider_envs}
# Garden configuration
GARDEN_WORKSPACE: /workspace
RUST_LOG: info
# Metrics (optional)
METRICS_PORT: 8080
volumes:
- ./workspace:/workspace
- ./pi-auth.json:/home/garden/.pi/agent/auth.json:ro
networks:
- garden-net
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
networks:
garden-net:
name: {network_name}
driver: bridge
"#,
container_name = format!("garden-{}", self.name),
context = crate::garden::GardensRegistry::gardens_dir()
.join(&self.name)
.to_string_lossy(),
bot_envs = bot_envs,
telegram_group_id = self.telegram_group_id,
ingress_bot = self
.bots
.first()
.map(|b| b.name.as_str())
.unwrap_or("ingress"),
provider_envs = provider_envs,
network_name = format!("garden-net-{}", self.name)
)
}
pub fn generate_env(&self) -> String {
let mut lines = vec![
"# ClawGarden Environment Configuration".to_string(),
"# DO NOT COMMIT THIS FILE TO VERSION CONTROL".to_string(),
"".to_string(),
];
lines.push("# Telegram Bot Tokens".to_string());
for bot in &self.bots {
let key = format!("TELEGRAM_BOT_TOKEN_{}", bot.name.to_uppercase());
lines.push(format!("{}={}", key, bot.token));
}
lines.push("".to_string());
lines.push("# AI Provider API Keys".to_string());
for (provider, _auth_method, api_key) in &self.providers {
let key = format!("{}_API_KEY", provider.id.to_uppercase());
lines.push(format!("{}={}", key, api_key));
}
lines.push("".to_string());
lines.push("# Configuration".to_string());
lines.push(format!("TELEGRAM_GROUP_ID={}", self.telegram_group_id));
lines.push("PI_VERSION=latest".to_string());
lines.push("RUST_LOG=info".to_string());
lines.join("\n")
}
pub fn write(&self) -> Result<(PathBuf, PathBuf)> {
let registry = load_gardens()?;
let garden_dir = registry.garden_dir(&self.name);
let workspace_dir = registry.workspace_dir(&self.name);
std::fs::create_dir_all(&garden_dir).context("Failed to create garden directory")?;
std::fs::create_dir_all(workspace_dir.join("agents"))
.context("Failed to create agents directory")?;
std::fs::create_dir_all(workspace_dir.join("data"))
.context("Failed to create data directory")?;
std::fs::create_dir_all(workspace_dir.join("logs"))
.context("Failed to create logs directory")?;
let compose_path = registry.compose_file(&self.name);
std::fs::write(&compose_path, self.generate_compose())
.context("Failed to write docker-compose.yml")?;
let env_path = registry.env_file(&self.name);
std::fs::write(&env_path, self.generate_env()).context("Failed to write .env file")?;
let auth_json_path = garden_dir.join("pi-auth.json");
let auth_json_content = self.generate_auth_json();
std::fs::write(&auth_json_path, auth_json_content)
.context("Failed to write pi-auth.json")?;
let registry_path = workspace_dir.join("agents/registry.json");
let registry_json = self.generate_registry_json();
std::fs::write(®istry_path, registry_json).context("Failed to write registry.json")?;
let allowlist_path = workspace_dir.join("agents/.allowlist");
std::fs::write(&allowlist_path, "pi-coding-agent\n")
.context("Failed to write allowlist")?;
Ok((compose_path, env_path))
}
pub fn generate_auth_json(&self) -> String {
use crate::providers::PiAuthJson;
let provider_keys: Vec<(String, String)> = self
.providers
.iter()
.map(|(p, _auth_method, key)| (p.id.clone(), key.clone()))
.collect();
let auth = PiAuthJson::new(&provider_keys);
auth.to_json_string().unwrap_or_else(|_| "{}".to_string())
}
pub fn generate_registry_json(&self) -> String {
let mut agents = vec![];
for bot in &self.bots {
let agent = serde_json::json!({
"name": bot.name,
"role": bot.role,
"enabled": true,
"priority": 50,
"bot": {
"token_env": format!("TELEGRAM_BOT_TOKEN_{}", bot.name.to_uppercase())
}
});
agents.push(agent);
}
let registry = serde_json::json!({
"version": 1,
"telegram": {
"group_id": self.telegram_group_id,
"ingress_bot": self.bots.first().map(|b| b.name.as_str()).unwrap_or("ingress")
},
"agents": agents
});
serde_json::to_string_pretty(®istry).unwrap()
}
}
pub fn start_garden(name: &str) -> Result<()> {
let registry = load_gardens()?;
if !registry.exists(name) {
anyhow::bail!("Garden '{}' not found. Run 'garden new' first.", name);
}
let compose_file = registry.compose_file(name);
if !compose_file.exists() {
anyhow::bail!("Garden '{}' not initialized. Run 'garden new' first.", name);
}
let env_file = registry.env_file(name);
load_env_file(&env_file)?;
crate::ui::spinner(&format!("Starting garden '{}'...", name), 600);
let result = std::process::Command::new(docker_compose_bin())
.args(["-f", compose_file.to_str().unwrap(), "up", "-d"])
.current_dir(registry.garden_dir(name))
.status();
match result {
Ok(status) if status.success() => {
println!();
crate::ui::success(&format!("Garden '{}' is blooming!", name));
println!(" Container: garden-{}", name);
Ok(())
}
Ok(status) => {
anyhow::bail!("Failed to start garden (exit code: {:?})", status.code());
}
Err(e) => {
anyhow::bail!("Failed to execute docker-compose: {}", e);
}
}
}
pub fn stop_garden(name: &str) -> Result<()> {
let registry = load_gardens()?;
if !registry.exists(name) {
anyhow::bail!("Garden '{}' not found", name);
}
let compose_file = registry.compose_file(name);
if !compose_file.exists() {
println!("Garden '{}' is not initialized.", name);
return Ok(());
}
crate::ui::spinner(&format!("Stopping garden '{}'...", name), 400);
let result = std::process::Command::new(docker_compose_bin())
.args(["-f", compose_file.to_str().unwrap(), "down"])
.current_dir(registry.garden_dir(name))
.status();
match result {
Ok(status) if status.success() => {
println!();
crate::ui::success(&format!("Garden '{}' put to rest.", name));
Ok(())
}
Ok(status) => {
anyhow::bail!("Failed to stop garden (exit code: {:?})", status.code());
}
Err(e) => {
anyhow::bail!("Failed to execute docker-compose: {}", e);
}
}
}
pub fn restart_garden(name: &str) -> Result<()> {
crate::ui::spinner(&format!("Restarting garden '{}'...", name), 500);
stop_garden_quiet(name)?;
start_garden(name)
}
pub fn garden_logs(name: &str, follow: bool, lines: usize) -> Result<std::process::ExitStatus> {
let registry = load_gardens()?;
if !registry.exists(name) {
anyhow::bail!("Garden '{}' not found", name);
}
let compose_file = registry.compose_file(name);
if !compose_file.exists() {
anyhow::bail!("Garden '{}' is not initialized", name);
}
let mut args = vec![
"-f".to_string(),
compose_file.to_string_lossy().to_string(),
"logs".to_string(),
];
if follow {
args.push("--follow".to_string());
}
args.push(format!("--tail={}", lines));
let result = std::process::Command::new(docker_compose_bin())
.args(&args)
.current_dir(registry.garden_dir(name))
.status();
Ok(result?)
}
pub fn garden_exec(name: &str, command: &[String]) -> Result<std::process::ExitStatus> {
let registry = load_gardens()?;
if !registry.exists(name) {
anyhow::bail!("Garden '{}' not found", name);
}
let compose_file = registry.compose_file(name);
if !compose_file.exists() {
anyhow::bail!("Garden '{}' is not initialized", name);
}
let mut args = vec![
"-f".to_string(),
compose_file.to_string_lossy().to_string(),
"exec".to_string(),
"-T".to_string(),
"garden".to_string(),
];
args.extend(command.to_vec());
let result = std::process::Command::new(docker_compose_bin())
.args(&args)
.current_dir(registry.garden_dir(name))
.status();
Ok(result?)
}
pub fn garden_shell(name: &str, shell: &str) -> Result<std::process::ExitStatus> {
let registry = load_gardens()?;
if !registry.exists(name) {
anyhow::bail!("Garden '{}' not found", name);
}
let compose_file = registry.compose_file(name);
if !compose_file.exists() {
anyhow::bail!("Garden '{}' is not initialized", name);
}
let args = vec![
"-f".to_string(),
compose_file.to_string_lossy().to_string(),
"exec".to_string(),
"garden".to_string(),
shell.to_string(),
];
let result = std::process::Command::new(docker_compose_bin())
.args(&args)
.current_dir(registry.garden_dir(name))
.status();
Ok(result?)
}
pub fn inspect_container(name: &str) -> Result<ContainerStatus> {
let container_name = format!("garden-{}", name);
let output = std::process::Command::new("docker")
.args([
"inspect",
"--format",
"{{.State.Running}}|{{.State.Status}}|{{.State.StartedAt}}|{{.Config.Image}}|{{range $p, $conf := .NetworkSettings.Ports}}{{if $conf}}{{$p}} {{end}}{{end}}",
&container_name,
])
.output();
match output {
Ok(out) if out.status.success() => {
let stdout = String::from_utf8_lossy(&out.stdout);
let parts: Vec<&str> = stdout.trim().split('|').collect();
let running = parts.first().map(|s| *s == "true").unwrap_or(false);
let status_raw = parts.get(1).map(|s| *s).unwrap_or("unknown");
let started_at = parts.get(2).map(|s| s.to_string());
let image = parts.get(3).map(|s| s.to_string()).unwrap_or_default();
let ports_str = parts.get(4).map(|s| s.to_string()).unwrap_or_default();
let ports: Vec<String> = if ports_str.is_empty() {
vec![]
} else {
ports_str
.split_whitespace()
.map(|s| s.to_string())
.collect()
};
let status = if running {
format!(
"Up{}",
started_at
.as_ref()
.map(|t| format!(" since {}", t))
.unwrap_or_default()
)
} else {
status_raw.to_string()
};
let healthy = check_health(&container_name);
Ok(ContainerStatus {
name: container_name,
running,
status,
healthy,
started_at,
image,
ports,
})
}
Ok(_) => {
Ok(ContainerStatus {
name: container_name,
running: false,
status: "not created".to_string(),
healthy: None,
started_at: None,
image: String::new(),
ports: vec![],
})
}
Err(e) => {
anyhow::bail!("Failed to inspect container: {}", e);
}
}
}
fn check_health(container_name: &str) -> Option<bool> {
let output = std::process::Command::new("docker")
.args([
"inspect",
"--format",
"{{.State.Health.Status}}",
container_name,
])
.output();
match output {
Ok(out) if out.status.success() => {
let stdout = String::from_utf8_lossy(&out.stdout);
match stdout.trim() {
"healthy" => Some(true),
"unhealthy" => Some(false),
"starting" => None, _ => None,
}
}
_ => None, }
}
pub fn container_stats(name: &str) -> Option<String> {
let container_name = format!("garden-{}", name);
let output = std::process::Command::new("docker")
.args([
"stats",
"--no-stream",
"--format",
"{{.CPUPerc}} CPU · {{.MemUsage}}",
&container_name,
])
.output()
.ok()?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout.lines().nth(1).unwrap_or("").trim();
if !line.is_empty() {
return Some(line.to_string());
}
}
None
}
pub fn docker_compose_bin() -> &'static str {
use once_cell::sync::Lazy;
static BIN: Lazy<&str> = Lazy::new(|| {
if let Ok(out) = std::process::Command::new("docker")
.args(["compose", "version"])
.output()
{
if out.status.success() {
return "docker";
}
}
"docker-compose"
});
*BIN
}
fn compose_args(file: &str, subcmd: &[&str]) -> Vec<String> {
let bin = docker_compose_bin();
let mut args = Vec::new();
if bin == "docker" {
args.push("compose".to_string());
}
args.push("-f".to_string());
args.push(file.to_string());
for s in subcmd {
args.push(s.to_string());
}
args
}
fn stop_garden_quiet(name: &str) -> Result<()> {
let registry = load_gardens()?;
if !registry.exists(name) {
return Ok(());
}
let compose_file = registry.compose_file(name);
if !compose_file.exists() {
return Ok(());
}
let _ = std::process::Command::new(docker_compose_bin())
.args(compose_args(compose_file.to_str().unwrap_or(""), &["down"]))
.current_dir(registry.garden_dir(name))
.output();
Ok(())
}
fn load_env_file(path: &PathBuf) -> Result<()> {
if !path.exists() {
return Ok(());
}
let content = std::fs::read_to_string(path).context("Failed to read .env file")?;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
let key = key.trim();
let value = value.trim();
std::env::set_var(key, value);
}
}
Ok(())
}