use anyhow::{Context, Result};
use std::path::PathBuf;
use crate::garden::load_gardens;
use crate::providers::ProviderManifest;
use clawgarden_proto::config::{generate_default_toml, SecretsToml, TomlConfig};
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct BotConfig {
pub name: String,
pub role: String,
pub token: String,
pub username: String,
pub priority: u32,
pub enabled: bool,
}
impl Default for BotConfig {
fn default() -> Self {
Self {
name: String::new(),
role: "assistant".to_string(),
token: String::new(),
username: String::new(),
priority: 100,
enabled: true,
}
}
}
impl BotConfig {
pub fn env_id(&self) -> String {
if !self.username.is_empty() {
self.username.to_uppercase()
} else {
let slug: String = self
.name
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect();
if slug.is_empty() {
use std::hash::{Hash, Hasher};
use std::collections::hash_map::DefaultHasher;
let mut hasher = DefaultHasher::new();
self.name.hash(&mut hasher);
format!("BOT{:x}", hasher.finish())
} else {
slug.to_uppercase()
}
}
}
}
#[derive(Debug, Clone)]
pub struct ProviderEntry {
pub provider: Arc<ProviderManifest>,
pub auth_method_id: String,
pub api_key: String,
pub model: String,
}
#[derive(Debug, Clone)]
pub struct GardenConfig {
pub name: String,
pub telegram_group_id: String,
pub bots: Vec<BotConfig>,
pub providers: Vec<ProviderEntry>,
}
#[derive(Debug, Clone)]
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.env_id());
bot_envs.push_str(&format!(" {}: \"${{{}}}\"\n", key, key));
}
let mut provider_envs = String::new();
for entry in &self.providers {
let key = format!("{}_API_KEY", entry.provider.id.to_uppercase());
provider_envs.push_str(&format!(" {}: \"${{{}}}\"\n", key, key));
let model_key = format!("{}_MODEL", entry.provider.id.to_uppercase());
provider_envs.push_str(&format!(" {}: \"${{{}}}\"\n", model_key, model_key));
}
format!(
r#"services:
garden:
container_name: {container_name}
build:
context: .
dockerfile: Dockerfile
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
# LLM backend (defaults to provider-specific env vars for backwards compat)
LLM_API_BASE: "${{LLM_API_BASE:-}}"
LLM_API_KEY: "${{LLM_API_KEY:-}}"
LLM_MODEL: "${{LLM_MODEL:-}}"
# Turn-based group chat settings
ROUND_MAX_TURNS: "${{ROUND_MAX_TURNS:-3}}"
TURN_DECISION_WINDOW_MS: "${{TURN_DECISION_WINDOW_MS:-2000}}"
TURN_RESPONSE_TIMEOUT_MS: "${{TURN_RESPONSE_TIMEOUT_MS:-10000}}"
SELECTION_CONFIDENCE_THRESHOLD: "${{SELECTION_CONFIDENCE_THRESHOLD:-0.3}}"
# Team members for agent awareness
TEAM_MEMBERS: "${{TEAM_MEMBERS:-}}"
# Metrics (optional)
METRICS_PORT: 8080
volumes:
- ./workspace:/workspace
- ./clawgarden.toml:/app/clawgarden.toml:ro
- ./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),
bot_envs = bot_envs,
telegram_group_id = self.telegram_group_id,
ingress_bot = self
.bots
.first()
.map(|b| if b.username.is_empty() { b.name.as_str() } else { b.username.as_str() })
.unwrap_or("ingress"),
provider_envs = provider_envs,
network_name = format!("garden-net-{}", self.name)
)
}
pub fn generate_env(&self) -> String {
let secrets = self.generate_secrets_toml();
let parsed: clawgarden_proto::config::SecretsToml =
toml::from_str(&secrets).unwrap_or_default();
parsed.to_env_content()
}
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 dockerfile_path = garden_dir.join("Dockerfile");
std::fs::write(&dockerfile_path, self.generate_dockerfile())
.context("Failed to write Dockerfile")?;
let entrypoint_path = garden_dir.join("entrypoint.sh");
std::fs::write(&entrypoint_path, self.generate_entrypoint())
.context("Failed to write entrypoint.sh")?;
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")?;
let config_path = garden_dir.join("clawgarden.toml");
std::fs::write(&config_path, self.generate_clawgarden_toml())
.context("Failed to write clawgarden.toml")?;
let secrets_path = garden_dir.join(".secrets.toml");
std::fs::write(&secrets_path, self.generate_secrets_toml())
.context("Failed to write .secrets.toml")?;
Ok((compose_path, env_path))
}
pub fn generate_clawgarden_toml(&self) -> String {
let mut config = TomlConfig::default();
config.telegram.group_id = if self.telegram_group_id.is_empty() {
None
} else {
Some(self.telegram_group_id.clone())
};
config.telegram.ingress_bot = Some(
self.bots
.first()
.map(|b| if b.username.is_empty() { b.name.as_str() } else { b.username.as_str() })
.unwrap_or("alex")
.to_string(),
);
toml::to_string_pretty(&config).unwrap_or_else(|_| generate_default_toml())
}
pub fn generate_secrets_toml(&self) -> String {
let mut secrets = SecretsToml::default();
for bot in &self.bots {
let key = format!("TELEGRAM_BOT_TOKEN_{}", bot.env_id());
secrets.set(key, bot.token.clone());
}
for entry in &self.providers {
let key = format!("{}_API_KEY", entry.provider.id.to_uppercase());
secrets.set(key, entry.api_key.clone());
let model_key = format!("{}_MODEL", entry.provider.id.to_uppercase());
secrets.set(model_key, entry.model.clone());
}
if !self.telegram_group_id.is_empty() {
secrets.set(
"TELEGRAM_GROUP_ID".to_string(),
self.telegram_group_id.clone(),
);
}
toml::to_string_pretty(&secrets).unwrap_or_else(|_| "[secrets]\n".to_string())
}
pub fn generate_dockerfile(&self) -> String {
indoc::indoc!(r#"
FROM rust:slim AS builder
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
ARG CLAWGARDEN_VERSION=0.7.0
RUN cargo install clawgarden-bus clawgarden-agent --version ${CLAWGARDEN_VERSION}
FROM debian:trixie-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl sqlite3 bash nodejs npm \
&& rm -rf /var/lib/apt/lists/*
ARG PI_VERSION=latest
# Note: npm supports 'latest' unlike cargo
RUN npm install -g pi-coding-agent@${PI_VERSION}
COPY --from=builder /usr/local/cargo/bin/clawgarden-bus /app/clawgarden-bus
COPY --from=builder /usr/local/cargo/bin/clawgarden-agent /app/clawgarden-agent
RUN groupadd -g 10001 garden && \
useradd -r -u 10001 -g garden -s /bin/bash -d /app garden
RUN mkdir -p /workspace/agents /workspace/data /workspace/logs /workspace/skills && \
chown -R garden:garden /app /workspace
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
USER garden
ENV GARDEN_WORKSPACE=/workspace
ENV RUST_LOG=info
ENV CLAWGARDEN_SOCKET=/tmp/clawgarden.sock
ENV CLAWGARDEN_REGISTRY=/workspace/agents/registry.json
ENV CLAWGARDEN_DB=/workspace/data/clawgarden.db
EXPOSE 8080
ENTRYPOINT ["/app/entrypoint.sh"]
"#).trim_start().to_string()
}
pub fn generate_entrypoint(&self) -> String {
indoc::indoc!(r#"
#!/bin/bash
set -e
REGISTRY=/workspace/agents/registry.json
if [ ! -f "$REGISTRY" ]; then
echo "ERROR: $REGISTRY not found"; exit 1
fi
MEMBERS=""
# Extract (name token_env) pairs from registry.json using node (installed in image)
PAIRS=$(node -e "
const reg = JSON.parse(require('fs').readFileSync('$REGISTRY','utf8'));
reg.agents.forEach(a => console.log(a.name + '\t' + a.bot.token_env));
")
echo "Loaded agents from registry"
while IFS=$'\t' read -r name token_env; do
[ -z "$name" ] && continue
token=$(printenv "$token_env" 2>/dev/null || true)
if [ -z "$token" ]; then
echo "WARN: No token for agent '$name' (expected env $token_env)"
continue
fi
# Resolve Telegram username via getMe
USERNAME=$(curl -sf "https://api.telegram.org/bot${token}/getMe" \
| node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{console.log(JSON.parse(d).result.username)}catch{}})" 2>/dev/null || true)
if [ -n "$USERNAME" ]; then
MEMBERS="${MEMBERS}${name}:@${USERNAME},"
# Update registry.json with discovered username
node -e "
const fs=require('fs');
const r=JSON.parse(fs.readFileSync('$REGISTRY','utf8'));
r.agents.filter(a=>a.name==='$name').forEach(a=>a.bot.username='$USERNAME');
fs.writeFileSync('$REGISTRY',JSON.stringify(r,null,2));
" 2>/dev/null || true
echo " $name -> @${USERNAME}"
else
MEMBERS="${MEMBERS}${name},"
echo " WARN: Could not resolve username for '$name'"
fi
# Start agent process
export TELEGRAM_BOT_USERNAME="$USERNAME"
export TEAM_MEMBERS="$MEMBERS"
/app/clawgarden-agent --agent-name "$name" &
echo " Agent '$name' started (@${USERNAME:-?})"
unset TELEGRAM_BOT_USERNAME
done <<< "$PAIRS"
echo "Team: $MEMBERS"
# Start bus (foreground)
exec /app/clawgarden-bus
"#).trim_start().to_string()
}
pub fn generate_auth_json(&self) -> String {
use crate::providers::PiAuthJson;
let provider_keys: Vec<(String, String)> = self
.providers
.iter()
.map(|e| (e.provider.id.clone(), e.api_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 mut agent = serde_json::json!({
"name": bot.name,
"role": bot.role,
"enabled": bot.enabled,
"priority": bot.priority,
"bot": {
"token_env": format!("TELEGRAM_BOT_TOKEN_{}", bot.env_id())
}
});
if !bot.username.is_empty() {
agent["bot"]["username"] = serde_json::json!(bot.username);
}
agents.push(agent);
}
let provider_entries: Vec<serde_json::Value> = self
.providers
.iter()
.map(|entry| {
serde_json::json!({
"id": entry.provider.id,
"auth_method": entry.auth_method_id,
"model": entry.model,
})
})
.collect();
let registry = serde_json::json!({
"version": 1,
"telegram": {
"group_id": self.telegram_group_id,
"ingress_bot": self.bots.first().map(|b| if b.username.is_empty() { b.name.as_str() } else { b.username.as_str() }).unwrap_or("ingress")
},
"agents": agents,
"providers": provider_entries
});
serde_json::to_string_pretty(®istry).unwrap()
}
}
pub fn start_garden(name: &str, build: bool) -> 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 up_args = if build {
vec!["up", "-d", "--build"]
} else {
vec!["up", "-d"]
};
let result = std::process::Command::new(docker_compose_bin())
.args(compose_args(compose_file.to_str().unwrap_or(""), &up_args))
.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(compose_args(compose_file.to_str().unwrap_or(""), &["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, false)
}
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
}
pub 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(())
}
pub 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(())
}