Skip to main content

sid_isnt_done/
config.rs

1//! Configuration loading for sid workspaces.
2//!
3//! A sid workspace keeps its configuration in a directory tree with two rc.conf
4//! files ([`AGENTS_CONF_FILE`] and [`TOOLS_CONF_FILE`]) and companion
5//! subdirectories for agent prompts, tool executables, and skill markdown
6//! files.  [`Config::load`] reads these files and produces a strongly typed
7//! configuration that the rest of the agent runtime consumes.
8
9use std::collections::BTreeMap;
10use std::fs;
11#[cfg(unix)]
12use std::os::unix::fs::PermissionsExt;
13use std::time::Duration;
14
15use claudius::chat::ChatConfig;
16use claudius::{Model, ThinkingConfig};
17use handled::SError;
18use rc_conf::{RcConf, SwitchPosition};
19use serde::Deserialize;
20use shvar::VariableProvider;
21use utf8path::Path;
22
23/// Default token budget for extended thinking.
24pub const DEFAULT_THINKING_BUDGET: u32 = 1024;
25/// Filename for the agent declarations rc.conf file.
26pub const AGENTS_CONF_FILE: &str = "agents.conf";
27/// Filename for the tool declarations rc.conf file.
28pub const TOOLS_CONF_FILE: &str = "tools.conf";
29/// Subdirectory that holds per-agent prompt and configuration files.
30pub const AGENTS_DIR: &str = "agents";
31/// Subdirectory that holds per-tool executables and manifest JSON files.
32pub const TOOLS_DIR: &str = "tools";
33/// Subdirectory that holds skill markdown files.
34pub const SKILLS_DIR: &str = "skills";
35/// Conventional filename for a skill definition.
36pub const SKILL_FILE: &str = "SKILL.md";
37/// Conventional filename for agent-level markdown instructions.
38pub const AGENTS_MD_FILE: &str = "AGENTS.md";
39/// Environment variable that overrides the path to the AGENTS.md file.
40pub const AGENTS_MD_PATH_ENV: &str = "AGENTS_MD_PATH";
41/// Current version of the sid tool protocol.
42pub const TOOL_PROTOCOL_VERSION: u32 = 1;
43/// Conventional name for the primary system prompt in a prompt set.
44pub const SYSTEM_PROMPT_ID: &str = "SYSTEM";
45/// Conventional name for the compaction request prompt in an agent prompt set.
46pub const COMPACTION_PROMPT_ID: &str = "COMPACTION";
47/// Conventional name for the memory-expert addendum prompt in an agent prompt set.
48pub const MEMORY_EXPERT_PROMPT_ID: &str = "MEMORY_EXPERT";
49
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51struct KnownPromptField {
52    id: &'static str,
53    field: &'static str,
54}
55
56const KNOWN_AGENT_PROMPTS: &[KnownPromptField] = &[
57    KnownPromptField {
58        id: SYSTEM_PROMPT_ID,
59        field: "PROMPT",
60    },
61    KnownPromptField {
62        id: COMPACTION_PROMPT_ID,
63        field: "PROMPT_COMPACTION",
64    },
65    KnownPromptField {
66        id: MEMORY_EXPERT_PROMPT_ID,
67        field: "PROMPT_MEMORY_EXPERT",
68    },
69];
70
71const KNOWN_TOOL_PROMPTS: &[KnownPromptField] = &[KnownPromptField {
72    id: SYSTEM_PROMPT_ID,
73    field: "PROMPT",
74}];
75
76/// Fully resolved workspace configuration.
77///
78/// Contains all agents, tools, and skills discovered during [`Config::load`],
79/// together with the parsed rc.conf backing stores.
80#[derive(Debug)]
81pub struct Config {
82    /// Root directory from which the configuration was loaded.
83    pub root: Path<'static>,
84    /// Explicit default agent, if one was declared in the agents rc.conf.
85    pub default_agent: Option<String>,
86    /// Agent configurations keyed by agent identifier.
87    pub agents: BTreeMap<String, AgentConfig>,
88    /// Tool configurations keyed by tool identifier.
89    pub tools: BTreeMap<String, ToolConfig>,
90    /// Skill configurations keyed by skill identifier.
91    pub skills: BTreeMap<String, SkillConfig>,
92    pub(crate) agents_rc_conf: RcConf,
93    pub(crate) tools_rc_conf: RcConf,
94}
95
96impl Config {
97    /// Load a workspace configuration from `root`.
98    ///
99    /// Reads `agents.conf` and `tools.conf` from the given directory, resolves
100    /// every agent, tool, and skill referenced in those files, and validates
101    /// tool manifests and executables.
102    ///
103    /// # Errors
104    ///
105    /// Returns an error when a required configuration file is missing, a
106    /// referenced tool executable or manifest cannot be found, or an rc.conf
107    /// entry is malformed.
108    pub fn load(root: &Path) -> Result<Self, SError> {
109        let root = root.clone().into_owned();
110        let agents_conf_path = root.join(AGENTS_CONF_FILE);
111        let tools_conf_path = root.join(TOOLS_CONF_FILE);
112
113        require_file(&agents_conf_path, AGENTS_CONF_FILE)?;
114        require_file(&tools_conf_path, TOOLS_CONF_FILE)?;
115
116        let agents_rc_conf = parse_rc_conf(&agents_conf_path)?;
117        let tools_rc_conf = parse_rc_conf(&tools_conf_path)?;
118        let agent_names = collect_names_from_rc_conf(&agents_rc_conf)?;
119        let tool_names = collect_names_from_rc_conf(&tools_rc_conf)?;
120        let skills_dirs = resolve_skills_dirs(&root);
121        let skills = load_skills(&skills_dirs)?;
122        Self::from_parts(
123            root,
124            agents_rc_conf,
125            &agent_names,
126            tools_rc_conf,
127            &tool_names,
128            skills,
129        )
130    }
131
132    fn from_parts(
133        root: Path<'static>,
134        agents_rc_conf: RcConf,
135        agent_names: &[String],
136        tools_rc_conf: RcConf,
137        tool_names: &[String],
138        skills: BTreeMap<String, SkillConfig>,
139    ) -> Result<Self, SError> {
140        let agents_dir = root.join(AGENTS_DIR).into_owned();
141        let tools_dir = root.join(TOOLS_DIR).into_owned();
142
143        let default_agent = resolve_default_agent(&agents_rc_conf, agent_names)?;
144
145        let mut agents = BTreeMap::new();
146        for agent_name in agent_names {
147            let agent = AgentConfig::from_rc_conf(&root, &agents_dir, &agents_rc_conf, agent_name)?;
148            agents.insert(agent_name.clone(), agent);
149        }
150
151        let tools = resolve_tool_configs(&root, &tools_dir, &tools_rc_conf, tool_names)?;
152
153        Ok(Self {
154            root,
155            default_agent,
156            agents,
157            tools,
158            skills,
159            agents_rc_conf,
160            tools_rc_conf,
161        })
162    }
163}
164
165/// Configuration for a single agent declared in `agents.conf`.
166///
167/// Each agent has an identity, an enablement switch, a system prompt, a list
168/// of tools it may invoke, and tuning knobs for user-instruction injection
169/// and extended-thinking budgets.
170#[derive(Debug)]
171pub struct AgentConfig {
172    /// Unique identifier for this agent (the rc.conf service name).
173    pub id: String,
174    /// Whether the agent is enabled, disabled, or requires manual confirmation.
175    pub enabled: SwitchPosition,
176    /// Human-readable display name, if specified.
177    pub display_name: Option<String>,
178    /// Short prose description of the agent's purpose.
179    pub description: Option<String>,
180    /// Tool identifiers this agent is allowed to invoke.
181    pub tools: Vec<String>,
182    /// Skill identifiers this agent has access to.
183    pub skills: Vec<String>,
184    /// Filesystem path to the agent's system prompt markdown file.
185    pub prompt_path: Path<'static>,
186    /// Filesystem paths that contributed to the agent's system prompt.
187    pub prompt_paths: Vec<Path<'static>>,
188    /// Loaded system prompt markdown content, or `None` when the file is absent.
189    pub prompt_markdown: Option<String>,
190    /// Additional named markdown prompts loaded for the agent.
191    pub prompts: BTreeMap<String, PromptConfig>,
192    /// Merged chat configuration (model, thinking budget, etc.).
193    pub chat_config: ChatConfig,
194    /// Whether user-instruction injection is enabled for this agent.
195    pub user_instructions_enabled: bool,
196    /// Whether the AGENTS.md file should be appended to the system prompt.
197    pub agents_md_enabled: bool,
198    /// Explicit override path for the AGENTS.md file, if set.
199    pub agents_md_path: Option<String>,
200    /// Shell command executed as a hook to produce additional user instructions.
201    pub user_instructions_hook: Option<String>,
202    /// When set, automatically compact the session after this many output tokens.
203    pub auto_compact_tokens: Option<u64>,
204}
205
206impl AgentConfig {
207    fn from_rc_conf(
208        config_root: &Path,
209        agents_dir: &Path,
210        rc_conf: &RcConf,
211        agent: &str,
212    ) -> Result<Self, SError> {
213        let provider = rc_conf.variable_provider_for(agent).map_err(|err| {
214            SError::new("config")
215                .with_code("rc_conf_error")
216                .with_message("failed to derive agent config from rc_conf")
217                .with_string_field("agent", agent)
218                .with_string_field("cause", &format!("{err:?}"))
219        })?;
220
221        let enabled = rc_conf.service_switch(agent);
222        let display_name = lookup_expanded(&provider, agent, "NAME")?;
223        let description = lookup_expanded(&provider, agent, "DESC")?;
224        let tools = lookup_split_field(&provider, agent, "TOOLS")?;
225        let skills = lookup_split_field(&provider, agent, "SKILLS")?;
226        let user_instructions_enabled =
227            lookup_bool_field(&provider, agent, "USER_INSTRUCTIONS", true)?;
228        let agents_md_enabled = lookup_bool_field(&provider, agent, "AGENTS_MD", true)?;
229        let agents_md_path = lookup_nonempty_field(&provider, agent, "AGENTS_MD_PATH")?;
230        let user_instructions_hook =
231            lookup_nonempty_field(&provider, agent, "USER_INSTRUCTIONS_HOOK")?;
232        let auto_compact_tokens = match lookup_expanded(&provider, agent, "AUTO_COMPACT")? {
233            Some(value) => Some(parse_u64_field(agent, "AUTO_COMPACT", &value)?),
234            None => None,
235        };
236        let prompts =
237            load_named_prompts(config_root, rc_conf, agent, KNOWN_AGENT_PROMPTS, "agent")?;
238
239        let default_prompt_path = resolve_agent_prompt_path(agents_dir, rc_conf, agent);
240        let (prompt_path, prompt_paths, prompt_markdown) =
241            if let Some(system_prompt) = prompts.get(SYSTEM_PROMPT_ID) {
242                (
243                    system_prompt
244                        .paths
245                        .first()
246                        .cloned()
247                        .unwrap_or_else(|| default_prompt_path.clone()),
248                    system_prompt.paths.clone(),
249                    Some(system_prompt.markdown.clone()),
250                )
251            } else if default_prompt_path.exists() {
252                (
253                    default_prompt_path.clone(),
254                    vec![default_prompt_path.clone()],
255                    Some(read_utf8_file(&default_prompt_path, agent, "prompt")?),
256                )
257            } else {
258                (default_prompt_path, vec![], None)
259            };
260
261        let mut non_system_prompts = prompts;
262        non_system_prompts.remove(SYSTEM_PROMPT_ID);
263
264        let mut chat_config = ChatConfig::new();
265        if let Some(prompt) = prompt_markdown.as_ref() {
266            chat_config.set_system_prompt(Some(prompt.clone()));
267        }
268        apply_chat_config_overrides(&mut chat_config, &provider, agent)?;
269
270        Ok(Self {
271            id: agent.to_string(),
272            enabled,
273            display_name,
274            description,
275            tools,
276            skills,
277            prompt_path,
278            prompt_paths,
279            prompt_markdown,
280            prompts: non_system_prompts,
281            chat_config,
282            user_instructions_enabled,
283            agents_md_enabled,
284            agents_md_path,
285            user_instructions_hook,
286            auto_compact_tokens,
287        })
288    }
289}
290
291/// Default tool execution timeout: 2 minutes.
292///
293/// Applied to all tools that do not specify an explicit `TIMEOUT` in
294/// `tools.conf`.  A per-tool `TIMEOUT` of `"0"` disables the timeout for
295/// that tool.
296pub const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(120);
297
298/// Configuration for a single tool declared in `tools.conf`.
299///
300/// Tools are external executables that speak the sid tool protocol.  The
301/// manifest JSON supplies the Anthropic API with a description and JSON-Schema
302/// input definition, while the executable path locates the binary that
303/// actually runs each invocation.
304#[derive(Debug)]
305pub struct ToolConfig {
306    /// Tool identifier (the rc.conf service name after alias resolution).
307    pub id: String,
308    /// Whether the tool is enabled, disabled, or requires manual confirmation.
309    pub enabled: SwitchPosition,
310    /// When `true`, the harness shows a diff preview before executing write operations.
311    pub confirm_preview: bool,
312    /// Filesystem path to the tool executable, or `None` for built-in tools.
313    pub executable_path: Option<Path<'static>>,
314    /// Filesystem path to the tool's manifest JSON file.
315    pub manifest_path: Path<'static>,
316    /// Parsed manifest, or `None` when the manifest is optional and absent.
317    pub manifest: Option<ToolManifest>,
318    /// Additional named markdown prompts loaded for the tool.
319    pub prompts: BTreeMap<String, PromptConfig>,
320    /// Maximum execution time, or `None` for no timeout.
321    ///
322    /// Defaults to [`DEFAULT_TOOL_TIMEOUT`] when omitted in `tools.conf`.
323    /// Set `TIMEOUT="0"` in `tools.conf` to disable the timeout for a tool.
324    pub timeout: Option<Duration>,
325}
326
327/// Parsed content of a tool manifest JSON file.
328///
329/// The manifest supplies the Anthropic messages API with the information it
330/// needs to present the tool to the model: a human-readable description and
331/// the JSON-Schema that validates tool-use inputs.
332#[derive(Clone, Debug, Eq, PartialEq)]
333pub struct ToolManifest {
334    /// Protocol version this manifest was written for.
335    pub protocol_version: u32,
336    /// Human-readable description shown to the model.
337    pub description: String,
338    /// JSON-Schema describing the tool's input parameters.
339    pub input_schema: serde_json::Value,
340}
341
342/// Parsed content of a named prompt assembled from one or more markdown files.
343#[derive(Clone, Debug, Eq, PartialEq)]
344pub struct PromptConfig {
345    /// Prompt identifier such as `SYSTEM` or `COMPACTION`.
346    pub id: String,
347    /// Absolute paths to the markdown files that were concatenated.
348    pub paths: Vec<Path<'static>>,
349    /// Concatenated markdown content.
350    pub markdown: String,
351}
352
353/// Per-skill configuration loaded from a markdown file in the skills directory.
354#[derive(Clone, Debug)]
355pub struct SkillConfig {
356    /// Skill identifier derived from the filename without the `.md` extension.
357    pub id: String,
358    /// Absolute path to the skill markdown file.
359    pub path: Path<'static>,
360    /// Markdown content of the skill file.
361    pub content: String,
362}
363
364#[derive(Debug, Deserialize)]
365struct ToolManifestFile {
366    protocol_version: u32,
367    description: String,
368    input_schema: serde_json::Value,
369}
370
371fn resolve_tool_configs(
372    config_root: &Path,
373    tools_dir: &Path,
374    rc_conf: &RcConf,
375    tool_names: &[String],
376) -> Result<BTreeMap<String, ToolConfig>, SError> {
377    let mut resolved_ids = BTreeMap::new();
378    for tool in tool_names {
379        let canonical_id = resolve_canonical_tool_id(rc_conf, tool)?;
380        validate_anthropic_tool_name(&canonical_id)?;
381        resolved_ids.insert(tool.clone(), canonical_id);
382    }
383
384    let mut canonical_metadata = BTreeMap::new();
385    for canonical_id in resolved_ids.values() {
386        if canonical_metadata.contains_key(canonical_id) {
387            continue;
388        }
389        let executable_path = if builtin_tool_executable_is_optional(canonical_id) {
390            None
391        } else {
392            let executable_path = tools_dir.join(canonical_id).into_owned();
393            require_tool_executable(canonical_id, &executable_path)?;
394            Some(executable_path)
395        };
396
397        let manifest_path = tools_dir.join(format!("{canonical_id}.json")).into_owned();
398        let manifest = if manifest_path.is_file() {
399            Some(load_tool_manifest(canonical_id, &manifest_path)?)
400        } else if builtin_tool_manifest_is_optional(canonical_id) {
401            None
402        } else {
403            return Err(SError::new("config")
404                .with_code("missing_tool_manifest")
405                .with_message("required tool manifest does not exist")
406                .with_string_field("tool", canonical_id)
407                .with_string_field("manifest_path", manifest_path.as_str()));
408        };
409        canonical_metadata.insert(
410            canonical_id.clone(),
411            (executable_path, manifest_path, manifest),
412        );
413    }
414
415    let mut tools = BTreeMap::new();
416    for tool_id in tool_names {
417        let enabled = resolve_tool_switch(rc_conf, tool_id)?;
418        let confirm_preview = resolve_tool_confirm_preview(rc_conf, tool_id)?;
419        let timeout = resolve_tool_timeout(rc_conf, tool_id)?;
420        let prompts =
421            load_named_prompts(config_root, rc_conf, tool_id, KNOWN_TOOL_PROMPTS, "tool")?;
422        let canonical_id = resolved_ids
423            .get(tool_id)
424            .expect("resolved tool id should exist")
425            .clone();
426        let (executable_path, manifest_path, manifest) = canonical_metadata
427            .get(&canonical_id)
428            .expect("canonical metadata should exist");
429        tools.insert(
430            tool_id.clone(),
431            ToolConfig {
432                id: tool_id.clone(),
433                enabled,
434                confirm_preview,
435                executable_path: executable_path.clone(),
436                manifest_path: manifest_path.clone(),
437                manifest: manifest.clone(),
438                prompts,
439                timeout,
440            },
441        );
442    }
443
444    Ok(tools)
445}
446
447pub(crate) fn resolve_canonical_tool_id(rc_conf: &RcConf, tool: &str) -> Result<String, SError> {
448    let services = collect_names_from_rc_conf(rc_conf)?;
449    let canonical_id = rc_conf.resolve_alias(tool).to_string();
450    if services.iter().any(|service| service == &canonical_id) {
451        Ok(canonical_id)
452    } else {
453        Err(SError::new("config")
454            .with_code("unknown_tool")
455            .with_message("tool alias resolves to an undefined tool")
456            .with_string_field("tool", tool)
457            .with_string_field("alias_target", &canonical_id))
458    }
459}
460
461fn resolve_tool_switch(rc_conf: &RcConf, tool: &str) -> Result<SwitchPosition, SError> {
462    Ok(rc_conf.service_switch(tool))
463}
464
465fn resolve_tool_confirm_preview(rc_conf: &RcConf, tool: &str) -> Result<bool, SError> {
466    let provider = rc_conf.variable_provider_for(tool).map_err(|err| {
467        SError::new("config")
468            .with_code("rc_conf_error")
469            .with_message("failed to derive tool config from rc_conf")
470            .with_string_field("tool", tool)
471            .with_string_field("cause", &format!("{err:?}"))
472    })?;
473    let Some(value) = lookup_expanded(&provider, tool, "CONFIRM")? else {
474        return Ok(false);
475    };
476    parse_bool_field(tool, "CONFIRM", &value)
477}
478
479/// Resolve the execution timeout for a tool.
480///
481/// Returns `Some(duration)` when a timeout should be enforced, or `None` when
482/// the tool should run without a time limit.  The lookup order is:
483///
484/// 1. `<tool>_TIMEOUT` — per-tool override.
485/// 2. `TIMEOUT` — top-level default in `tools.conf`.
486/// 3. [`DEFAULT_TOOL_TIMEOUT`] — compiled-in 2-minute default.
487///
488/// A value of `"0"` at any level disables the timeout (`None`).
489fn resolve_tool_timeout(rc_conf: &RcConf, tool: &str) -> Result<Option<Duration>, SError> {
490    let provider = rc_conf.variable_provider_for(tool).map_err(|err| {
491        SError::new("config")
492            .with_code("rc_conf_error")
493            .with_message("failed to derive tool config from rc_conf")
494            .with_string_field("tool", tool)
495            .with_string_field("cause", &format!("{err:?}"))
496    })?;
497    let Some(value) = lookup_expanded(&provider, tool, "TIMEOUT")? else {
498        return Ok(Some(DEFAULT_TOOL_TIMEOUT));
499    };
500    let seconds = parse_u64_field(tool, "TIMEOUT", &value)?;
501    if seconds == 0 {
502        Ok(None)
503    } else {
504        Ok(Some(Duration::from_secs(seconds)))
505    }
506}
507
508pub(crate) fn is_valid_anthropic_tool_name(name: &str) -> bool {
509    !name.is_empty()
510        && name.len() <= 64
511        && name
512            .bytes()
513            .all(|ch| ch.is_ascii_alphanumeric() || ch == b'_' || ch == b'-')
514}
515
516fn validate_anthropic_tool_name(name: &str) -> Result<(), SError> {
517    if is_valid_anthropic_tool_name(name) {
518        Ok(())
519    } else {
520        Err(SError::new("config")
521            .with_code("invalid_tool_id")
522            .with_message("tool id is not a legal Anthropic tool name")
523            .with_string_field("tool", name)
524            .with_string_field("reason", "expected 1-64 ASCII letters, digits, '_' or '-'"))
525    }
526}
527
528fn require_tool_executable(tool: &str, path: &Path) -> Result<(), SError> {
529    require_file_with_code(
530        path,
531        "missing_tool_executable",
532        "required tool executable does not exist",
533        "tool",
534        tool,
535        "path",
536    )?;
537    #[cfg(unix)]
538    {
539        let metadata = fs::metadata(path.as_str()).map_err(|err| {
540            SError::new("config")
541                .with_code("io_error")
542                .with_message("failed to inspect tool executable")
543                .with_string_field("tool", tool)
544                .with_string_field("path", path.as_str())
545                .with_string_field("cause", &err.to_string())
546        })?;
547        if metadata.permissions().mode() & 0o111 == 0 {
548            return Err(SError::new("config")
549                .with_code("tool_not_executable")
550                .with_message("tool executable is not marked executable")
551                .with_string_field("tool", tool)
552                .with_string_field("path", path.as_str()));
553        }
554    }
555    Ok(())
556}
557
558fn load_tool_manifest(tool: &str, path: &Path) -> Result<ToolManifest, SError> {
559    let raw = read_utf8_file(path, tool, "manifest")?;
560    let manifest: ToolManifestFile = serde_json::from_str(&raw).map_err(|err| {
561        SError::new("config")
562            .with_code("invalid_tool_manifest_json")
563            .with_message("failed to parse tool manifest")
564            .with_string_field("tool", tool)
565            .with_string_field("path", path.as_str())
566            .with_string_field("cause", &err.to_string())
567    })?;
568    if manifest.protocol_version != TOOL_PROTOCOL_VERSION {
569        return Err(SError::new("config")
570            .with_code("unsupported_tool_protocol_version")
571            .with_message("tool manifest declares an unsupported protocol version")
572            .with_string_field("tool", tool)
573            .with_string_field("path", path.as_str())
574            .with_string_field("protocol_version", &manifest.protocol_version.to_string()));
575    }
576    if manifest.description.trim().is_empty() {
577        return Err(SError::new("config")
578            .with_code("invalid_tool_manifest")
579            .with_message("tool manifest description must not be empty")
580            .with_string_field("tool", tool)
581            .with_string_field("path", path.as_str()));
582    }
583    if !manifest.input_schema.is_object() {
584        return Err(SError::new("config")
585            .with_code("invalid_tool_manifest")
586            .with_message("tool manifest input_schema must be a JSON object")
587            .with_string_field("tool", tool)
588            .with_string_field("path", path.as_str()));
589    }
590
591    Ok(ToolManifest {
592        protocol_version: manifest.protocol_version,
593        description: manifest.description,
594        input_schema: manifest.input_schema,
595    })
596}
597
598fn builtin_tool_manifest_is_optional(tool: &str) -> bool {
599    matches!(tool, "bash" | "edit")
600}
601
602fn builtin_tool_executable_is_optional(tool: &str) -> bool {
603    matches!(tool, "bash")
604}
605
606fn resolve_skills_dirs(root: &Path) -> Vec<Path<'static>> {
607    match std::env::var("SID_SKILLS_PATH") {
608        Ok(path) if !path.is_empty() => path
609            .split(':')
610            .filter(|component| !component.is_empty())
611            .map(|component| Path::new(component).into_owned())
612            .collect(),
613        _ => vec![root.join(SKILLS_DIR).into_owned()],
614    }
615}
616
617fn load_skills(dirs: &[Path<'static>]) -> Result<BTreeMap<String, SkillConfig>, SError> {
618    let mut skills = BTreeMap::new();
619    for dir in dirs {
620        if !std::path::Path::new(dir.as_str()).is_dir() {
621            continue;
622        }
623        let entries = fs::read_dir(dir.as_str()).map_err(|err| {
624            SError::new("config")
625                .with_code("io_error")
626                .with_message("failed to read skills directory")
627                .with_string_field("path", dir.as_str())
628                .with_string_field("cause", &err.to_string())
629        })?;
630        for entry in entries {
631            let entry = entry.map_err(|err| {
632                SError::new("config")
633                    .with_code("io_error")
634                    .with_message("failed to read skills directory entry")
635                    .with_string_field("path", dir.as_str())
636                    .with_string_field("cause", &err.to_string())
637            })?;
638            let entry_path = entry.path();
639            if !entry_path.is_dir() {
640                continue;
641            }
642            let skill_file = entry_path.join(SKILL_FILE);
643            if !skill_file.is_file() {
644                continue;
645            }
646            let dir_name = entry.file_name();
647            let skill_name = dir_name.to_string_lossy();
648            if skill_name.is_empty() {
649                continue;
650            }
651            if skills.contains_key(skill_name.as_ref()) {
652                continue;
653            }
654            let path = Path::try_from(skill_file)
655                .map_err(|err| {
656                    SError::new("config")
657                        .with_code("invalid_skill_path")
658                        .with_message("skill path is not valid UTF-8")
659                        .with_string_field("cause", &format!("{err:?}"))
660                })?
661                .into_owned();
662            let content = read_utf8_file(&path, &skill_name, "skill")?;
663            skills.insert(
664                skill_name.to_string(),
665                SkillConfig {
666                    id: skill_name.to_string(),
667                    path,
668                    content,
669                },
670            );
671        }
672    }
673    Ok(skills)
674}
675
676fn apply_chat_config_overrides(
677    chat_config: &mut ChatConfig,
678    provider: &impl VariableProvider,
679    agent: &str,
680) -> Result<(), SError> {
681    if let Some(model) = lookup_expanded(provider, agent, "MODEL")? {
682        let model = model
683            .parse()
684            .unwrap_or_else(|_| Model::Custom(model.clone()));
685        chat_config.set_model(model);
686    }
687    if let Some(system_prompt) = lookup_expanded(provider, agent, "SYSTEM")? {
688        chat_config.set_system_prompt(Some(system_prompt));
689    }
690    if let Some(max_tokens) = lookup_expanded(provider, agent, "MAX_TOKENS")? {
691        chat_config.set_max_tokens(parse_u32_field(agent, "MAX_TOKENS", &max_tokens)?);
692    }
693    if let Some(temperature) = lookup_expanded(provider, agent, "TEMPERATURE")? {
694        chat_config.set_temperature(Some(parse_unit_interval_field(
695            agent,
696            "TEMPERATURE",
697            &temperature,
698        )?));
699    }
700    if let Some(top_p) = lookup_expanded(provider, agent, "TOP_P")? {
701        chat_config.set_top_p(Some(parse_unit_interval_field(agent, "TOP_P", &top_p)?));
702    }
703    if let Some(top_k) = lookup_expanded(provider, agent, "TOP_K")? {
704        chat_config.set_top_k(Some(parse_u32_field(agent, "TOP_K", &top_k)?));
705    }
706    if let Some(stop_sequences) = lookup_expanded(provider, agent, "STOP_SEQUENCES")? {
707        let stop_sequences = shvar::split(&stop_sequences).map_err(|err| {
708            invalid_config_field(agent, "STOP_SEQUENCES", &stop_sequences, format!("{err:?}"))
709        })?;
710        if stop_sequences.is_empty() {
711            chat_config.template.stop_sequences = None;
712        } else {
713            chat_config.template.stop_sequences = Some(stop_sequences);
714        }
715    }
716    if let Some(thinking) = lookup_expanded(provider, agent, "THINKING")? {
717        chat_config.template.thinking = parse_thinking_budget(agent, "THINKING", &thinking)?;
718    }
719    if let Some(use_color) = lookup_expanded(provider, agent, "USE_COLOR")? {
720        chat_config.use_color = parse_bool_field(agent, "USE_COLOR", &use_color)?;
721    }
722    if let Some(no_color) = lookup_expanded(provider, agent, "NO_COLOR")? {
723        chat_config.use_color = !parse_bool_field(agent, "NO_COLOR", &no_color)?;
724    }
725    if let Some(session_spend) = lookup_expanded(provider, agent, "SESSION_SPEND")? {
726        chat_config.set_session_spend(Some(parse_f64_field(
727            agent,
728            "SESSION_SPEND",
729            &session_spend,
730        )?));
731    }
732    if let Some(caching_enabled) = lookup_expanded(provider, agent, "CACHING_ENABLED")? {
733        chat_config.caching_enabled = parse_bool_field(agent, "CACHING_ENABLED", &caching_enabled)?;
734    }
735
736    Ok(())
737}
738
739/// Read the explicit `DEFAULT_AGENT` global variable from agents.conf when present.
740///
741/// If the variable is set, its value must name one of the defined agents.
742fn resolve_default_agent(
743    agents_rc_conf: &RcConf,
744    agent_names: &[String],
745) -> Result<Option<String>, SError> {
746    let Some(value) = agents_rc_conf.lookup("DEFAULT_AGENT") else {
747        return Ok(None);
748    };
749    let value = value.trim().to_string();
750    if value.is_empty() {
751        return Ok(None);
752    }
753    if !agent_names.contains(&value) {
754        return Err(SError::new("config")
755            .with_code("invalid_default_agent")
756            .with_message("DEFAULT_AGENT names an undefined agent")
757            .with_string_field("default_agent", &value));
758    }
759    Ok(Some(value))
760}
761
762fn collect_names_from_rc_conf(rc_conf: &RcConf) -> Result<Vec<String>, SError> {
763    Ok(rc_conf
764        .list()
765        .map_err(|err| {
766            SError::new("config")
767                .with_code("rc_conf_error")
768                .with_message("failed to list configured names")
769                .with_string_field("cause", &format!("{err:?}"))
770        })?
771        .collect())
772}
773
774fn parse_rc_conf(path: &Path) -> Result<RcConf, SError> {
775    RcConf::parse(path.as_str()).map_err(|err| {
776        SError::new("config")
777            .with_code("rc_conf_error")
778            .with_message("failed to parse rc_conf file")
779            .with_string_field("path", path.as_str())
780            .with_string_field("cause", &format!("{err:?}"))
781    })
782}
783
784fn require_file(path: &Path, label: &str) -> Result<(), SError> {
785    require_file_with_code(
786        path,
787        "missing_config_file",
788        "required config file does not exist",
789        "file",
790        label,
791        "path",
792    )
793}
794
795fn require_file_with_code(
796    path: &Path,
797    code: &str,
798    message: &str,
799    name_field: &str,
800    name: &str,
801    path_field: &str,
802) -> Result<(), SError> {
803    if path.is_file() {
804        Ok(())
805    } else {
806        Err(SError::new("config")
807            .with_code(code)
808            .with_message(message)
809            .with_string_field(name_field, name)
810            .with_string_field(path_field, path.as_str()))
811    }
812}
813
814fn read_utf8_file(path: &Path, name: &str, field: &str) -> Result<String, SError> {
815    fs::read_to_string(path.as_str()).map_err(|err| {
816        SError::new("config")
817            .with_code("io_error")
818            .with_message("failed to read config file")
819            .with_string_field("name", name)
820            .with_string_field("field", field)
821            .with_string_field("path", path.as_str())
822            .with_string_field("cause", &err.to_string())
823    })
824}
825
826fn load_named_prompts(
827    config_root: &Path,
828    rc_conf: &RcConf,
829    service: &str,
830    known_prompts: &[KnownPromptField],
831    scope_kind: &str,
832) -> Result<BTreeMap<String, PromptConfig>, SError> {
833    let provider = rc_conf.variable_provider_for(service).map_err(|err| {
834        SError::new("config")
835            .with_code("rc_conf_error")
836            .with_message("failed to derive config from rc_conf")
837            .with_string_field("scope", service)
838            .with_string_field("kind", scope_kind)
839            .with_string_field("cause", &format!("{err:?}"))
840    })?;
841
842    let mut prompts = BTreeMap::new();
843    for (prompt, value) in collect_prompt_fields(&provider, service, known_prompts)? {
844        let paths = resolve_prompt_paths(config_root, service, prompt.field, &value)?;
845        let markdown = read_markdown_files(&paths, service, prompt.field)?;
846        prompts.insert(
847            prompt.id.to_string(),
848            PromptConfig {
849                id: prompt.id.to_string(),
850                paths,
851                markdown,
852            },
853        );
854    }
855    Ok(prompts)
856}
857
858fn resolve_agent_prompt_path(agents_dir: &Path, rc_conf: &RcConf, agent: &str) -> Path<'static> {
859    for candidate in rc_conf.alias_lookup_order(agent).0 {
860        let path = agents_dir.join(format!("{candidate}.md")).into_owned();
861        if path.is_file() {
862            return path;
863        }
864    }
865    agents_dir.join(format!("{agent}.md")).into_owned()
866}
867
868fn collect_prompt_fields(
869    provider: &impl VariableProvider,
870    scope: &str,
871    known_prompts: &[KnownPromptField],
872) -> Result<Vec<(KnownPromptField, String)>, SError> {
873    let mut prompts = Vec::new();
874    for prompt in known_prompts {
875        if let Some(value) = lookup_expanded(provider, scope, prompt.field)? {
876            prompts.push((*prompt, value));
877        }
878    }
879    Ok(prompts)
880}
881
882fn resolve_prompt_paths(
883    config_root: &Path,
884    scope: &str,
885    field: &str,
886    value: &str,
887) -> Result<Vec<Path<'static>>, SError> {
888    let mut paths = Vec::new();
889    for component in value.split(':') {
890        let component = component.trim();
891        if component.is_empty() {
892            continue;
893        }
894        let path = resolve_prompt_path_component(config_root, component);
895        let path = Path::try_from(path).map_err(|err| {
896            invalid_config_field(
897                scope,
898                field,
899                value,
900                format!("prompt path {component:?} is not valid UTF-8: {err:?}"),
901            )
902        })?;
903        if !path.is_file() {
904            return Err(SError::new("config")
905                .with_code("missing_prompt_file")
906                .with_message("configured prompt markdown file does not exist")
907                .with_string_field("scope", scope)
908                .with_string_field("field", field)
909                .with_string_field("path", path.as_str()));
910        }
911        paths.push(path.into_owned());
912    }
913    if paths.is_empty() {
914        return Err(invalid_config_field(
915            scope,
916            field,
917            value,
918            "expected one or more colon-separated markdown file paths",
919        ));
920    }
921    Ok(paths)
922}
923
924fn resolve_prompt_path_component(config_root: &Path, component: &str) -> std::path::PathBuf {
925    if let Some(rest) = component.strip_prefix("~/")
926        && let Ok(home) = std::env::var("HOME")
927    {
928        return std::path::PathBuf::from(home).join(rest);
929    }
930
931    let path = std::path::PathBuf::from(component);
932    if path.is_absolute() {
933        path
934    } else {
935        std::path::PathBuf::from(config_root.as_str()).join(path)
936    }
937}
938
939fn read_markdown_files(
940    paths: &[Path<'static>],
941    scope: &str,
942    field: &str,
943) -> Result<String, SError> {
944    let mut output = String::new();
945    for path in paths {
946        let content = read_utf8_file(path, scope, field)?;
947        if !output.is_empty() {
948            output.push_str("\n\n");
949        }
950        output.push_str(content.trim_end());
951    }
952    if !output.is_empty() {
953        output.push('\n');
954    }
955    Ok(output)
956}
957
958fn lookup_expanded(
959    provider: &impl VariableProvider,
960    scope: &str,
961    key: &str,
962) -> Result<Option<String>, SError> {
963    let Some(value) = provider.lookup(key) else {
964        return Ok(None);
965    };
966    let expanded = expand_config_value(provider, scope, key, &value)?;
967    Ok(Some(expanded))
968}
969
970fn lookup_split_field(
971    provider: &impl VariableProvider,
972    scope: &str,
973    key: &str,
974) -> Result<Vec<String>, SError> {
975    let Some(value) = lookup_expanded(provider, scope, key)? else {
976        return Ok(vec![]);
977    };
978    shvar::split(&value).map_err(|err| invalid_config_field(scope, key, &value, format!("{err:?}")))
979}
980
981fn lookup_nonempty_field(
982    provider: &impl VariableProvider,
983    scope: &str,
984    key: &str,
985) -> Result<Option<String>, SError> {
986    Ok(lookup_expanded(provider, scope, key)?.and_then(|value| {
987        let value = value.trim().to_string();
988        (!value.is_empty()).then_some(value)
989    }))
990}
991
992fn lookup_bool_field(
993    provider: &impl VariableProvider,
994    scope: &str,
995    key: &str,
996    default: bool,
997) -> Result<bool, SError> {
998    let Some(value) = lookup_expanded(provider, scope, key)? else {
999        return Ok(default);
1000    };
1001    parse_bool_field(scope, key, &value)
1002}
1003
1004fn parse_u32_field(scope: &str, field: &str, value: &str) -> Result<u32, SError> {
1005    value
1006        .trim()
1007        .parse::<u32>()
1008        .map_err(|err| invalid_config_field(scope, field, value, err.to_string()))
1009}
1010
1011fn parse_u64_field(scope: &str, field: &str, value: &str) -> Result<u64, SError> {
1012    value
1013        .trim()
1014        .parse::<u64>()
1015        .map_err(|err| invalid_config_field(scope, field, value, err.to_string()))
1016}
1017
1018fn parse_f64_field(scope: &str, field: &str, value: &str) -> Result<f64, SError> {
1019    let parsed = value
1020        .trim()
1021        .parse::<f64>()
1022        .map_err(|err| invalid_config_field(scope, field, value, err.to_string()))?;
1023    if parsed.is_finite() && parsed > 0.0 {
1024        Ok(parsed)
1025    } else {
1026        Err(invalid_config_field(
1027            scope,
1028            field,
1029            value,
1030            "expected a positive dollar amount",
1031        ))
1032    }
1033}
1034
1035fn parse_unit_interval_field(scope: &str, field: &str, value: &str) -> Result<f32, SError> {
1036    let parsed = value
1037        .trim()
1038        .parse::<f32>()
1039        .map_err(|err| invalid_config_field(scope, field, value, err.to_string()))?;
1040    if parsed.is_finite() && (0.0..=1.0).contains(&parsed) {
1041        Ok(parsed)
1042    } else {
1043        Err(invalid_config_field(
1044            scope,
1045            field,
1046            value,
1047            "expected a finite value between 0.0 and 1.0",
1048        ))
1049    }
1050}
1051
1052fn parse_bool_field(scope: &str, field: &str, value: &str) -> Result<bool, SError> {
1053    match value.trim().to_ascii_lowercase().as_str() {
1054        "1" | "on" | "true" | "yes" | "enable" | "enabled" => Ok(true),
1055        "0" | "off" | "false" | "no" | "disable" | "disabled" => Ok(false),
1056        _ => Err(invalid_config_field(
1057            scope,
1058            field,
1059            value,
1060            "expected one of yes/no, true/false, on/off, or 1/0",
1061        )),
1062    }
1063}
1064
1065fn parse_thinking_budget(
1066    scope: &str,
1067    field: &str,
1068    value: &str,
1069) -> Result<Option<ThinkingConfig>, SError> {
1070    match value.trim().to_ascii_lowercase().as_str() {
1071        "off" | "false" | "no" | "disable" | "disabled" => Ok(None),
1072        "on" | "true" | "yes" | "enable" | "enabled" => {
1073            Ok(Some(ThinkingConfig::enabled(DEFAULT_THINKING_BUDGET)))
1074        }
1075        "adaptive" => Ok(Some(ThinkingConfig::adaptive())),
1076        _ => parse_u32_field(scope, field, value).map(|v| Some(ThinkingConfig::enabled(v))),
1077    }
1078}
1079
1080fn invalid_config_field(
1081    scope: &str,
1082    field: &str,
1083    value: &str,
1084    reason: impl Into<String>,
1085) -> SError {
1086    let reason = reason.into();
1087    SError::new("config")
1088        .with_code("invalid_config_field")
1089        .with_message("failed to derive config from rc_conf")
1090        .with_string_field("scope", scope)
1091        .with_string_field("field", field)
1092        .with_string_field("value", value)
1093        .with_string_field("reason", &reason)
1094}
1095
1096fn expand_config_value(
1097    provider: &impl VariableProvider,
1098    scope: &str,
1099    field: &str,
1100    value: &str,
1101) -> Result<String, SError> {
1102    let mut current = value.to_string();
1103    for _ in 0..128 {
1104        let next = expand_config_value_once(provider, scope, field, &current)?;
1105        if next == current {
1106            return Ok(next);
1107        }
1108        current = next;
1109    }
1110    Err(invalid_config_field(
1111        scope,
1112        field,
1113        value,
1114        "variable expansion exceeded recursion limit",
1115    ))
1116}
1117
1118fn expand_config_value_once(
1119    provider: &impl VariableProvider,
1120    scope: &str,
1121    field: &str,
1122    value: &str,
1123) -> Result<String, SError> {
1124    let mut output = String::with_capacity(value.len());
1125    let mut chars = value.chars().peekable();
1126    while let Some(ch) = chars.next() {
1127        if ch != '$' {
1128            output.push(ch);
1129            continue;
1130        }
1131        match chars.peek().copied() {
1132            Some('$') => {
1133                output.push('$');
1134                chars.next();
1135            }
1136            Some('{') => {
1137                chars.next();
1138                let ident = parse_braced_identifier(&mut chars, scope, field, value)?;
1139                output.push_str(&provider.lookup(&ident).unwrap_or_default());
1140            }
1141            Some(next) if is_identifier_start(next) => {
1142                let ident = parse_identifier(&mut chars);
1143                output.push_str(&provider.lookup(&ident).unwrap_or_default());
1144            }
1145            _ => {
1146                output.push('$');
1147            }
1148        }
1149    }
1150    Ok(output)
1151}
1152
1153fn parse_braced_identifier(
1154    chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
1155    scope: &str,
1156    field: &str,
1157    value: &str,
1158) -> Result<String, SError> {
1159    let Some(first) = chars.next() else {
1160        return Err(invalid_config_field(
1161            scope,
1162            field,
1163            value,
1164            "unterminated variable expansion",
1165        ));
1166    };
1167    if !is_identifier_start(first) {
1168        return Err(invalid_config_field(
1169            scope,
1170            field,
1171            value,
1172            "invalid variable name in expansion",
1173        ));
1174    }
1175
1176    let mut ident = String::from(first);
1177    loop {
1178        match chars.next() {
1179            Some('}') => return Ok(ident),
1180            Some(ch) if is_identifier_continue(ch) => ident.push(ch),
1181            Some(_) => {
1182                return Err(invalid_config_field(
1183                    scope,
1184                    field,
1185                    value,
1186                    "invalid variable name in expansion",
1187                ));
1188            }
1189            None => {
1190                return Err(invalid_config_field(
1191                    scope,
1192                    field,
1193                    value,
1194                    "unterminated variable expansion",
1195                ));
1196            }
1197        }
1198    }
1199}
1200
1201fn parse_identifier(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> String {
1202    let mut ident = String::new();
1203    while let Some(ch) = chars.peek().copied() {
1204        if !is_identifier_continue(ch) {
1205            break;
1206        }
1207        ident.push(ch);
1208        chars.next();
1209    }
1210    ident
1211}
1212
1213fn is_identifier_start(ch: char) -> bool {
1214    ch == '_' || ch.is_ascii_alphabetic()
1215}
1216
1217fn is_identifier_continue(ch: char) -> bool {
1218    ch == '_' || ch.is_ascii_alphanumeric()
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223    use std::fs;
1224
1225    use claudius::KnownModel;
1226
1227    use super::*;
1228    use crate::test_support::{
1229        unique_temp_dir, write_default_tool_manifest, write_tool_manifest,
1230        write_tool_manifest_with_schema, write_tool_script,
1231    };
1232
1233    #[test]
1234    fn load_config_from_readme_style_files() {
1235        let root = unique_temp_dir("config");
1236        fs::create_dir_all(root.join("agents").as_str()).unwrap();
1237
1238        fs::write(
1239            root.join("agents.conf").as_str(),
1240            r#"
1241ROLE='principal engineer'
1242build_ENABLED="YES"
1243plan_ENABLED="MANUAL"
1244evil_ENABLED="NO"
1245
1246build_NAME="Let's go ${ROLE}"
1247build_DESC="buildit"
1248build_TOOLS='format bash'
1249build_SKILLS='* "rust docs"'
1250
1251plan_MODEL=claude-sonnet-4-5
1252plan_SYSTEM="You are ${ROLE}"
1253plan_MAX_TOKENS=8192
1254plan_TEMPERATURE=0.7
1255plan_TOP_P=0.9
1256plan_TOP_K=40
1257plan_STOP_SEQUENCES='END "two words"'
1258plan_THINKING=on
1259plan_NO_COLOR=yes
1260plan_SESSION_SPEND=5.00
1261plan_CACHING_ENABLED=off
1262"#,
1263        )
1264        .unwrap();
1265        fs::write(
1266            root.join("tools.conf").as_str(),
1267            r#"
1268fmt_ENABLED="YES"
1269bash_ENABLED="YES"
1270
1271format_INHERIT="YES"
1272format_ALIASES="fmt"
1273"#,
1274        )
1275        .unwrap();
1276        write_tool_contract(&root, "fmt", "Format files in the workspace.");
1277        write_tool_contract(&root, "bash", "Run a shell command.");
1278        fs::write(
1279            root.join("agents/build.md").as_str(),
1280            "# Build\n\nYou are an expert builder.\n",
1281        )
1282        .unwrap();
1283        fs::write(
1284            root.join("agents/plan.md").as_str(),
1285            "# Plan\n\nYou are an expert planner.\n",
1286        )
1287        .unwrap();
1288
1289        let config = Config::load(&root).unwrap();
1290
1291        assert_eq!(config.agents.len(), 4);
1292        assert_eq!(config.tools.len(), 3);
1293
1294        let build = config.agents.get("build").unwrap();
1295        assert_eq!(build.enabled, SwitchPosition::Yes);
1296        assert_eq!(
1297            build.display_name.as_deref(),
1298            Some("Let's go principal engineer")
1299        );
1300        assert_eq!(build.description.as_deref(), Some("buildit"));
1301        assert_eq!(build.tools, vec!["format".to_string(), "bash".to_string()]);
1302        assert_eq!(build.skills, vec!["*".to_string(), "rust docs".to_string()]);
1303        assert_eq!(
1304            build.prompt_markdown.as_deref(),
1305            Some("# Build\n\nYou are an expert builder.\n")
1306        );
1307        assert_eq!(
1308            build.chat_config.system_prompt_text(),
1309            Some("# Build\n\nYou are an expert builder.\n")
1310        );
1311
1312        let plan = config.agents.get("plan").unwrap();
1313        assert_eq!(plan.enabled, SwitchPosition::Manual);
1314        assert_eq!(
1315            plan.chat_config.model(),
1316            Model::Known(KnownModel::ClaudeSonnet45)
1317        );
1318        assert_eq!(
1319            plan.chat_config.system_prompt_text(),
1320            Some("You are principal engineer")
1321        );
1322        assert_eq!(plan.chat_config.max_tokens(), 8192);
1323        assert_eq!(plan.chat_config.template.temperature, Some(0.7));
1324        assert_eq!(plan.chat_config.template.top_p, Some(0.9));
1325        assert_eq!(plan.chat_config.template.top_k, Some(40));
1326        assert_eq!(
1327            plan.chat_config.stop_sequences(),
1328            &["END".to_string(), "two words".to_string()]
1329        );
1330        assert_eq!(
1331            plan.chat_config.thinking_budget(),
1332            Some(DEFAULT_THINKING_BUDGET)
1333        );
1334        assert!(!plan.chat_config.use_color);
1335        assert!(plan.chat_config.session_spend.is_some());
1336        assert!(!plan.chat_config.caching_enabled);
1337
1338        let evil = config.agents.get("evil").unwrap();
1339        assert_eq!(evil.enabled, SwitchPosition::No);
1340        assert!(evil.prompt_markdown.is_none());
1341        assert_eq!(evil.prompt_path, root.join("agents/evil.md"));
1342
1343        let plan_caching = config.agents.get("plan-caching").unwrap();
1344        assert_eq!(plan_caching.enabled, SwitchPosition::No);
1345        assert!(plan_caching.prompt_markdown.is_none());
1346        assert_eq!(
1347            plan_caching.prompt_path,
1348            root.join("agents/plan-caching.md")
1349        );
1350
1351        let fmt = config.tools.get("fmt").unwrap();
1352        assert_eq!(fmt.enabled, SwitchPosition::Yes);
1353        assert!(!fmt.confirm_preview);
1354        assert_eq!(fmt.executable_path, Some(root.join("tools/fmt")));
1355        assert_eq!(fmt.manifest_path, root.join("tools/fmt.json"));
1356        let fmt_manifest = fmt.manifest.as_ref().unwrap();
1357        assert_eq!(fmt_manifest.protocol_version, TOOL_PROTOCOL_VERSION);
1358        assert_eq!(fmt_manifest.description, "Format files in the workspace.");
1359
1360        let format = config.tools.get("format").unwrap();
1361        assert_eq!(format.enabled, SwitchPosition::Yes);
1362        assert_eq!(format.executable_path, Some(root.join("tools/fmt")));
1363        assert_eq!(format.manifest_path, root.join("tools/fmt.json"));
1364        assert_eq!(
1365            format.manifest.as_ref().unwrap().description,
1366            "Format files in the workspace."
1367        );
1368
1369        let bash = config.tools.get("bash").unwrap();
1370        assert_eq!(bash.enabled, SwitchPosition::Yes);
1371        assert!(!bash.confirm_preview);
1372        assert!(bash.executable_path.is_none());
1373    }
1374
1375    #[test]
1376    fn named_prompt_sets_load_from_markdown_files() {
1377        let root = unique_temp_dir("config");
1378        fs::create_dir_all(root.join("agents").as_str()).unwrap();
1379        fs::create_dir_all(root.join("prompts").as_str()).unwrap();
1380        fs::create_dir_all(root.join("tool-prompts").as_str()).unwrap();
1381
1382        fs::write(
1383            root.join("agents.conf").as_str(),
1384            r#"
1385build_ENABLED="YES"
1386build_PROMPT='agents/base.md:agents/build.md'
1387build_PROMPT_COMPACTION='prompts/common.md:prompts/compact.md'
1388"#,
1389        )
1390        .unwrap();
1391        fs::write(
1392            root.join("tools.conf").as_str(),
1393            r#"
1394fmt_ENABLED="YES"
1395fmt_PROMPT='tool-prompts/base.md:tool-prompts/review.md'
1396"#,
1397        )
1398        .unwrap();
1399        fs::write(root.join("agents/base.md").as_str(), "# Base\n").unwrap();
1400        fs::write(
1401            root.join("agents/build.md").as_str(),
1402            "Use the build plan.\n",
1403        )
1404        .unwrap();
1405        fs::write(
1406            root.join("prompts/common.md").as_str(),
1407            "Summarize the session.\n",
1408        )
1409        .unwrap();
1410        fs::write(
1411            root.join("prompts/compact.md").as_str(),
1412            "Write only the handoff.\n",
1413        )
1414        .unwrap();
1415        fs::write(
1416            root.join("tool-prompts/base.md").as_str(),
1417            "Review output.\n",
1418        )
1419        .unwrap();
1420        fs::write(
1421            root.join("tool-prompts/review.md").as_str(),
1422            "Focus on bugs.\n",
1423        )
1424        .unwrap();
1425        write_tool_contract(&root, "fmt", "Format files.");
1426
1427        let config = Config::load(&root).unwrap();
1428
1429        let build = config.agents.get("build").unwrap();
1430        assert_eq!(
1431            build.prompt_paths,
1432            vec![root.join("agents/base.md"), root.join("agents/build.md")]
1433        );
1434        assert_eq!(
1435            build.prompt_markdown.as_deref(),
1436            Some("# Base\n\nUse the build plan.\n")
1437        );
1438        let compaction = build.prompts.get("COMPACTION").unwrap();
1439        assert_eq!(
1440            compaction.paths,
1441            vec![
1442                root.join("prompts/common.md"),
1443                root.join("prompts/compact.md"),
1444            ]
1445        );
1446        assert_eq!(
1447            compaction.markdown,
1448            "Summarize the session.\n\nWrite only the handoff.\n"
1449        );
1450
1451        let fmt = config.tools.get("fmt").unwrap();
1452        let tool_prompt = fmt.prompts.get(SYSTEM_PROMPT_ID).unwrap();
1453        assert_eq!(
1454            tool_prompt.paths,
1455            vec![
1456                root.join("tool-prompts/base.md"),
1457                root.join("tool-prompts/review.md"),
1458            ]
1459        );
1460        assert_eq!(tool_prompt.markdown, "Review output.\n\nFocus on bugs.\n");
1461
1462        fs::remove_dir_all(root.as_str()).unwrap();
1463    }
1464
1465    #[test]
1466    fn unknown_prompt_keys_are_ignored() {
1467        let root = unique_temp_dir("config");
1468        fs::create_dir_all(root.join("agents").as_str()).unwrap();
1469        fs::write(
1470            root.join("agents.conf").as_str(),
1471            r#"
1472build_ENABLED="YES"
1473build_PROMPT_REVIEW='agents/missing.md'
1474"#,
1475        )
1476        .unwrap();
1477        fs::write(
1478            root.join("tools.conf").as_str(),
1479            r#"
1480fmt_ENABLED="YES"
1481fmt_PROMPT_REVIEW='tool-prompts/missing.md'
1482"#,
1483        )
1484        .unwrap();
1485        write_tool_contract(&root, "fmt", "Format files.");
1486
1487        let config = Config::load(&root).unwrap();
1488        assert!(config.agents.get("build").unwrap().prompts.is_empty());
1489        assert!(config.tools.get("fmt").unwrap().prompts.is_empty());
1490
1491        fs::remove_dir_all(root.as_str()).unwrap();
1492    }
1493
1494    #[test]
1495    fn missing_named_prompt_file_is_reported() {
1496        let root = unique_temp_dir("config");
1497        fs::create_dir_all(root.join("agents").as_str()).unwrap();
1498        fs::write(
1499            root.join("agents.conf").as_str(),
1500            "build_ENABLED=YES\nbuild_PROMPT='agents/missing.md'\n",
1501        )
1502        .unwrap();
1503        fs::write(root.join("tools.conf").as_str(), "").unwrap();
1504
1505        let err = Config::load(&root).unwrap_err().to_string();
1506        assert!(err.contains("missing_prompt_file"));
1507        assert!(err.contains("agents/missing.md"));
1508
1509        fs::remove_dir_all(root.as_str()).unwrap();
1510    }
1511
1512    #[test]
1513    fn tool_confirm_preview_defaults_and_parses_bool() {
1514        let root = unique_temp_dir("config");
1515        fs::create_dir_all(root.join("agents").as_str()).unwrap();
1516        fs::write(
1517            root.join("agents.conf").as_str(),
1518            "build_ENABLED=YES\nbuild_TOOLS='fmt plain'\n",
1519        )
1520        .unwrap();
1521        fs::write(
1522            root.join("tools.conf").as_str(),
1523            "fmt_ENABLED=YES\nfmt_CONFIRM=YES\nplain_ENABLED=YES\n",
1524        )
1525        .unwrap();
1526        write_tool_contract(&root, "fmt", "Format files.");
1527        write_tool_contract(&root, "plain", "Plain tool.");
1528
1529        let config = Config::load(&root).unwrap();
1530        assert!(config.tools.get("fmt").unwrap().confirm_preview);
1531        assert!(!config.tools.get("plain").unwrap().confirm_preview);
1532
1533        fs::remove_dir_all(root.as_str()).unwrap();
1534    }
1535
1536    #[test]
1537    fn invalid_tool_confirm_preview_bool_is_an_error() {
1538        let root = unique_temp_dir("config");
1539        fs::create_dir_all(root.join("agents").as_str()).unwrap();
1540        fs::write(
1541            root.join("agents.conf").as_str(),
1542            "build_ENABLED=YES\nbuild_TOOLS='fmt'\n",
1543        )
1544        .unwrap();
1545        fs::write(
1546            root.join("tools.conf").as_str(),
1547            "fmt_ENABLED=YES\nfmt_CONFIRM=maybe\n",
1548        )
1549        .unwrap();
1550        write_tool_contract(&root, "fmt", "Format files.");
1551
1552        let err = Config::load(&root)
1553            .expect_err("invalid CONFIRM value should fail")
1554            .to_string();
1555        assert!(err.contains("CONFIRM"));
1556
1557        fs::remove_dir_all(root.as_str()).unwrap();
1558    }
1559
1560    #[test]
1561    fn missing_top_level_config_file_is_an_error() {
1562        let root = unique_temp_dir("config");
1563        fs::create_dir_all(root.as_str()).unwrap();
1564        fs::write(root.join("agents.conf").as_str(), "build_ENABLED=YES\n").unwrap();
1565
1566        let err = Config::load(&root).unwrap_err().to_string();
1567        assert!(err.contains("missing_config_file"));
1568        assert!(err.contains("tools.conf"));
1569    }
1570
1571    #[test]
1572    fn invalid_agent_field_is_reported() {
1573        let root = unique_temp_dir("config");
1574        fs::create_dir_all(root.join("agents").as_str()).unwrap();
1575        fs::write(
1576            root.join("agents.conf").as_str(),
1577            "build_ENABLED=YES\nbuild_TOP_P=wat\n",
1578        )
1579        .unwrap();
1580        fs::write(root.join("tools.conf").as_str(), "").unwrap();
1581        fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
1582
1583        let err = Config::load(&root).unwrap_err().to_string();
1584        assert!(err.contains("invalid_config_field"));
1585        assert!(err.contains("TOP_P"));
1586        assert!(err.contains("wat"));
1587    }
1588
1589    #[test]
1590    fn missing_tool_executable_is_reported_during_config_load() {
1591        let root = unique_temp_dir("config");
1592        fs::create_dir_all(root.as_str()).unwrap();
1593        fs::write(root.join("agents.conf").as_str(), "").unwrap();
1594        fs::write(root.join("tools.conf").as_str(), "fmt_ENABLED=YES\n").unwrap();
1595        write_tool_manifest(&root, "fmt", TOOL_PROTOCOL_VERSION, "Format files.");
1596
1597        let err = Config::load(&root).unwrap_err().to_string();
1598        assert!(err.contains("missing_tool_executable"));
1599        assert!(err.contains("fmt"));
1600    }
1601
1602    #[test]
1603    fn missing_tool_manifest_is_reported_during_config_load() {
1604        let root = unique_temp_dir("config");
1605        fs::create_dir_all(root.as_str()).unwrap();
1606        fs::write(root.join("agents.conf").as_str(), "").unwrap();
1607        fs::write(root.join("tools.conf").as_str(), "fmt_ENABLED=YES\n").unwrap();
1608        write_tool_script(&root, "fmt", "#!/bin/sh\nexit 0\n");
1609
1610        let err = Config::load(&root).unwrap_err().to_string();
1611        assert!(err.contains("missing_tool_manifest"));
1612        assert!(err.contains("fmt"));
1613    }
1614
1615    #[test]
1616    fn missing_builtin_tool_manifest_is_allowed_during_config_load() {
1617        let root = unique_temp_dir("config");
1618        fs::create_dir_all(root.as_str()).unwrap();
1619        fs::write(root.join("agents.conf").as_str(), "").unwrap();
1620        fs::write(
1621            root.join("tools.conf").as_str(),
1622            "bash_ENABLED=YES\nedit_ENABLED=YES\n",
1623        )
1624        .unwrap();
1625        write_tool_script(&root, "edit", "#!/bin/sh\nexit 0\n");
1626
1627        let config = Config::load(&root).unwrap();
1628        let bash = config.tools.get("bash").unwrap();
1629        assert!(bash.executable_path.is_none());
1630        assert_eq!(bash.manifest_path, root.join("tools/bash.json"));
1631        assert!(bash.manifest.is_none());
1632
1633        let edit = config.tools.get("edit").unwrap();
1634        assert_eq!(edit.executable_path, Some(root.join("tools/edit")));
1635        assert_eq!(edit.manifest_path, root.join("tools/edit.json"));
1636        assert!(edit.manifest.is_none());
1637    }
1638
1639    #[test]
1640    fn missing_builtin_edit_executable_is_reported_during_config_load() {
1641        let root = unique_temp_dir("config");
1642        fs::create_dir_all(root.as_str()).unwrap();
1643        fs::write(root.join("agents.conf").as_str(), "").unwrap();
1644        fs::write(
1645            root.join("tools.conf").as_str(),
1646            "bash_ENABLED=YES\nedit_ENABLED=YES\n",
1647        )
1648        .unwrap();
1649
1650        let err = Config::load(&root).unwrap_err().to_string();
1651        assert!(err.contains("missing_tool_executable"));
1652        assert!(err.contains("edit"));
1653    }
1654
1655    #[test]
1656    fn invalid_tool_manifest_json_is_reported_during_config_load() {
1657        let root = unique_temp_dir("config");
1658        fs::create_dir_all(root.as_str()).unwrap();
1659        fs::write(root.join("agents.conf").as_str(), "").unwrap();
1660        fs::write(root.join("tools.conf").as_str(), "fmt_ENABLED=YES\n").unwrap();
1661        write_tool_script(&root, "fmt", "#!/bin/sh\nexit 0\n");
1662        fs::write(root.join("tools/fmt.json").as_str(), "{ not valid json").unwrap();
1663
1664        let err = Config::load(&root).unwrap_err().to_string();
1665        assert!(err.contains("invalid_tool_manifest_json"));
1666    }
1667
1668    #[test]
1669    fn unsupported_tool_protocol_version_is_reported_during_config_load() {
1670        let root = unique_temp_dir("config");
1671        fs::create_dir_all(root.as_str()).unwrap();
1672        fs::write(root.join("agents.conf").as_str(), "").unwrap();
1673        fs::write(root.join("tools.conf").as_str(), "fmt_ENABLED=YES\n").unwrap();
1674        write_tool_script(&root, "fmt", "#!/bin/sh\nexit 0\n");
1675        write_tool_manifest(&root, "fmt", 2, "Format files.");
1676
1677        let err = Config::load(&root).unwrap_err().to_string();
1678        assert!(err.contains("unsupported_tool_protocol_version"));
1679    }
1680
1681    #[test]
1682    fn invalid_tool_id_is_reported_during_config_load() {
1683        let root = unique_temp_dir("config");
1684        fs::create_dir_all(root.as_str()).unwrap();
1685        fs::write(root.join("agents.conf").as_str(), "").unwrap();
1686        let long_name = "a".repeat(65);
1687        fs::write(
1688            root.join("tools.conf").as_str(),
1689            format!("{long_name}_ENABLED=YES\n"),
1690        )
1691        .unwrap();
1692
1693        let err = Config::load(&root).unwrap_err().to_string();
1694        assert!(err.contains("invalid_tool_id"));
1695        assert!(err.contains(&long_name));
1696    }
1697
1698    #[cfg(unix)]
1699    #[test]
1700    fn tool_must_be_executable_during_config_load() {
1701        let root = unique_temp_dir("config");
1702        fs::create_dir_all(root.as_str()).unwrap();
1703        fs::write(root.join("agents.conf").as_str(), "").unwrap();
1704        fs::write(root.join("tools.conf").as_str(), "fmt_ENABLED=YES\n").unwrap();
1705        fs::create_dir_all(root.join("tools").as_str()).unwrap();
1706        let executable = root.join("tools/fmt").into_owned();
1707        fs::write(executable.as_str(), "#!/bin/sh\nexit 0\n").unwrap();
1708        write_tool_manifest(&root, "fmt", TOOL_PROTOCOL_VERSION, "Format files.");
1709
1710        let err = Config::load(&root).unwrap_err().to_string();
1711        assert!(err.contains("tool_not_executable"));
1712        assert!(err.contains("fmt"));
1713    }
1714
1715    #[test]
1716    fn empty_tool_description_is_reported_during_config_load() {
1717        let root = unique_temp_dir("config");
1718        fs::create_dir_all(root.as_str()).unwrap();
1719        fs::write(root.join("agents.conf").as_str(), "").unwrap();
1720        fs::write(root.join("tools.conf").as_str(), "fmt_ENABLED=YES\n").unwrap();
1721        write_tool_script(&root, "fmt", "#!/bin/sh\nexit 0\n");
1722        write_tool_manifest(&root, "fmt", TOOL_PROTOCOL_VERSION, "   ");
1723
1724        let err = Config::load(&root).unwrap_err().to_string();
1725        assert!(err.contains("invalid_tool_manifest"));
1726        assert!(err.contains("description"));
1727    }
1728
1729    #[test]
1730    fn non_object_input_schema_is_reported_during_config_load() {
1731        let root = unique_temp_dir("config");
1732        fs::create_dir_all(root.as_str()).unwrap();
1733        fs::write(root.join("agents.conf").as_str(), "").unwrap();
1734        fs::write(root.join("tools.conf").as_str(), "fmt_ENABLED=YES\n").unwrap();
1735        write_tool_script(&root, "fmt", "#!/bin/sh\nexit 0\n");
1736        write_tool_manifest_with_schema(
1737            &root,
1738            "fmt",
1739            TOOL_PROTOCOL_VERSION,
1740            "Format files.",
1741            serde_json::json!("not an object"),
1742        );
1743
1744        let err = Config::load(&root).unwrap_err().to_string();
1745        assert!(err.contains("invalid_tool_manifest"));
1746        assert!(err.contains("input_schema"));
1747    }
1748
1749    #[test]
1750    fn undefined_tool_alias_target_is_reported_during_config_load() {
1751        let root = unique_temp_dir("config");
1752        fs::create_dir_all(root.as_str()).unwrap();
1753        fs::write(root.join("agents.conf").as_str(), "").unwrap();
1754        fs::write(
1755            root.join("tools.conf").as_str(),
1756            "format_ALIASES=fmt\nformat_INHERIT=YES\n",
1757        )
1758        .unwrap();
1759
1760        let err = Config::load(&root).unwrap_err().to_string();
1761        assert!(err.contains("unknown_tool"));
1762        assert!(err.contains("fmt"));
1763    }
1764
1765    #[test]
1766    fn load_skills_from_skills_directory() {
1767        let root = unique_temp_dir("config");
1768        fs::create_dir_all(root.join("agents").as_str()).unwrap();
1769        fs::create_dir_all(root.join("skills/rust").as_str()).unwrap();
1770        fs::create_dir_all(root.join("skills/python").as_str()).unwrap();
1771        fs::create_dir_all(root.join("skills/empty-dir").as_str()).unwrap();
1772
1773        fs::write(root.join("agents.conf").as_str(), "").unwrap();
1774        fs::write(root.join("tools.conf").as_str(), "").unwrap();
1775        fs::write(
1776            root.join("skills/rust/SKILL.md").as_str(),
1777            "# Rust\n\nYou are a Rust expert.\n",
1778        )
1779        .unwrap();
1780        fs::write(
1781            root.join("skills/python/SKILL.md").as_str(),
1782            "# Python\n\nYou know Python.\n",
1783        )
1784        .unwrap();
1785        // Bare file in skills/ should be ignored.
1786        fs::write(root.join("skills/notes.txt").as_str(), "not a skill").unwrap();
1787        // Subdirectory without SKILL.md should be ignored.
1788
1789        let config = Config::load(&root).unwrap();
1790        assert_eq!(config.skills.len(), 2);
1791
1792        let rust_skill = config.skills.get("rust").unwrap();
1793        assert_eq!(rust_skill.id, "rust");
1794        assert_eq!(rust_skill.content, "# Rust\n\nYou are a Rust expert.\n");
1795        assert_eq!(rust_skill.path, root.join("skills/rust/SKILL.md"));
1796
1797        let python_skill = config.skills.get("python").unwrap();
1798        assert_eq!(python_skill.id, "python");
1799        assert_eq!(python_skill.content, "# Python\n\nYou know Python.\n");
1800    }
1801
1802    #[test]
1803    fn missing_skills_directory_produces_empty_skills() {
1804        let root = unique_temp_dir("config");
1805        fs::create_dir_all(root.as_str()).unwrap();
1806        fs::write(root.join("agents.conf").as_str(), "").unwrap();
1807        fs::write(root.join("tools.conf").as_str(), "").unwrap();
1808
1809        let config = Config::load(&root).unwrap();
1810        assert!(config.skills.is_empty());
1811    }
1812
1813    #[test]
1814    fn load_skills_earlier_directory_wins() {
1815        let dir_a = unique_temp_dir("skills-a");
1816        let dir_b = unique_temp_dir("skills-b");
1817        fs::create_dir_all(dir_a.join("rust").as_str()).unwrap();
1818        fs::create_dir_all(dir_a.join("go").as_str()).unwrap();
1819        fs::create_dir_all(dir_b.join("rust").as_str()).unwrap();
1820        fs::create_dir_all(dir_b.join("python").as_str()).unwrap();
1821
1822        fs::write(dir_a.join("rust/SKILL.md").as_str(), "# Rust from A\n").unwrap();
1823        fs::write(dir_a.join("go/SKILL.md").as_str(), "# Go from A\n").unwrap();
1824        fs::write(
1825            dir_b.join("rust/SKILL.md").as_str(),
1826            "# Rust from B (should be shadowed)\n",
1827        )
1828        .unwrap();
1829        fs::write(dir_b.join("python/SKILL.md").as_str(), "# Python from B\n").unwrap();
1830
1831        let dirs = vec![dir_a.clone(), dir_b.clone()];
1832        let skills = load_skills(&dirs).unwrap();
1833        assert_eq!(skills.len(), 3);
1834
1835        let rust_skill = skills.get("rust").unwrap();
1836        assert_eq!(rust_skill.content, "# Rust from A\n");
1837        assert_eq!(rust_skill.path, dir_a.join("rust/SKILL.md"));
1838
1839        assert_eq!(skills.get("go").unwrap().content, "# Go from A\n");
1840
1841        let python_skill = skills.get("python").unwrap();
1842        assert_eq!(python_skill.content, "# Python from B\n");
1843        assert_eq!(python_skill.path, dir_b.join("python/SKILL.md"));
1844    }
1845
1846    #[test]
1847    fn load_skills_skips_nonexistent_directories() {
1848        let dir_exists = unique_temp_dir("skills-exists");
1849        let dir_missing = unique_temp_dir("skills-missing");
1850        fs::create_dir_all(dir_exists.join("rust").as_str()).unwrap();
1851        fs::write(dir_exists.join("rust/SKILL.md").as_str(), "# Rust\n").unwrap();
1852
1853        let dirs = vec![dir_missing, dir_exists];
1854        let skills = load_skills(&dirs).unwrap();
1855        assert_eq!(skills.len(), 1);
1856        assert!(skills.contains_key("rust"));
1857    }
1858
1859    fn write_tool_contract(root: &Path, tool: &str, description: &str) {
1860        write_tool_script(root, tool, "#!/bin/sh\nexit 0\n");
1861        write_default_tool_manifest(root, tool, description);
1862    }
1863}