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