Skip to main content

agent_doc_config/
lib.rs

1//! # Crate: agent-doc-config
2//!
3//! ## Spec
4//! - Defines `Config`: global user configuration loaded from `~/.config/agent-doc/config.toml`
5//!   (or `$XDG_CONFIG_HOME/agent-doc/config.toml`). Fields: `default_agent`, `agents` map,
6//!   `agent_args`, `claude_args`, `codex_args`, `opencode_args` (harness aliases),
7//!   `execution_mode`, `terminal`.
8//! - Defines `AgentConfig`: per-named-agent settings (`command`, `args`, `result_path`,
9//!   `session_path`).
10//! - Defines `TerminalConfig`: command template for launching an external terminal; supports
11//!   `{tmux_command}` substitution.
12//! - Defines `ExecutionMode` enum: `Hybrid` (default — first doc direct, rest subagent),
13//!   `Parallel` (always subagent), `Sequential` (fully serial).
14//! - `load()` reads and parses the global config file; returns `Config::default()` when the
15//!   file is absent. Propagates I/O and parse errors via `anyhow::Result`.
16//! - Project-level configuration types live in `agent-doc-frontmatter`;
17//!   file-backed helpers live in `agent-doc-project-config-io`.
18//!
19//! ## Agentic Contracts
20//! - **Never panics on missing config**: `load()` returns defaults when the file is absent.
21
22use anyhow::Result;
23use serde::{Deserialize, Serialize};
24use std::collections::BTreeMap;
25use std::path::PathBuf;
26
27use agent_doc_frontmatter::frontmatter::{CodexNetworkAccess, FreeTextExecutionMode};
28use agent_doc_model_tier::ModelConfig;
29
30pub mod env;
31
32/// Execution mode for skill-level parallelism.
33#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum ExecutionMode {
36    /// First doc direct, 2nd+ concurrent use subagent (default)
37    #[default]
38    Hybrid,
39    /// Every /agent-doc spawns subagent
40    Parallel,
41    /// Fully sequential, cheapest
42    Sequential,
43}
44
45impl std::fmt::Display for ExecutionMode {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            Self::Hybrid => write!(f, "hybrid"),
49            Self::Parallel => write!(f, "parallel"),
50            Self::Sequential => write!(f, "sequential"),
51        }
52    }
53}
54
55#[derive(Debug, Default, Clone, Serialize, Deserialize)]
56pub struct Config {
57    #[serde(default)]
58    pub default_agent: Option<String>,
59    #[serde(default)]
60    pub agents: BTreeMap<String, AgentConfig>,
61    /// Additional CLI arguments to pass to the agent process (harness-neutral).
62    /// Takes precedence over harness-specific aliases. Space-separated string.
63    #[serde(default)]
64    pub agent_args: Option<String>,
65    /// Additional CLI arguments to pass to the `claude` process.
66    /// Backward-compatible alias for `agent_args`. `agent_args` takes precedence when both are set.
67    #[serde(default)]
68    pub claude_args: Option<String>,
69    /// Additional CLI arguments to pass to the `codex` process.
70    /// Codex-only alias for `agent_args`. `agent_args` takes precedence when both are set.
71    #[serde(default)]
72    pub codex_args: Option<String>,
73    /// Additional CLI arguments to pass to the `opencode` process.
74    /// OpenCode-only alias for `agent_args`. `agent_args` takes precedence when both are set.
75    #[serde(default)]
76    pub opencode_args: Option<String>,
77    /// Explicit Codex network policy for agent-doc-launched sessions.
78    /// `inherit` keeps the ambient launcher setting, `enabled` removes
79    /// `CODEX_SANDBOX_NETWORK_DISABLED`, and `disabled` forces it on.
80    #[serde(default)]
81    pub codex_network_access: Option<CodexNetworkAccess>,
82    /// Maximum number of managed-capability-proof attempts before the dispatch
83    /// gate is set to `Failed`. Frontmatter overrides this. Default `3`.
84    #[serde(default)]
85    pub managed_proof_max_attempts: Option<u32>,
86    /// Base back-off (seconds) between managed-capability-proof retries.
87    /// Frontmatter overrides this. Default `2`.
88    #[serde(default)]
89    pub managed_proof_retry_backoff_secs: Option<u64>,
90    /// Override for the managed-capability child probe timeout (seconds).
91    /// Frontmatter overrides this. Default `45`.
92    #[serde(default)]
93    pub managed_proof_probe_timeout_secs: Option<u64>,
94    /// Global default execution strategy for free text admitted from
95    /// `agent:exchange` or `agent:queue` after backlog item creation. Values:
96    /// `auto`, `goal`, `queue`; frontmatter and project config override this.
97    #[serde(default, alias = "free_text_execution")]
98    pub agent_doc_free_text_execution: Option<FreeTextExecutionMode>,
99    /// Execution mode: hybrid (default), parallel, sequential.
100    /// Controls how the skill handles concurrent /agent-doc invocations.
101    #[serde(default)]
102    pub execution_mode: Option<ExecutionMode>,
103    /// Terminal emulator configuration for `agent-doc terminal`.
104    #[serde(default)]
105    pub terminal: Option<TerminalConfig>,
106    /// Model tier configuration: tier→model name maps per harness, plus
107    /// gating preferences. Loaded from `[model]` and `[model.tiers.<harness>]`
108    /// sections. See `model_tier::ModelConfig`.
109    #[serde(default)]
110    pub model: ModelConfig,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct TerminalConfig {
115    /// Command template to launch a terminal.
116    /// `{tmux_command}` is replaced with the tmux attach/create command.
117    /// Example: `wezterm start -- {tmux_command}`
118    pub command: Option<String>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct AgentConfig {
123    pub command: String,
124    #[serde(default)]
125    pub args: Vec<String>,
126    #[serde(default)]
127    pub result_path: Option<String>,
128    #[serde(default)]
129    pub session_path: Option<String>,
130}
131
132/// Load config from ~/.config/agent-doc/config.toml, or return defaults.
133pub fn load() -> Result<Config> {
134    let path = config_path();
135    if path.exists() {
136        let content = std::fs::read_to_string(&path)?;
137        Ok(toml::from_str(&content)?)
138    } else {
139        Ok(Config::default())
140    }
141}
142
143fn config_path() -> PathBuf {
144    dirs_config_dir().join("agent-doc").join("config.toml")
145}
146
147fn dirs_config_dir() -> PathBuf {
148    std::env::var("XDG_CONFIG_HOME")
149        .map(PathBuf::from)
150        .unwrap_or_else(|_| {
151            let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
152            PathBuf::from(home).join(".config")
153        })
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn test_load_missing_global_config() {
162        // This test assumes no config exists in the temp directory
163        // In practice, load() returns Config::default() when file is absent
164        let _cfg = Config::default();
165        assert!(_cfg.agents.is_empty());
166    }
167
168    #[test]
169    fn test_execution_mode_display() {
170        assert_eq!(ExecutionMode::Hybrid.to_string(), "hybrid");
171        assert_eq!(ExecutionMode::Parallel.to_string(), "parallel");
172        assert_eq!(ExecutionMode::Sequential.to_string(), "sequential");
173    }
174
175    #[test]
176    fn test_config_agent_args_deserialization() {
177        let toml_str = r#"
178agent_args = "--json -s workspace-write"
179claude_args = "--dangerously-skip-permissions"
180codex_args = "-s danger-full-access"
181opencode_args = "--dangerously-skip-permissions"
182codex_network_access = "enabled"
183agent_doc_free_text_execution = "queue"
184"#;
185        let cfg: Config = toml::from_str(toml_str).unwrap();
186        assert_eq!(cfg.agent_args.as_deref(), Some("--json -s workspace-write"));
187        assert_eq!(
188            cfg.claude_args.as_deref(),
189            Some("--dangerously-skip-permissions")
190        );
191        assert_eq!(cfg.codex_args.as_deref(), Some("-s danger-full-access"));
192        assert_eq!(
193            cfg.opencode_args.as_deref(),
194            Some("--dangerously-skip-permissions")
195        );
196        assert_eq!(cfg.codex_network_access, Some(CodexNetworkAccess::Enabled));
197        assert_eq!(
198            cfg.agent_doc_free_text_execution,
199            Some(FreeTextExecutionMode::Queue)
200        );
201        let alias: Config = toml::from_str("free_text_execution = \"goal\"").unwrap();
202        assert_eq!(
203            alias.agent_doc_free_text_execution,
204            Some(FreeTextExecutionMode::Goal)
205        );
206    }
207
208    #[test]
209    fn test_config_agent_args_precedence_resolution() {
210        // agent_args takes precedence over claude_args
211        let cfg = Config {
212            agent_args: Some("--json".to_string()),
213            claude_args: Some("--old-flag".to_string()),
214            ..Default::default()
215        };
216        let resolved = cfg.agent_args.or(cfg.claude_args);
217        assert_eq!(resolved.as_deref(), Some("--json"));
218    }
219
220    #[test]
221    fn test_config_claude_args_fallback() {
222        // When agent_args is absent, claude_args is used
223        let cfg = Config {
224            agent_args: None,
225            claude_args: Some("--old-flag".to_string()),
226            ..Default::default()
227        };
228        let resolved = cfg.agent_args.or(cfg.claude_args);
229        assert_eq!(resolved.as_deref(), Some("--old-flag"));
230    }
231
232    #[test]
233    fn test_config_codex_args_fallback() {
234        // When agent_args is absent, codex_args is used for Codex-specific resolution.
235        let cfg = Config {
236            agent_args: None,
237            codex_args: Some("-s danger-full-access".to_string()),
238            ..Default::default()
239        };
240        let resolved = cfg.agent_args.or(cfg.codex_args);
241        assert_eq!(resolved.as_deref(), Some("-s danger-full-access"));
242    }
243
244    #[test]
245    fn test_config_opencode_args_fallback() {
246        // When agent_args is absent, opencode_args is used for OpenCode-specific resolution.
247        let cfg = Config {
248            agent_args: None,
249            opencode_args: Some("--dangerously-skip-permissions".to_string()),
250            ..Default::default()
251        };
252        let resolved = cfg.agent_args.or(cfg.opencode_args);
253        assert_eq!(resolved.as_deref(), Some("--dangerously-skip-permissions"));
254    }
255}