use anyhow::{Context, Result};
use log::debug;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AgentModels {
pub claude: Option<String>,
pub codex: Option<String>,
pub gemini: Option<String>,
pub copilot: Option<String>,
pub ollama: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OllamaConfig {
pub model: Option<String>,
pub size: Option<String>,
pub size_small: Option<String>,
pub size_medium: Option<String>,
pub size_large: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Defaults {
pub auto_approve: Option<bool>,
pub model: Option<String>,
pub provider: Option<String>,
pub max_turns: Option<u32>,
pub system_prompt: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AutoConfig {
pub provider: Option<String>,
pub model: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ListenConfig {
pub format: Option<String>,
pub timestamp_format: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub defaults: Defaults,
#[serde(default)]
pub models: AgentModels,
#[serde(default)]
pub auto: AutoConfig,
#[serde(default)]
pub ollama: OllamaConfig,
#[serde(default)]
pub listen: ListenConfig,
}
impl Config {
pub fn load(root: Option<&str>) -> Result<Self> {
let path = Self::config_path(root);
debug!("Loading config from {}", path.display());
if !path.exists() {
debug!("Config file not found, using defaults");
return Ok(Self::default());
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read config: {}", path.display()))?;
let config: Config = toml::from_str(&content)
.with_context(|| format!("Failed to parse config: {}", path.display()))?;
debug!("Config loaded successfully from {}", path.display());
Ok(config)
}
pub fn save(&self, root: Option<&str>) -> Result<()> {
let path = Self::config_path(root);
debug!("Saving config to {}", path.display());
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
let content = toml::to_string_pretty(self).context("Failed to serialize config")?;
std::fs::write(&path, content)
.with_context(|| format!("Failed to write config: {}", path.display()))?;
debug!("Config saved to {}", path.display());
Ok(())
}
pub fn init(root: Option<&str>) -> Result<bool> {
let path = Self::config_path(root);
if path.exists() {
debug!("Config already exists at {}", path.display());
return Ok(false);
}
debug!("Initializing new config at {}", path.display());
let config = Self::default_with_comments();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
std::fs::write(&path, config)
.with_context(|| format!("Failed to write config: {}", path.display()))?;
Ok(true)
}
fn find_git_root(start_dir: &Path) -> Option<PathBuf> {
let output = Command::new("git")
.arg("rev-parse")
.arg("--show-toplevel")
.current_dir(start_dir)
.output()
.ok()?;
if output.status.success() {
let root = String::from_utf8(output.stdout).ok()?;
Some(PathBuf::from(root.trim()))
} else {
None
}
}
pub fn global_base_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".zag")
}
pub fn sanitize_path(path: &str) -> String {
path.trim_start_matches('/').replace('/', "-")
}
fn resolve_project_dir(root: Option<&str>) -> PathBuf {
let base = Self::global_base_dir();
if let Some(r) = root {
let sanitized = Self::sanitize_path(r);
return base.join("projects").join(sanitized);
}
let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
if let Some(git_root) = Self::find_git_root(¤t_dir) {
let sanitized = Self::sanitize_path(&git_root.to_string_lossy());
return base.join("projects").join(sanitized);
}
base
}
pub fn config_path(root: Option<&str>) -> PathBuf {
Self::resolve_project_dir(root).join("zag.toml")
}
#[allow(dead_code)]
pub fn agent_dir(root: Option<&str>) -> PathBuf {
Self::resolve_project_dir(root)
}
pub fn global_logs_dir() -> PathBuf {
Self::global_base_dir().join("logs")
}
pub fn get_model(&self, agent: &str) -> Option<&str> {
let agent_model = match agent {
"claude" => self.models.claude.as_deref(),
"codex" => self.models.codex.as_deref(),
"gemini" => self.models.gemini.as_deref(),
"copilot" => self.models.copilot.as_deref(),
"ollama" => self.models.ollama.as_deref(),
_ => None,
};
agent_model.or(self.defaults.model.as_deref())
}
#[allow(dead_code)]
pub fn default_model(&self) -> Option<&str> {
self.defaults.model.as_deref()
}
pub fn ollama_model(&self) -> &str {
self.ollama.model.as_deref().unwrap_or("qwen3.5")
}
pub fn ollama_size(&self) -> &str {
self.ollama.size.as_deref().unwrap_or("9b")
}
pub fn ollama_size_for<'a>(&'a self, size: &'a str) -> &'a str {
match size {
"small" | "s" => self.ollama.size_small.as_deref().unwrap_or("2b"),
"medium" | "m" | "default" => self.ollama.size_medium.as_deref().unwrap_or("9b"),
"large" | "l" | "max" => self.ollama.size_large.as_deref().unwrap_or("35b"),
_ => size, }
}
pub fn auto_approve(&self) -> bool {
self.defaults.auto_approve.unwrap_or(false)
}
pub fn max_turns(&self) -> Option<u32> {
self.defaults.max_turns
}
pub fn system_prompt(&self) -> Option<&str> {
self.defaults.system_prompt.as_deref()
}
pub fn provider(&self) -> Option<&str> {
self.defaults.provider.as_deref()
}
pub fn auto_provider(&self) -> Option<&str> {
self.auto.provider.as_deref()
}
pub fn auto_model(&self) -> Option<&str> {
self.auto.model.as_deref()
}
pub fn listen_format(&self) -> Option<&str> {
self.listen.format.as_deref()
}
pub fn listen_timestamp_format(&self) -> &str {
self.listen
.timestamp_format
.as_deref()
.unwrap_or("%H:%M:%S")
}
#[cfg(not(test))]
pub const VALID_PROVIDERS: &'static [&'static str] =
&["claude", "codex", "gemini", "copilot", "ollama", "auto"];
#[cfg(test)]
pub const VALID_PROVIDERS: &'static [&'static str] = &[
"claude", "codex", "gemini", "copilot", "ollama", "auto", "mock",
];
pub const VALID_KEYS: &'static [&'static str] = &[
"provider",
"model",
"auto_approve",
"max_turns",
"system_prompt",
"model.claude",
"model.codex",
"model.gemini",
"model.copilot",
"model.ollama",
"auto.provider",
"auto.model",
"ollama.model",
"ollama.size",
"ollama.size_small",
"ollama.size_medium",
"ollama.size_large",
"listen.format",
"listen.timestamp_format",
];
pub fn get_value(&self, key: &str) -> Option<String> {
match key {
"provider" => self.defaults.provider.clone(),
"model" => self.defaults.model.clone(),
"auto_approve" => self.defaults.auto_approve.map(|v| v.to_string()),
"max_turns" => self.defaults.max_turns.map(|v| v.to_string()),
"system_prompt" => self.defaults.system_prompt.clone(),
"model.claude" => self.models.claude.clone(),
"model.codex" => self.models.codex.clone(),
"model.gemini" => self.models.gemini.clone(),
"model.copilot" => self.models.copilot.clone(),
"model.ollama" => self.models.ollama.clone(),
"auto.provider" => self.auto.provider.clone(),
"auto.model" => self.auto.model.clone(),
"ollama.model" => self.ollama.model.clone(),
"ollama.size" => self.ollama.size.clone(),
"ollama.size_small" => self.ollama.size_small.clone(),
"ollama.size_medium" => self.ollama.size_medium.clone(),
"ollama.size_large" => self.ollama.size_large.clone(),
"listen.format" => self.listen.format.clone(),
"listen.timestamp_format" => self.listen.timestamp_format.clone(),
_ => None,
}
}
pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
debug!("Setting config: {} = {}", key, value);
match key {
"provider" => {
let v = value.to_lowercase();
if !Self::VALID_PROVIDERS.contains(&v.as_str()) {
anyhow::bail!(
"Invalid provider '{}'. Available: {}",
value,
Self::VALID_PROVIDERS.join(", ")
);
}
self.defaults.provider = Some(v);
}
"model" => {
self.defaults.model = Some(value.to_string());
}
"max_turns" => {
let turns: u32 = value.parse().map_err(|_| {
anyhow::anyhow!(
"Invalid value '{}' for max_turns. Must be a positive integer.",
value
)
})?;
self.defaults.max_turns = Some(turns);
}
"system_prompt" => {
self.defaults.system_prompt = Some(value.to_string());
}
"auto_approve" => match value.to_lowercase().as_str() {
"true" | "1" | "yes" => self.defaults.auto_approve = Some(true),
"false" | "0" | "no" => self.defaults.auto_approve = Some(false),
_ => anyhow::bail!(
"Invalid value '{}' for auto_approve. Use true or false.",
value
),
},
"model.claude" => self.models.claude = Some(value.to_string()),
"model.codex" => self.models.codex = Some(value.to_string()),
"model.gemini" => self.models.gemini = Some(value.to_string()),
"model.copilot" => self.models.copilot = Some(value.to_string()),
"model.ollama" => self.models.ollama = Some(value.to_string()),
"auto.provider" => self.auto.provider = Some(value.to_string()),
"auto.model" => self.auto.model = Some(value.to_string()),
"ollama.model" => self.ollama.model = Some(value.to_string()),
"ollama.size" => self.ollama.size = Some(value.to_string()),
"ollama.size_small" => self.ollama.size_small = Some(value.to_string()),
"ollama.size_medium" => self.ollama.size_medium = Some(value.to_string()),
"ollama.size_large" => self.ollama.size_large = Some(value.to_string()),
"listen.format" => {
let v = value.to_lowercase();
if !["text", "json", "rich-text"].contains(&v.as_str()) {
anyhow::bail!(
"Invalid listen format '{}'. Available: text, json, rich-text",
value
);
}
self.listen.format = Some(v);
}
"listen.timestamp_format" => {
self.listen.timestamp_format = Some(value.to_string());
}
_ => anyhow::bail!(
"Unknown config key '{}'. Available: provider, model, auto_approve, max_turns, system_prompt, model.claude, model.codex, model.gemini, model.copilot, model.ollama, auto.provider, auto.model, ollama.model, ollama.size, ollama.size_small, ollama.size_medium, ollama.size_large, listen.format, listen.timestamp_format",
key
),
}
Ok(())
}
pub fn unset_value(&mut self, key: &str) -> Result<()> {
debug!("Unsetting config: {}", key);
match key {
"provider" => self.defaults.provider = None,
"model" => self.defaults.model = None,
"auto_approve" => self.defaults.auto_approve = None,
"max_turns" => self.defaults.max_turns = None,
"system_prompt" => self.defaults.system_prompt = None,
"model.claude" => self.models.claude = None,
"model.codex" => self.models.codex = None,
"model.gemini" => self.models.gemini = None,
"model.copilot" => self.models.copilot = None,
"model.ollama" => self.models.ollama = None,
"auto.provider" => self.auto.provider = None,
"auto.model" => self.auto.model = None,
"ollama.model" => self.ollama.model = None,
"ollama.size" => self.ollama.size = None,
"ollama.size_small" => self.ollama.size_small = None,
"ollama.size_medium" => self.ollama.size_medium = None,
"ollama.size_large" => self.ollama.size_large = None,
"listen.format" => self.listen.format = None,
"listen.timestamp_format" => self.listen.timestamp_format = None,
_ => anyhow::bail!(
"Unknown config key '{}'. Run 'zag config list' to see available keys.",
key
),
}
Ok(())
}
fn default_with_comments() -> String {
r#"# Zag CLI Configuration
# This file configures default behavior for the zag CLI.
# Settings here can be overridden by command-line flags.
[defaults]
# Default provider (claude, codex, gemini, copilot)
# provider = "claude"
# Auto-approve all actions (skip permission prompts)
# auto_approve = false
# Default model size for all agents (small, medium, large)
# Can be overridden per-agent in [models] section
model = "medium"
# Default maximum number of agentic turns
# max_turns = 10
# Default system prompt for all agents
# system_prompt = ""
[models]
# Default models for each agent (overrides defaults.model)
# Use size aliases (small, medium, large) or specific model names
# claude = "opus"
# codex = "gpt-5.4"
# gemini = "auto"
# copilot = "claude-sonnet-4.6"
[auto]
# Settings for auto provider/model selection (-p auto / -m auto)
# provider = "claude"
# model = "haiku"
[ollama]
# Ollama-specific settings
# model = "qwen3.5"
# size = "9b"
# size_small = "2b"
# size_medium = "9b"
# size_large = "35b"
[listen]
# Default output format for listen command: "text", "json", or "rich-text"
# format = "text"
# Timestamp format for --timestamps flag (strftime-style, default: "%H:%M:%S")
# timestamp_format = "%H:%M:%S"
"#
.to_string()
}
}
pub fn resolve_provider(flag: Option<&str>, root: Option<&str>) -> anyhow::Result<String> {
if let Some(p) = flag {
let p = p.to_lowercase();
if !Config::VALID_PROVIDERS.contains(&p.as_str()) {
anyhow::bail!(
"Invalid provider '{}'. Available: {}",
p,
Config::VALID_PROVIDERS.join(", ")
);
}
return Ok(p);
}
let config = Config::load(root).unwrap_or_default();
if let Some(p) = config.provider() {
return Ok(p.to_string());
}
Ok("claude".to_string())
}
#[cfg(test)]
#[path = "config_tests.rs"]
mod tests;