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;
13
14use claudius::chat::ChatConfig;
15use claudius::{Model, ThinkingConfig};
16use handled::SError;
17use rc_conf::{RcConf, SwitchPosition};
18use serde::Deserialize;
19use shvar::VariableProvider;
20use utf8path::Path;
21
22/// Default token budget for extended thinking.
23pub const DEFAULT_THINKING_BUDGET: u32 = 1024;
24/// Filename for the agent declarations rc.conf file.
25pub const AGENTS_CONF_FILE: &str = "agents.conf";
26/// Filename for the tool declarations rc.conf file.
27pub const TOOLS_CONF_FILE: &str = "tools.conf";
28/// Subdirectory that holds per-agent prompt and configuration files.
29pub const AGENTS_DIR: &str = "agents";
30/// Subdirectory that holds per-tool executables and manifest JSON files.
31pub const TOOLS_DIR: &str = "tools";
32/// Subdirectory that holds skill markdown files.
33pub const SKILLS_DIR: &str = "skills";
34/// Conventional filename for a skill definition.
35pub const SKILL_FILE: &str = "SKILL.md";
36/// Conventional filename for agent-level markdown instructions.
37pub const AGENTS_MD_FILE: &str = "AGENTS.md";
38/// Environment variable that overrides the path to the AGENTS.md file.
39pub const AGENTS_MD_PATH_ENV: &str = "AGENTS_MD_PATH";
40/// Current version of the sid tool protocol.
41pub const TOOL_PROTOCOL_VERSION: u32 = 1;
42
43/// Fully resolved workspace configuration.
44///
45/// Contains all agents, tools, and skills discovered during [`Config::load`],
46/// together with the parsed rc.conf backing stores.
47#[derive(Debug)]
48pub struct Config {
49    /// Root directory from which the configuration was loaded.
50    pub root: Path<'static>,
51    /// Explicit default agent, if one was declared in the agents rc.conf.
52    pub default_agent: Option<String>,
53    /// Agent configurations keyed by agent identifier.
54    pub agents: BTreeMap<String, AgentConfig>,
55    /// Tool configurations keyed by tool identifier.
56    pub tools: BTreeMap<String, ToolConfig>,
57    /// Skill configurations keyed by skill identifier.
58    pub skills: BTreeMap<String, SkillConfig>,
59    pub(crate) agents_rc_conf: RcConf,
60    pub(crate) tools_rc_conf: RcConf,
61}
62
63impl Config {
64    /// Load a workspace configuration from `root`.
65    ///
66    /// Reads `agents.conf` and `tools.conf` from the given directory, resolves
67    /// every agent, tool, and skill referenced in those files, and validates
68    /// tool manifests and executables.
69    ///
70    /// # Errors
71    ///
72    /// Returns an error when a required configuration file is missing, a
73    /// referenced tool executable or manifest cannot be found, or an rc.conf
74    /// entry is malformed.
75    pub fn load(root: &Path) -> Result<Self, SError> {
76        let root = root.clone().into_owned();
77        let agents_conf_path = root.join(AGENTS_CONF_FILE);
78        let tools_conf_path = root.join(TOOLS_CONF_FILE);
79
80        require_file(&agents_conf_path, AGENTS_CONF_FILE)?;
81        require_file(&tools_conf_path, TOOLS_CONF_FILE)?;
82
83        let agents_rc_conf = parse_rc_conf(&agents_conf_path)?;
84        let tools_rc_conf = parse_rc_conf(&tools_conf_path)?;
85        let agent_names = collect_names_from_rc_conf(&agents_rc_conf)?;
86        let tool_names = collect_names_from_rc_conf(&tools_rc_conf)?;
87        let skills_dirs = resolve_skills_dirs(&root);
88        let skills = load_skills(&skills_dirs)?;
89        Self::from_parts(
90            root,
91            agents_rc_conf,
92            &agent_names,
93            tools_rc_conf,
94            &tool_names,
95            skills,
96        )
97    }
98
99    fn from_parts(
100        root: Path<'static>,
101        agents_rc_conf: RcConf,
102        agent_names: &[String],
103        tools_rc_conf: RcConf,
104        tool_names: &[String],
105        skills: BTreeMap<String, SkillConfig>,
106    ) -> Result<Self, SError> {
107        let agents_dir = root.join(AGENTS_DIR).into_owned();
108        let tools_dir = root.join(TOOLS_DIR).into_owned();
109
110        let default_agent = resolve_default_agent(&agents_rc_conf, agent_names)?;
111
112        let mut agents = BTreeMap::new();
113        for agent_name in agent_names {
114            let agent = AgentConfig::from_rc_conf(&agents_dir, &agents_rc_conf, agent_name)?;
115            agents.insert(agent_name.clone(), agent);
116        }
117
118        let tools = resolve_tool_configs(&tools_dir, &tools_rc_conf, tool_names)?;
119
120        Ok(Self {
121            root,
122            default_agent,
123            agents,
124            tools,
125            skills,
126            agents_rc_conf,
127            tools_rc_conf,
128        })
129    }
130}
131
132/// Configuration for a single agent declared in `agents.conf`.
133///
134/// Each agent has an identity, an enablement switch, a system prompt, a list
135/// of tools it may invoke, and tuning knobs for user-instruction injection
136/// and extended-thinking budgets.
137#[derive(Debug)]
138pub struct AgentConfig {
139    /// Unique identifier for this agent (the rc.conf service name).
140    pub id: String,
141    /// Whether the agent is enabled, disabled, or requires manual confirmation.
142    pub enabled: SwitchPosition,
143    /// Human-readable display name, if specified.
144    pub display_name: Option<String>,
145    /// Short prose description of the agent's purpose.
146    pub description: Option<String>,
147    /// Tool identifiers this agent is allowed to invoke.
148    pub tools: Vec<String>,
149    /// Skill identifiers this agent has access to.
150    pub skills: Vec<String>,
151    /// Filesystem path to the agent's system prompt markdown file.
152    pub prompt_path: Path<'static>,
153    /// Loaded system prompt markdown content, or `None` when the file is absent.
154    pub prompt_markdown: Option<String>,
155    /// Merged chat configuration (model, thinking budget, etc.).
156    pub chat_config: ChatConfig,
157    /// Whether user-instruction injection is enabled for this agent.
158    pub user_instructions_enabled: bool,
159    /// Whether the AGENTS.md file should be appended to the system prompt.
160    pub agents_md_enabled: bool,
161    /// Explicit override path for the AGENTS.md file, if set.
162    pub agents_md_path: Option<String>,
163    /// Shell command executed as a hook to produce additional user instructions.
164    pub user_instructions_hook: Option<String>,
165}
166
167impl AgentConfig {
168    fn from_rc_conf(agents_dir: &Path, rc_conf: &RcConf, agent: &str) -> Result<Self, SError> {
169        let provider = rc_conf.variable_provider_for(agent).map_err(|err| {
170            SError::new("config")
171                .with_code("rc_conf_error")
172                .with_message("failed to derive agent config from rc_conf")
173                .with_string_field("agent", agent)
174                .with_string_field("cause", &format!("{err:?}"))
175        })?;
176
177        let enabled = rc_conf.service_switch(agent);
178        let display_name = lookup_expanded(&provider, agent, "NAME")?;
179        let description = lookup_expanded(&provider, agent, "DESC")?;
180        let tools = lookup_split_field(&provider, agent, "TOOLS")?;
181        let skills = lookup_split_field(&provider, agent, "SKILLS")?;
182        let user_instructions_enabled =
183            lookup_bool_field(&provider, agent, "USER_INSTRUCTIONS", true)?;
184        let agents_md_enabled = lookup_bool_field(&provider, agent, "AGENTS_MD", true)?;
185        let agents_md_path = lookup_nonempty_field(&provider, agent, "AGENTS_MD_PATH")?;
186        let user_instructions_hook =
187            lookup_nonempty_field(&provider, agent, "USER_INSTRUCTIONS_HOOK")?;
188
189        let prompt_path = resolve_agent_prompt_path(agents_dir, rc_conf, agent);
190        let prompt_markdown = if prompt_path.exists() {
191            Some(read_utf8_file(&prompt_path, agent, "prompt")?)
192        } else {
193            None
194        };
195
196        let mut chat_config = ChatConfig::new();
197        if let Some(prompt) = prompt_markdown.as_ref() {
198            chat_config.set_system_prompt(Some(prompt.clone()));
199        }
200        apply_chat_config_overrides(&mut chat_config, &provider, agent)?;
201
202        Ok(Self {
203            id: agent.to_string(),
204            enabled,
205            display_name,
206            description,
207            tools,
208            skills,
209            prompt_path,
210            prompt_markdown,
211            chat_config,
212            user_instructions_enabled,
213            agents_md_enabled,
214            agents_md_path,
215            user_instructions_hook,
216        })
217    }
218}
219
220/// Configuration for a single tool declared in `tools.conf`.
221///
222/// Tools are external executables that speak the sid tool protocol.  The
223/// manifest JSON supplies the Anthropic API with a description and JSON-Schema
224/// input definition, while the executable path locates the binary that
225/// actually runs each invocation.
226#[derive(Debug)]
227pub struct ToolConfig {
228    /// Tool identifier (the rc.conf service name after alias resolution).
229    pub id: String,
230    /// Whether the tool is enabled, disabled, or requires manual confirmation.
231    pub enabled: SwitchPosition,
232    /// When `true`, the harness shows a diff preview before executing write operations.
233    pub confirm_preview: bool,
234    /// Filesystem path to the tool executable, or `None` for built-in tools.
235    pub executable_path: Option<Path<'static>>,
236    /// Filesystem path to the tool's manifest JSON file.
237    pub manifest_path: Path<'static>,
238    /// Parsed manifest, or `None` when the manifest is optional and absent.
239    pub manifest: Option<ToolManifest>,
240}
241
242/// Parsed content of a tool manifest JSON file.
243///
244/// The manifest supplies the Anthropic messages API with the information it
245/// needs to present the tool to the model: a human-readable description and
246/// the JSON-Schema that validates tool-use inputs.
247#[derive(Clone, Debug, Eq, PartialEq)]
248pub struct ToolManifest {
249    /// Protocol version this manifest was written for.
250    pub protocol_version: u32,
251    /// Human-readable description shown to the model.
252    pub description: String,
253    /// JSON-Schema describing the tool's input parameters.
254    pub input_schema: serde_json::Value,
255}
256
257/// Per-skill configuration loaded from a markdown file in the skills directory.
258#[derive(Clone, Debug)]
259pub struct SkillConfig {
260    /// Skill identifier derived from the filename without the `.md` extension.
261    pub id: String,
262    /// Absolute path to the skill markdown file.
263    pub path: Path<'static>,
264    /// Markdown content of the skill file.
265    pub content: String,
266}
267
268#[derive(Debug, Deserialize)]
269struct ToolManifestFile {
270    protocol_version: u32,
271    description: String,
272    input_schema: serde_json::Value,
273}
274
275fn resolve_tool_configs(
276    tools_dir: &Path,
277    rc_conf: &RcConf,
278    tool_names: &[String],
279) -> Result<BTreeMap<String, ToolConfig>, SError> {
280    let mut resolved_ids = BTreeMap::new();
281    for tool in tool_names {
282        let canonical_id = resolve_canonical_tool_id(rc_conf, tool)?;
283        validate_anthropic_tool_name(&canonical_id)?;
284        resolved_ids.insert(tool.clone(), canonical_id);
285    }
286
287    let mut canonical_metadata = BTreeMap::new();
288    for canonical_id in resolved_ids.values() {
289        if canonical_metadata.contains_key(canonical_id) {
290            continue;
291        }
292        let executable_path = if builtin_tool_executable_is_optional(canonical_id) {
293            None
294        } else {
295            let executable_path = tools_dir.join(canonical_id).into_owned();
296            require_tool_executable(canonical_id, &executable_path)?;
297            Some(executable_path)
298        };
299
300        let manifest_path = tools_dir.join(format!("{canonical_id}.json")).into_owned();
301        let manifest = if manifest_path.is_file() {
302            Some(load_tool_manifest(canonical_id, &manifest_path)?)
303        } else if builtin_tool_manifest_is_optional(canonical_id) {
304            None
305        } else {
306            return Err(SError::new("config")
307                .with_code("missing_tool_manifest")
308                .with_message("required tool manifest does not exist")
309                .with_string_field("tool", canonical_id)
310                .with_string_field("manifest_path", manifest_path.as_str()));
311        };
312        canonical_metadata.insert(
313            canonical_id.clone(),
314            (executable_path, manifest_path, manifest),
315        );
316    }
317
318    let mut tools = BTreeMap::new();
319    for tool_id in tool_names {
320        let enabled = resolve_tool_switch(rc_conf, tool_id)?;
321        let confirm_preview = resolve_tool_confirm_preview(rc_conf, tool_id)?;
322        let canonical_id = resolved_ids
323            .get(tool_id)
324            .expect("resolved tool id should exist")
325            .clone();
326        let (executable_path, manifest_path, manifest) = canonical_metadata
327            .get(&canonical_id)
328            .expect("canonical metadata should exist");
329        tools.insert(
330            tool_id.clone(),
331            ToolConfig {
332                id: tool_id.clone(),
333                enabled,
334                confirm_preview,
335                executable_path: executable_path.clone(),
336                manifest_path: manifest_path.clone(),
337                manifest: manifest.clone(),
338            },
339        );
340    }
341
342    Ok(tools)
343}
344
345pub(crate) fn resolve_canonical_tool_id(rc_conf: &RcConf, tool: &str) -> Result<String, SError> {
346    let services = collect_names_from_rc_conf(rc_conf)?;
347    let canonical_id = rc_conf.resolve_alias(tool).to_string();
348    if services.iter().any(|service| service == &canonical_id) {
349        Ok(canonical_id)
350    } else {
351        Err(SError::new("config")
352            .with_code("unknown_tool")
353            .with_message("tool alias resolves to an undefined tool")
354            .with_string_field("tool", tool)
355            .with_string_field("alias_target", &canonical_id))
356    }
357}
358
359fn resolve_tool_switch(rc_conf: &RcConf, tool: &str) -> Result<SwitchPosition, SError> {
360    Ok(rc_conf.service_switch(tool))
361}
362
363fn resolve_tool_confirm_preview(rc_conf: &RcConf, tool: &str) -> Result<bool, SError> {
364    let provider = rc_conf.variable_provider_for(tool).map_err(|err| {
365        SError::new("config")
366            .with_code("rc_conf_error")
367            .with_message("failed to derive tool config from rc_conf")
368            .with_string_field("tool", tool)
369            .with_string_field("cause", &format!("{err:?}"))
370    })?;
371    let Some(value) = lookup_expanded(&provider, tool, "CONFIRM")? else {
372        return Ok(false);
373    };
374    parse_bool_field(tool, "CONFIRM", &value)
375}
376
377pub(crate) fn is_valid_anthropic_tool_name(name: &str) -> bool {
378    !name.is_empty()
379        && name.len() <= 64
380        && name
381            .bytes()
382            .all(|ch| ch.is_ascii_alphanumeric() || ch == b'_' || ch == b'-')
383}
384
385fn validate_anthropic_tool_name(name: &str) -> Result<(), SError> {
386    if is_valid_anthropic_tool_name(name) {
387        Ok(())
388    } else {
389        Err(SError::new("config")
390            .with_code("invalid_tool_id")
391            .with_message("tool id is not a legal Anthropic tool name")
392            .with_string_field("tool", name)
393            .with_string_field("reason", "expected 1-64 ASCII letters, digits, '_' or '-'"))
394    }
395}
396
397fn require_tool_executable(tool: &str, path: &Path) -> Result<(), SError> {
398    require_file_with_code(
399        path,
400        "missing_tool_executable",
401        "required tool executable does not exist",
402        "tool",
403        tool,
404        "path",
405    )?;
406    #[cfg(unix)]
407    {
408        let metadata = fs::metadata(path.as_str()).map_err(|err| {
409            SError::new("config")
410                .with_code("io_error")
411                .with_message("failed to inspect tool executable")
412                .with_string_field("tool", tool)
413                .with_string_field("path", path.as_str())
414                .with_string_field("cause", &err.to_string())
415        })?;
416        if metadata.permissions().mode() & 0o111 == 0 {
417            return Err(SError::new("config")
418                .with_code("tool_not_executable")
419                .with_message("tool executable is not marked executable")
420                .with_string_field("tool", tool)
421                .with_string_field("path", path.as_str()));
422        }
423    }
424    Ok(())
425}
426
427fn load_tool_manifest(tool: &str, path: &Path) -> Result<ToolManifest, SError> {
428    let raw = read_utf8_file(path, tool, "manifest")?;
429    let manifest: ToolManifestFile = serde_json::from_str(&raw).map_err(|err| {
430        SError::new("config")
431            .with_code("invalid_tool_manifest_json")
432            .with_message("failed to parse tool manifest")
433            .with_string_field("tool", tool)
434            .with_string_field("path", path.as_str())
435            .with_string_field("cause", &err.to_string())
436    })?;
437    if manifest.protocol_version != TOOL_PROTOCOL_VERSION {
438        return Err(SError::new("config")
439            .with_code("unsupported_tool_protocol_version")
440            .with_message("tool manifest declares an unsupported protocol version")
441            .with_string_field("tool", tool)
442            .with_string_field("path", path.as_str())
443            .with_string_field("protocol_version", &manifest.protocol_version.to_string()));
444    }
445    if manifest.description.trim().is_empty() {
446        return Err(SError::new("config")
447            .with_code("invalid_tool_manifest")
448            .with_message("tool manifest description must not be empty")
449            .with_string_field("tool", tool)
450            .with_string_field("path", path.as_str()));
451    }
452    if !manifest.input_schema.is_object() {
453        return Err(SError::new("config")
454            .with_code("invalid_tool_manifest")
455            .with_message("tool manifest input_schema must be a JSON object")
456            .with_string_field("tool", tool)
457            .with_string_field("path", path.as_str()));
458    }
459
460    Ok(ToolManifest {
461        protocol_version: manifest.protocol_version,
462        description: manifest.description,
463        input_schema: manifest.input_schema,
464    })
465}
466
467fn builtin_tool_manifest_is_optional(tool: &str) -> bool {
468    matches!(tool, "bash" | "edit")
469}
470
471fn builtin_tool_executable_is_optional(tool: &str) -> bool {
472    matches!(tool, "bash")
473}
474
475fn resolve_skills_dirs(root: &Path) -> Vec<Path<'static>> {
476    match std::env::var("SID_SKILLS_PATH") {
477        Ok(path) if !path.is_empty() => path
478            .split(':')
479            .filter(|component| !component.is_empty())
480            .map(|component| Path::new(component).into_owned())
481            .collect(),
482        _ => vec![root.join(SKILLS_DIR).into_owned()],
483    }
484}
485
486fn load_skills(dirs: &[Path<'static>]) -> Result<BTreeMap<String, SkillConfig>, SError> {
487    let mut skills = BTreeMap::new();
488    for dir in dirs {
489        if !std::path::Path::new(dir.as_str()).is_dir() {
490            continue;
491        }
492        let entries = fs::read_dir(dir.as_str()).map_err(|err| {
493            SError::new("config")
494                .with_code("io_error")
495                .with_message("failed to read skills directory")
496                .with_string_field("path", dir.as_str())
497                .with_string_field("cause", &err.to_string())
498        })?;
499        for entry in entries {
500            let entry = entry.map_err(|err| {
501                SError::new("config")
502                    .with_code("io_error")
503                    .with_message("failed to read skills directory entry")
504                    .with_string_field("path", dir.as_str())
505                    .with_string_field("cause", &err.to_string())
506            })?;
507            let entry_path = entry.path();
508            if !entry_path.is_dir() {
509                continue;
510            }
511            let skill_file = entry_path.join(SKILL_FILE);
512            if !skill_file.is_file() {
513                continue;
514            }
515            let dir_name = entry.file_name();
516            let skill_name = dir_name.to_string_lossy();
517            if skill_name.is_empty() {
518                continue;
519            }
520            if skills.contains_key(skill_name.as_ref()) {
521                continue;
522            }
523            let path = Path::try_from(skill_file)
524                .map_err(|err| {
525                    SError::new("config")
526                        .with_code("invalid_skill_path")
527                        .with_message("skill path is not valid UTF-8")
528                        .with_string_field("cause", &format!("{err:?}"))
529                })?
530                .into_owned();
531            let content = read_utf8_file(&path, &skill_name, "skill")?;
532            skills.insert(
533                skill_name.to_string(),
534                SkillConfig {
535                    id: skill_name.to_string(),
536                    path,
537                    content,
538                },
539            );
540        }
541    }
542    Ok(skills)
543}
544
545fn apply_chat_config_overrides(
546    chat_config: &mut ChatConfig,
547    provider: &impl VariableProvider,
548    agent: &str,
549) -> Result<(), SError> {
550    if let Some(model) = lookup_expanded(provider, agent, "MODEL")? {
551        let model = model
552            .parse()
553            .unwrap_or_else(|_| Model::Custom(model.clone()));
554        chat_config.set_model(model);
555    }
556    if let Some(system_prompt) = lookup_expanded(provider, agent, "SYSTEM")? {
557        chat_config.set_system_prompt(Some(system_prompt));
558    }
559    if let Some(max_tokens) = lookup_expanded(provider, agent, "MAX_TOKENS")? {
560        chat_config.set_max_tokens(parse_u32_field(agent, "MAX_TOKENS", &max_tokens)?);
561    }
562    if let Some(temperature) = lookup_expanded(provider, agent, "TEMPERATURE")? {
563        chat_config.set_temperature(Some(parse_unit_interval_field(
564            agent,
565            "TEMPERATURE",
566            &temperature,
567        )?));
568    }
569    if let Some(top_p) = lookup_expanded(provider, agent, "TOP_P")? {
570        chat_config.set_top_p(Some(parse_unit_interval_field(agent, "TOP_P", &top_p)?));
571    }
572    if let Some(top_k) = lookup_expanded(provider, agent, "TOP_K")? {
573        chat_config.set_top_k(Some(parse_u32_field(agent, "TOP_K", &top_k)?));
574    }
575    if let Some(stop_sequences) = lookup_expanded(provider, agent, "STOP_SEQUENCES")? {
576        let stop_sequences = shvar::split(&stop_sequences).map_err(|err| {
577            invalid_config_field(agent, "STOP_SEQUENCES", &stop_sequences, format!("{err:?}"))
578        })?;
579        if stop_sequences.is_empty() {
580            chat_config.template.stop_sequences = None;
581        } else {
582            chat_config.template.stop_sequences = Some(stop_sequences);
583        }
584    }
585    if let Some(thinking) = lookup_expanded(provider, agent, "THINKING")? {
586        chat_config.template.thinking =
587            parse_thinking_budget(agent, "THINKING", &thinking)?.map(ThinkingConfig::enabled);
588    }
589    if let Some(use_color) = lookup_expanded(provider, agent, "USE_COLOR")? {
590        chat_config.use_color = parse_bool_field(agent, "USE_COLOR", &use_color)?;
591    }
592    if let Some(no_color) = lookup_expanded(provider, agent, "NO_COLOR")? {
593        chat_config.use_color = !parse_bool_field(agent, "NO_COLOR", &no_color)?;
594    }
595    if let Some(session_budget) = lookup_expanded(provider, agent, "SESSION_BUDGET")? {
596        chat_config.set_session_budget(Some(parse_u64_field(
597            agent,
598            "SESSION_BUDGET",
599            &session_budget,
600        )?));
601    }
602    if let Some(caching_enabled) = lookup_expanded(provider, agent, "CACHING_ENABLED")? {
603        chat_config.caching_enabled = parse_bool_field(agent, "CACHING_ENABLED", &caching_enabled)?;
604    }
605
606    Ok(())
607}
608
609/// Read the explicit `DEFAULT_AGENT` global variable from agents.conf when present.
610///
611/// If the variable is set, its value must name one of the defined agents.
612fn resolve_default_agent(
613    agents_rc_conf: &RcConf,
614    agent_names: &[String],
615) -> Result<Option<String>, SError> {
616    let Some(value) = agents_rc_conf.lookup("DEFAULT_AGENT") else {
617        return Ok(None);
618    };
619    let value = value.trim().to_string();
620    if value.is_empty() {
621        return Ok(None);
622    }
623    if !agent_names.contains(&value) {
624        return Err(SError::new("config")
625            .with_code("invalid_default_agent")
626            .with_message("DEFAULT_AGENT names an undefined agent")
627            .with_string_field("default_agent", &value));
628    }
629    Ok(Some(value))
630}
631
632fn collect_names_from_rc_conf(rc_conf: &RcConf) -> Result<Vec<String>, SError> {
633    Ok(rc_conf
634        .list()
635        .map_err(|err| {
636            SError::new("config")
637                .with_code("rc_conf_error")
638                .with_message("failed to list configured names")
639                .with_string_field("cause", &format!("{err:?}"))
640        })?
641        .collect())
642}
643
644fn parse_rc_conf(path: &Path) -> Result<RcConf, SError> {
645    RcConf::parse(path.as_str()).map_err(|err| {
646        SError::new("config")
647            .with_code("rc_conf_error")
648            .with_message("failed to parse rc_conf file")
649            .with_string_field("path", path.as_str())
650            .with_string_field("cause", &format!("{err:?}"))
651    })
652}
653
654fn require_file(path: &Path, label: &str) -> Result<(), SError> {
655    require_file_with_code(
656        path,
657        "missing_config_file",
658        "required config file does not exist",
659        "file",
660        label,
661        "path",
662    )
663}
664
665fn require_file_with_code(
666    path: &Path,
667    code: &str,
668    message: &str,
669    name_field: &str,
670    name: &str,
671    path_field: &str,
672) -> Result<(), SError> {
673    if path.is_file() {
674        Ok(())
675    } else {
676        Err(SError::new("config")
677            .with_code(code)
678            .with_message(message)
679            .with_string_field(name_field, name)
680            .with_string_field(path_field, path.as_str()))
681    }
682}
683
684fn read_utf8_file(path: &Path, name: &str, field: &str) -> Result<String, SError> {
685    fs::read_to_string(path.as_str()).map_err(|err| {
686        SError::new("config")
687            .with_code("io_error")
688            .with_message("failed to read config file")
689            .with_string_field("name", name)
690            .with_string_field("field", field)
691            .with_string_field("path", path.as_str())
692            .with_string_field("cause", &err.to_string())
693    })
694}
695
696fn resolve_agent_prompt_path(agents_dir: &Path, rc_conf: &RcConf, agent: &str) -> Path<'static> {
697    for candidate in rc_conf.alias_lookup_order(agent).0 {
698        let path = agents_dir.join(format!("{candidate}.md")).into_owned();
699        if path.is_file() {
700            return path;
701        }
702    }
703    agents_dir.join(format!("{agent}.md")).into_owned()
704}
705
706fn lookup_expanded(
707    provider: &impl VariableProvider,
708    scope: &str,
709    key: &str,
710) -> Result<Option<String>, SError> {
711    let Some(value) = provider.lookup(key) else {
712        return Ok(None);
713    };
714    let expanded = expand_config_value(provider, scope, key, &value)?;
715    Ok(Some(expanded))
716}
717
718fn lookup_split_field(
719    provider: &impl VariableProvider,
720    scope: &str,
721    key: &str,
722) -> Result<Vec<String>, SError> {
723    let Some(value) = lookup_expanded(provider, scope, key)? else {
724        return Ok(vec![]);
725    };
726    shvar::split(&value).map_err(|err| invalid_config_field(scope, key, &value, format!("{err:?}")))
727}
728
729fn lookup_nonempty_field(
730    provider: &impl VariableProvider,
731    scope: &str,
732    key: &str,
733) -> Result<Option<String>, SError> {
734    Ok(lookup_expanded(provider, scope, key)?.and_then(|value| {
735        let value = value.trim().to_string();
736        (!value.is_empty()).then_some(value)
737    }))
738}
739
740fn lookup_bool_field(
741    provider: &impl VariableProvider,
742    scope: &str,
743    key: &str,
744    default: bool,
745) -> Result<bool, SError> {
746    let Some(value) = lookup_expanded(provider, scope, key)? else {
747        return Ok(default);
748    };
749    parse_bool_field(scope, key, &value)
750}
751
752fn parse_u32_field(scope: &str, field: &str, value: &str) -> Result<u32, SError> {
753    value
754        .trim()
755        .parse::<u32>()
756        .map_err(|err| invalid_config_field(scope, field, value, err.to_string()))
757}
758
759fn parse_u64_field(scope: &str, field: &str, value: &str) -> Result<u64, SError> {
760    value
761        .trim()
762        .parse::<u64>()
763        .map_err(|err| invalid_config_field(scope, field, value, err.to_string()))
764}
765
766fn parse_unit_interval_field(scope: &str, field: &str, value: &str) -> Result<f32, SError> {
767    let parsed = value
768        .trim()
769        .parse::<f32>()
770        .map_err(|err| invalid_config_field(scope, field, value, err.to_string()))?;
771    if parsed.is_finite() && (0.0..=1.0).contains(&parsed) {
772        Ok(parsed)
773    } else {
774        Err(invalid_config_field(
775            scope,
776            field,
777            value,
778            "expected a finite value between 0.0 and 1.0",
779        ))
780    }
781}
782
783fn parse_bool_field(scope: &str, field: &str, value: &str) -> Result<bool, SError> {
784    match value.trim().to_ascii_lowercase().as_str() {
785        "1" | "on" | "true" | "yes" | "enable" | "enabled" => Ok(true),
786        "0" | "off" | "false" | "no" | "disable" | "disabled" => Ok(false),
787        _ => Err(invalid_config_field(
788            scope,
789            field,
790            value,
791            "expected one of yes/no, true/false, on/off, or 1/0",
792        )),
793    }
794}
795
796fn parse_thinking_budget(scope: &str, field: &str, value: &str) -> Result<Option<u32>, SError> {
797    match value.trim().to_ascii_lowercase().as_str() {
798        "off" | "false" | "no" | "disable" | "disabled" => Ok(None),
799        "on" | "true" | "yes" | "enable" | "enabled" => Ok(Some(DEFAULT_THINKING_BUDGET)),
800        _ => parse_u32_field(scope, field, value).map(Some),
801    }
802}
803
804fn invalid_config_field(
805    scope: &str,
806    field: &str,
807    value: &str,
808    reason: impl Into<String>,
809) -> SError {
810    let reason = reason.into();
811    SError::new("config")
812        .with_code("invalid_config_field")
813        .with_message("failed to derive config from rc_conf")
814        .with_string_field("scope", scope)
815        .with_string_field("field", field)
816        .with_string_field("value", value)
817        .with_string_field("reason", &reason)
818}
819
820fn expand_config_value(
821    provider: &impl VariableProvider,
822    scope: &str,
823    field: &str,
824    value: &str,
825) -> Result<String, SError> {
826    let mut current = value.to_string();
827    for _ in 0..128 {
828        let next = expand_config_value_once(provider, scope, field, &current)?;
829        if next == current {
830            return Ok(next);
831        }
832        current = next;
833    }
834    Err(invalid_config_field(
835        scope,
836        field,
837        value,
838        "variable expansion exceeded recursion limit",
839    ))
840}
841
842fn expand_config_value_once(
843    provider: &impl VariableProvider,
844    scope: &str,
845    field: &str,
846    value: &str,
847) -> Result<String, SError> {
848    let mut output = String::with_capacity(value.len());
849    let mut chars = value.chars().peekable();
850    while let Some(ch) = chars.next() {
851        if ch != '$' {
852            output.push(ch);
853            continue;
854        }
855        match chars.peek().copied() {
856            Some('$') => {
857                output.push('$');
858                chars.next();
859            }
860            Some('{') => {
861                chars.next();
862                let ident = parse_braced_identifier(&mut chars, scope, field, value)?;
863                output.push_str(&provider.lookup(&ident).unwrap_or_default());
864            }
865            Some(next) if is_identifier_start(next) => {
866                let ident = parse_identifier(&mut chars);
867                output.push_str(&provider.lookup(&ident).unwrap_or_default());
868            }
869            _ => {
870                output.push('$');
871            }
872        }
873    }
874    Ok(output)
875}
876
877fn parse_braced_identifier(
878    chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
879    scope: &str,
880    field: &str,
881    value: &str,
882) -> Result<String, SError> {
883    let Some(first) = chars.next() else {
884        return Err(invalid_config_field(
885            scope,
886            field,
887            value,
888            "unterminated variable expansion",
889        ));
890    };
891    if !is_identifier_start(first) {
892        return Err(invalid_config_field(
893            scope,
894            field,
895            value,
896            "invalid variable name in expansion",
897        ));
898    }
899
900    let mut ident = String::from(first);
901    loop {
902        match chars.next() {
903            Some('}') => return Ok(ident),
904            Some(ch) if is_identifier_continue(ch) => ident.push(ch),
905            Some(_) => {
906                return Err(invalid_config_field(
907                    scope,
908                    field,
909                    value,
910                    "invalid variable name in expansion",
911                ));
912            }
913            None => {
914                return Err(invalid_config_field(
915                    scope,
916                    field,
917                    value,
918                    "unterminated variable expansion",
919                ));
920            }
921        }
922    }
923}
924
925fn parse_identifier(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> String {
926    let mut ident = String::new();
927    while let Some(ch) = chars.peek().copied() {
928        if !is_identifier_continue(ch) {
929            break;
930        }
931        ident.push(ch);
932        chars.next();
933    }
934    ident
935}
936
937fn is_identifier_start(ch: char) -> bool {
938    ch == '_' || ch.is_ascii_alphabetic()
939}
940
941fn is_identifier_continue(ch: char) -> bool {
942    ch == '_' || ch.is_ascii_alphanumeric()
943}
944
945#[cfg(test)]
946mod tests {
947    use std::fs;
948
949    use claudius::KnownModel;
950
951    use super::*;
952    use crate::test_support::{
953        unique_temp_dir, write_default_tool_manifest, write_tool_manifest,
954        write_tool_manifest_with_schema, write_tool_script,
955    };
956
957    #[test]
958    fn load_config_from_readme_style_files() {
959        let root = unique_temp_dir("config");
960        fs::create_dir_all(root.join("agents").as_str()).unwrap();
961
962        fs::write(
963            root.join("agents.conf").as_str(),
964            r#"
965ROLE='principal engineer'
966build_ENABLED="YES"
967plan_ENABLED="MANUAL"
968evil_ENABLED="NO"
969
970build_NAME="Let's go ${ROLE}"
971build_DESC="buildit"
972build_TOOLS='format bash'
973build_SKILLS='* "rust docs"'
974
975plan_MODEL=claude-sonnet-4-5
976plan_SYSTEM="You are ${ROLE}"
977plan_MAX_TOKENS=8192
978plan_TEMPERATURE=0.7
979plan_TOP_P=0.9
980plan_TOP_K=40
981plan_STOP_SEQUENCES='END "two words"'
982plan_THINKING=on
983plan_NO_COLOR=yes
984plan_SESSION_BUDGET=50000
985plan_CACHING_ENABLED=off
986"#,
987        )
988        .unwrap();
989        fs::write(
990            root.join("tools.conf").as_str(),
991            r#"
992fmt_ENABLED="YES"
993bash_ENABLED="YES"
994
995format_INHERIT="YES"
996format_ALIASES="fmt"
997"#,
998        )
999        .unwrap();
1000        write_tool_contract(&root, "fmt", "Format files in the workspace.");
1001        write_tool_contract(&root, "bash", "Run a shell command.");
1002        fs::write(
1003            root.join("agents/build.md").as_str(),
1004            "# Build\n\nYou are an expert builder.\n",
1005        )
1006        .unwrap();
1007        fs::write(
1008            root.join("agents/plan.md").as_str(),
1009            "# Plan\n\nYou are an expert planner.\n",
1010        )
1011        .unwrap();
1012
1013        let config = Config::load(&root).unwrap();
1014
1015        assert_eq!(config.agents.len(), 4);
1016        assert_eq!(config.tools.len(), 3);
1017
1018        let build = config.agents.get("build").unwrap();
1019        assert_eq!(build.enabled, SwitchPosition::Yes);
1020        assert_eq!(
1021            build.display_name.as_deref(),
1022            Some("Let's go principal engineer")
1023        );
1024        assert_eq!(build.description.as_deref(), Some("buildit"));
1025        assert_eq!(build.tools, vec!["format".to_string(), "bash".to_string()]);
1026        assert_eq!(build.skills, vec!["*".to_string(), "rust docs".to_string()]);
1027        assert_eq!(
1028            build.prompt_markdown.as_deref(),
1029            Some("# Build\n\nYou are an expert builder.\n")
1030        );
1031        assert_eq!(
1032            build.chat_config.system_prompt_text(),
1033            Some("# Build\n\nYou are an expert builder.\n")
1034        );
1035
1036        let plan = config.agents.get("plan").unwrap();
1037        assert_eq!(plan.enabled, SwitchPosition::Manual);
1038        assert_eq!(
1039            plan.chat_config.model(),
1040            Model::Known(KnownModel::ClaudeSonnet45)
1041        );
1042        assert_eq!(
1043            plan.chat_config.system_prompt_text(),
1044            Some("You are principal engineer")
1045        );
1046        assert_eq!(plan.chat_config.max_tokens(), 8192);
1047        assert_eq!(plan.chat_config.template.temperature, Some(0.7));
1048        assert_eq!(plan.chat_config.template.top_p, Some(0.9));
1049        assert_eq!(plan.chat_config.template.top_k, Some(40));
1050        assert_eq!(
1051            plan.chat_config.stop_sequences(),
1052            &["END".to_string(), "two words".to_string()]
1053        );
1054        assert_eq!(
1055            plan.chat_config.thinking_budget(),
1056            Some(DEFAULT_THINKING_BUDGET)
1057        );
1058        assert!(!plan.chat_config.use_color);
1059        assert!(plan.chat_config.session_budget.is_some());
1060        assert!(!plan.chat_config.caching_enabled);
1061
1062        let evil = config.agents.get("evil").unwrap();
1063        assert_eq!(evil.enabled, SwitchPosition::No);
1064        assert!(evil.prompt_markdown.is_none());
1065        assert_eq!(evil.prompt_path, root.join("agents/evil.md"));
1066
1067        let plan_caching = config.agents.get("plan-caching").unwrap();
1068        assert_eq!(plan_caching.enabled, SwitchPosition::No);
1069        assert!(plan_caching.prompt_markdown.is_none());
1070        assert_eq!(
1071            plan_caching.prompt_path,
1072            root.join("agents/plan-caching.md")
1073        );
1074
1075        let fmt = config.tools.get("fmt").unwrap();
1076        assert_eq!(fmt.enabled, SwitchPosition::Yes);
1077        assert!(!fmt.confirm_preview);
1078        assert_eq!(fmt.executable_path, Some(root.join("tools/fmt")));
1079        assert_eq!(fmt.manifest_path, root.join("tools/fmt.json"));
1080        let fmt_manifest = fmt.manifest.as_ref().unwrap();
1081        assert_eq!(fmt_manifest.protocol_version, TOOL_PROTOCOL_VERSION);
1082        assert_eq!(fmt_manifest.description, "Format files in the workspace.");
1083
1084        let format = config.tools.get("format").unwrap();
1085        assert_eq!(format.enabled, SwitchPosition::Yes);
1086        assert_eq!(format.executable_path, Some(root.join("tools/fmt")));
1087        assert_eq!(format.manifest_path, root.join("tools/fmt.json"));
1088        assert_eq!(
1089            format.manifest.as_ref().unwrap().description,
1090            "Format files in the workspace."
1091        );
1092
1093        let bash = config.tools.get("bash").unwrap();
1094        assert_eq!(bash.enabled, SwitchPosition::Yes);
1095        assert!(!bash.confirm_preview);
1096        assert!(bash.executable_path.is_none());
1097    }
1098
1099    #[test]
1100    fn tool_confirm_preview_defaults_and_parses_bool() {
1101        let root = unique_temp_dir("config");
1102        fs::create_dir_all(root.join("agents").as_str()).unwrap();
1103        fs::write(
1104            root.join("agents.conf").as_str(),
1105            "build_ENABLED=YES\nbuild_TOOLS='fmt plain'\n",
1106        )
1107        .unwrap();
1108        fs::write(
1109            root.join("tools.conf").as_str(),
1110            "fmt_ENABLED=YES\nfmt_CONFIRM=YES\nplain_ENABLED=YES\n",
1111        )
1112        .unwrap();
1113        write_tool_contract(&root, "fmt", "Format files.");
1114        write_tool_contract(&root, "plain", "Plain tool.");
1115
1116        let config = Config::load(&root).unwrap();
1117        assert!(config.tools.get("fmt").unwrap().confirm_preview);
1118        assert!(!config.tools.get("plain").unwrap().confirm_preview);
1119
1120        fs::remove_dir_all(root.as_str()).unwrap();
1121    }
1122
1123    #[test]
1124    fn invalid_tool_confirm_preview_bool_is_an_error() {
1125        let root = unique_temp_dir("config");
1126        fs::create_dir_all(root.join("agents").as_str()).unwrap();
1127        fs::write(
1128            root.join("agents.conf").as_str(),
1129            "build_ENABLED=YES\nbuild_TOOLS='fmt'\n",
1130        )
1131        .unwrap();
1132        fs::write(
1133            root.join("tools.conf").as_str(),
1134            "fmt_ENABLED=YES\nfmt_CONFIRM=maybe\n",
1135        )
1136        .unwrap();
1137        write_tool_contract(&root, "fmt", "Format files.");
1138
1139        let err = Config::load(&root)
1140            .expect_err("invalid CONFIRM value should fail")
1141            .to_string();
1142        assert!(err.contains("CONFIRM"));
1143
1144        fs::remove_dir_all(root.as_str()).unwrap();
1145    }
1146
1147    #[test]
1148    fn missing_top_level_config_file_is_an_error() {
1149        let root = unique_temp_dir("config");
1150        fs::create_dir_all(root.as_str()).unwrap();
1151        fs::write(root.join("agents.conf").as_str(), "build_ENABLED=YES\n").unwrap();
1152
1153        let err = Config::load(&root).unwrap_err().to_string();
1154        assert!(err.contains("missing_config_file"));
1155        assert!(err.contains("tools.conf"));
1156    }
1157
1158    #[test]
1159    fn invalid_agent_field_is_reported() {
1160        let root = unique_temp_dir("config");
1161        fs::create_dir_all(root.join("agents").as_str()).unwrap();
1162        fs::write(
1163            root.join("agents.conf").as_str(),
1164            "build_ENABLED=YES\nbuild_TOP_P=wat\n",
1165        )
1166        .unwrap();
1167        fs::write(root.join("tools.conf").as_str(), "").unwrap();
1168        fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
1169
1170        let err = Config::load(&root).unwrap_err().to_string();
1171        assert!(err.contains("invalid_config_field"));
1172        assert!(err.contains("TOP_P"));
1173        assert!(err.contains("wat"));
1174    }
1175
1176    #[test]
1177    fn missing_tool_executable_is_reported_during_config_load() {
1178        let root = unique_temp_dir("config");
1179        fs::create_dir_all(root.as_str()).unwrap();
1180        fs::write(root.join("agents.conf").as_str(), "").unwrap();
1181        fs::write(root.join("tools.conf").as_str(), "fmt_ENABLED=YES\n").unwrap();
1182        write_tool_manifest(&root, "fmt", TOOL_PROTOCOL_VERSION, "Format files.");
1183
1184        let err = Config::load(&root).unwrap_err().to_string();
1185        assert!(err.contains("missing_tool_executable"));
1186        assert!(err.contains("fmt"));
1187    }
1188
1189    #[test]
1190    fn missing_tool_manifest_is_reported_during_config_load() {
1191        let root = unique_temp_dir("config");
1192        fs::create_dir_all(root.as_str()).unwrap();
1193        fs::write(root.join("agents.conf").as_str(), "").unwrap();
1194        fs::write(root.join("tools.conf").as_str(), "fmt_ENABLED=YES\n").unwrap();
1195        write_tool_script(&root, "fmt", "#!/bin/sh\nexit 0\n");
1196
1197        let err = Config::load(&root).unwrap_err().to_string();
1198        assert!(err.contains("missing_tool_manifest"));
1199        assert!(err.contains("fmt"));
1200    }
1201
1202    #[test]
1203    fn missing_builtin_tool_manifest_is_allowed_during_config_load() {
1204        let root = unique_temp_dir("config");
1205        fs::create_dir_all(root.as_str()).unwrap();
1206        fs::write(root.join("agents.conf").as_str(), "").unwrap();
1207        fs::write(
1208            root.join("tools.conf").as_str(),
1209            "bash_ENABLED=YES\nedit_ENABLED=YES\n",
1210        )
1211        .unwrap();
1212        write_tool_script(&root, "edit", "#!/bin/sh\nexit 0\n");
1213
1214        let config = Config::load(&root).unwrap();
1215        let bash = config.tools.get("bash").unwrap();
1216        assert!(bash.executable_path.is_none());
1217        assert_eq!(bash.manifest_path, root.join("tools/bash.json"));
1218        assert!(bash.manifest.is_none());
1219
1220        let edit = config.tools.get("edit").unwrap();
1221        assert_eq!(edit.executable_path, Some(root.join("tools/edit")));
1222        assert_eq!(edit.manifest_path, root.join("tools/edit.json"));
1223        assert!(edit.manifest.is_none());
1224    }
1225
1226    #[test]
1227    fn missing_builtin_edit_executable_is_reported_during_config_load() {
1228        let root = unique_temp_dir("config");
1229        fs::create_dir_all(root.as_str()).unwrap();
1230        fs::write(root.join("agents.conf").as_str(), "").unwrap();
1231        fs::write(
1232            root.join("tools.conf").as_str(),
1233            "bash_ENABLED=YES\nedit_ENABLED=YES\n",
1234        )
1235        .unwrap();
1236
1237        let err = Config::load(&root).unwrap_err().to_string();
1238        assert!(err.contains("missing_tool_executable"));
1239        assert!(err.contains("edit"));
1240    }
1241
1242    #[test]
1243    fn invalid_tool_manifest_json_is_reported_during_config_load() {
1244        let root = unique_temp_dir("config");
1245        fs::create_dir_all(root.as_str()).unwrap();
1246        fs::write(root.join("agents.conf").as_str(), "").unwrap();
1247        fs::write(root.join("tools.conf").as_str(), "fmt_ENABLED=YES\n").unwrap();
1248        write_tool_script(&root, "fmt", "#!/bin/sh\nexit 0\n");
1249        fs::write(root.join("tools/fmt.json").as_str(), "{ not valid json").unwrap();
1250
1251        let err = Config::load(&root).unwrap_err().to_string();
1252        assert!(err.contains("invalid_tool_manifest_json"));
1253    }
1254
1255    #[test]
1256    fn unsupported_tool_protocol_version_is_reported_during_config_load() {
1257        let root = unique_temp_dir("config");
1258        fs::create_dir_all(root.as_str()).unwrap();
1259        fs::write(root.join("agents.conf").as_str(), "").unwrap();
1260        fs::write(root.join("tools.conf").as_str(), "fmt_ENABLED=YES\n").unwrap();
1261        write_tool_script(&root, "fmt", "#!/bin/sh\nexit 0\n");
1262        write_tool_manifest(&root, "fmt", 2, "Format files.");
1263
1264        let err = Config::load(&root).unwrap_err().to_string();
1265        assert!(err.contains("unsupported_tool_protocol_version"));
1266    }
1267
1268    #[test]
1269    fn invalid_tool_id_is_reported_during_config_load() {
1270        let root = unique_temp_dir("config");
1271        fs::create_dir_all(root.as_str()).unwrap();
1272        fs::write(root.join("agents.conf").as_str(), "").unwrap();
1273        let long_name = "a".repeat(65);
1274        fs::write(
1275            root.join("tools.conf").as_str(),
1276            format!("{long_name}_ENABLED=YES\n"),
1277        )
1278        .unwrap();
1279
1280        let err = Config::load(&root).unwrap_err().to_string();
1281        assert!(err.contains("invalid_tool_id"));
1282        assert!(err.contains(&long_name));
1283    }
1284
1285    #[cfg(unix)]
1286    #[test]
1287    fn tool_must_be_executable_during_config_load() {
1288        let root = unique_temp_dir("config");
1289        fs::create_dir_all(root.as_str()).unwrap();
1290        fs::write(root.join("agents.conf").as_str(), "").unwrap();
1291        fs::write(root.join("tools.conf").as_str(), "fmt_ENABLED=YES\n").unwrap();
1292        fs::create_dir_all(root.join("tools").as_str()).unwrap();
1293        let executable = root.join("tools/fmt").into_owned();
1294        fs::write(executable.as_str(), "#!/bin/sh\nexit 0\n").unwrap();
1295        write_tool_manifest(&root, "fmt", TOOL_PROTOCOL_VERSION, "Format files.");
1296
1297        let err = Config::load(&root).unwrap_err().to_string();
1298        assert!(err.contains("tool_not_executable"));
1299        assert!(err.contains("fmt"));
1300    }
1301
1302    #[test]
1303    fn empty_tool_description_is_reported_during_config_load() {
1304        let root = unique_temp_dir("config");
1305        fs::create_dir_all(root.as_str()).unwrap();
1306        fs::write(root.join("agents.conf").as_str(), "").unwrap();
1307        fs::write(root.join("tools.conf").as_str(), "fmt_ENABLED=YES\n").unwrap();
1308        write_tool_script(&root, "fmt", "#!/bin/sh\nexit 0\n");
1309        write_tool_manifest(&root, "fmt", TOOL_PROTOCOL_VERSION, "   ");
1310
1311        let err = Config::load(&root).unwrap_err().to_string();
1312        assert!(err.contains("invalid_tool_manifest"));
1313        assert!(err.contains("description"));
1314    }
1315
1316    #[test]
1317    fn non_object_input_schema_is_reported_during_config_load() {
1318        let root = unique_temp_dir("config");
1319        fs::create_dir_all(root.as_str()).unwrap();
1320        fs::write(root.join("agents.conf").as_str(), "").unwrap();
1321        fs::write(root.join("tools.conf").as_str(), "fmt_ENABLED=YES\n").unwrap();
1322        write_tool_script(&root, "fmt", "#!/bin/sh\nexit 0\n");
1323        write_tool_manifest_with_schema(
1324            &root,
1325            "fmt",
1326            TOOL_PROTOCOL_VERSION,
1327            "Format files.",
1328            serde_json::json!("not an object"),
1329        );
1330
1331        let err = Config::load(&root).unwrap_err().to_string();
1332        assert!(err.contains("invalid_tool_manifest"));
1333        assert!(err.contains("input_schema"));
1334    }
1335
1336    #[test]
1337    fn undefined_tool_alias_target_is_reported_during_config_load() {
1338        let root = unique_temp_dir("config");
1339        fs::create_dir_all(root.as_str()).unwrap();
1340        fs::write(root.join("agents.conf").as_str(), "").unwrap();
1341        fs::write(
1342            root.join("tools.conf").as_str(),
1343            "format_ALIASES=fmt\nformat_INHERIT=YES\n",
1344        )
1345        .unwrap();
1346
1347        let err = Config::load(&root).unwrap_err().to_string();
1348        assert!(err.contains("unknown_tool"));
1349        assert!(err.contains("fmt"));
1350    }
1351
1352    #[test]
1353    fn load_skills_from_skills_directory() {
1354        let root = unique_temp_dir("config");
1355        fs::create_dir_all(root.join("agents").as_str()).unwrap();
1356        fs::create_dir_all(root.join("skills/rust").as_str()).unwrap();
1357        fs::create_dir_all(root.join("skills/python").as_str()).unwrap();
1358        fs::create_dir_all(root.join("skills/empty-dir").as_str()).unwrap();
1359
1360        fs::write(root.join("agents.conf").as_str(), "").unwrap();
1361        fs::write(root.join("tools.conf").as_str(), "").unwrap();
1362        fs::write(
1363            root.join("skills/rust/SKILL.md").as_str(),
1364            "# Rust\n\nYou are a Rust expert.\n",
1365        )
1366        .unwrap();
1367        fs::write(
1368            root.join("skills/python/SKILL.md").as_str(),
1369            "# Python\n\nYou know Python.\n",
1370        )
1371        .unwrap();
1372        // Bare file in skills/ should be ignored.
1373        fs::write(root.join("skills/notes.txt").as_str(), "not a skill").unwrap();
1374        // Subdirectory without SKILL.md should be ignored.
1375
1376        let config = Config::load(&root).unwrap();
1377        assert_eq!(config.skills.len(), 2);
1378
1379        let rust_skill = config.skills.get("rust").unwrap();
1380        assert_eq!(rust_skill.id, "rust");
1381        assert_eq!(rust_skill.content, "# Rust\n\nYou are a Rust expert.\n");
1382        assert_eq!(rust_skill.path, root.join("skills/rust/SKILL.md"));
1383
1384        let python_skill = config.skills.get("python").unwrap();
1385        assert_eq!(python_skill.id, "python");
1386        assert_eq!(python_skill.content, "# Python\n\nYou know Python.\n");
1387    }
1388
1389    #[test]
1390    fn missing_skills_directory_produces_empty_skills() {
1391        let root = unique_temp_dir("config");
1392        fs::create_dir_all(root.as_str()).unwrap();
1393        fs::write(root.join("agents.conf").as_str(), "").unwrap();
1394        fs::write(root.join("tools.conf").as_str(), "").unwrap();
1395
1396        let config = Config::load(&root).unwrap();
1397        assert!(config.skills.is_empty());
1398    }
1399
1400    #[test]
1401    fn load_skills_earlier_directory_wins() {
1402        let dir_a = unique_temp_dir("skills-a");
1403        let dir_b = unique_temp_dir("skills-b");
1404        fs::create_dir_all(dir_a.join("rust").as_str()).unwrap();
1405        fs::create_dir_all(dir_a.join("go").as_str()).unwrap();
1406        fs::create_dir_all(dir_b.join("rust").as_str()).unwrap();
1407        fs::create_dir_all(dir_b.join("python").as_str()).unwrap();
1408
1409        fs::write(dir_a.join("rust/SKILL.md").as_str(), "# Rust from A\n").unwrap();
1410        fs::write(dir_a.join("go/SKILL.md").as_str(), "# Go from A\n").unwrap();
1411        fs::write(
1412            dir_b.join("rust/SKILL.md").as_str(),
1413            "# Rust from B (should be shadowed)\n",
1414        )
1415        .unwrap();
1416        fs::write(dir_b.join("python/SKILL.md").as_str(), "# Python from B\n").unwrap();
1417
1418        let dirs = vec![dir_a.clone(), dir_b.clone()];
1419        let skills = load_skills(&dirs).unwrap();
1420        assert_eq!(skills.len(), 3);
1421
1422        let rust_skill = skills.get("rust").unwrap();
1423        assert_eq!(rust_skill.content, "# Rust from A\n");
1424        assert_eq!(rust_skill.path, dir_a.join("rust/SKILL.md"));
1425
1426        assert_eq!(skills.get("go").unwrap().content, "# Go from A\n");
1427
1428        let python_skill = skills.get("python").unwrap();
1429        assert_eq!(python_skill.content, "# Python from B\n");
1430        assert_eq!(python_skill.path, dir_b.join("python/SKILL.md"));
1431    }
1432
1433    #[test]
1434    fn load_skills_skips_nonexistent_directories() {
1435        let dir_exists = unique_temp_dir("skills-exists");
1436        let dir_missing = unique_temp_dir("skills-missing");
1437        fs::create_dir_all(dir_exists.join("rust").as_str()).unwrap();
1438        fs::write(dir_exists.join("rust/SKILL.md").as_str(), "# Rust\n").unwrap();
1439
1440        let dirs = vec![dir_missing, dir_exists];
1441        let skills = load_skills(&dirs).unwrap();
1442        assert_eq!(skills.len(), 1);
1443        assert!(skills.contains_key("rust"));
1444    }
1445
1446    fn write_tool_contract(root: &Path, tool: &str, description: &str) {
1447        write_tool_script(root, tool, "#!/bin/sh\nexit 0\n");
1448        write_default_tool_manifest(root, tool, description);
1449    }
1450}