1use 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
22pub const DEFAULT_THINKING_BUDGET: u32 = 1024;
24pub const AGENTS_CONF_FILE: &str = "agents.conf";
26pub const TOOLS_CONF_FILE: &str = "tools.conf";
28pub const AGENTS_DIR: &str = "agents";
30pub const TOOLS_DIR: &str = "tools";
32pub const SKILLS_DIR: &str = "skills";
34pub const SKILL_FILE: &str = "SKILL.md";
36pub const AGENTS_MD_FILE: &str = "AGENTS.md";
38pub const AGENTS_MD_PATH_ENV: &str = "AGENTS_MD_PATH";
40pub const TOOL_PROTOCOL_VERSION: u32 = 1;
42
43#[derive(Debug)]
48pub struct Config {
49 pub root: Path<'static>,
51 pub default_agent: Option<String>,
53 pub agents: BTreeMap<String, AgentConfig>,
55 pub tools: BTreeMap<String, ToolConfig>,
57 pub skills: BTreeMap<String, SkillConfig>,
59 pub(crate) agents_rc_conf: RcConf,
60 pub(crate) tools_rc_conf: RcConf,
61}
62
63impl Config {
64 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#[derive(Debug)]
138pub struct AgentConfig {
139 pub id: String,
141 pub enabled: SwitchPosition,
143 pub display_name: Option<String>,
145 pub description: Option<String>,
147 pub tools: Vec<String>,
149 pub skills: Vec<String>,
151 pub prompt_path: Path<'static>,
153 pub prompt_markdown: Option<String>,
155 pub chat_config: ChatConfig,
157 pub user_instructions_enabled: bool,
159 pub agents_md_enabled: bool,
161 pub agents_md_path: Option<String>,
163 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#[derive(Debug)]
227pub struct ToolConfig {
228 pub id: String,
230 pub enabled: SwitchPosition,
232 pub confirm_preview: bool,
234 pub executable_path: Option<Path<'static>>,
236 pub manifest_path: Path<'static>,
238 pub manifest: Option<ToolManifest>,
240}
241
242#[derive(Clone, Debug, Eq, PartialEq)]
248pub struct ToolManifest {
249 pub protocol_version: u32,
251 pub description: String,
253 pub input_schema: serde_json::Value,
255}
256
257#[derive(Clone, Debug)]
259pub struct SkillConfig {
260 pub id: String,
262 pub path: Path<'static>,
264 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
609fn 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, ¤t)?;
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 fs::write(root.join("skills/notes.txt").as_str(), "not a skill").unwrap();
1374 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}