1use std::collections::HashSet;
15use std::fs;
16use std::sync::Arc;
17
18use async_trait::async_trait;
19use serde_json::{json, Value};
20
21use crate::error::{Error, Result};
22use crate::llm::ToolSpec;
23use crate::skills::Skill;
24use crate::tools::Tool;
25
26const MAX_DEPTH: usize = 3;
28
29pub struct LoadSkill {
32 skills: Arc<Vec<Skill>>,
33}
34
35impl LoadSkill {
36 pub fn new(skills: Vec<Skill>) -> Self {
37 Self {
38 skills: Arc::new(skills),
39 }
40 }
41
42 fn resolve_deps(
48 &self,
49 skill: &Skill,
50 visited: &mut HashSet<String>,
51 depth: usize,
52 ) -> Result<Vec<(String, String)>> {
53 if depth > MAX_DEPTH {
54 return Err(Error::Tool {
55 name: "Skill".into(),
56 call_id: None,
57 message: "dependency tree too deep (max 3 levels)".to_string(),
58 });
59 }
60
61 let mut deps = Vec::new();
62
63 for dep_name in &skill.depends_on {
64 if !visited.insert(dep_name.to_lowercase()) {
66 deps.push((
68 dep_name.clone(),
69 format!(
70 "[WARNING: circular dependency detected, skipping {}]",
71 dep_name
72 ),
73 ));
74 continue;
75 }
76
77 let dep_skill = self
79 .skills
80 .iter()
81 .find(|s| s.name.to_lowercase() == dep_name.to_lowercase())
82 .ok_or_else(|| Error::Tool {
83 name: "Skill".into(),
84 call_id: None,
85 message: format!(
86 "dependency '{}' not found (required by '{}')",
87 dep_name, skill.name
88 ),
89 })?;
90
91 let sub_deps = self.resolve_deps(dep_skill, visited, depth + 1)?;
93 deps.extend(sub_deps);
94
95 let content = fs::read_to_string(&dep_skill.path).map_err(|e| Error::Tool {
97 name: "Skill".into(),
98 call_id: None,
99 message: format!("failed to read dependency '{}': {e}", dep_skill.name),
100 })?;
101
102 let body = content
103 .strip_prefix("---")
104 .and_then(|rest| rest.find("---").map(|end| rest[end + 3..].trim()))
105 .unwrap_or(content.trim())
106 .to_string();
107
108 deps.push((dep_skill.name.clone(), body));
109
110 visited.remove(&dep_name.to_lowercase());
112 }
113
114 Ok(deps)
115 }
116}
117
118#[async_trait]
119impl Tool for LoadSkill {
120 fn spec(&self) -> ToolSpec {
121 ToolSpec {
122 name: "Skill".into(),
123 description: "Load a skill's content by name (case-insensitive). Optionally load a reference document from the skill's refs/ directory, or a named section from the skill body. Pass params for template substitution.".into(),
124 parameters: json!({
125 "type": "object",
126 "properties": {
127 "name": {
128 "type": "string",
129 "description": "Name of the skill to load"
130 },
131 "ref": {
132 "type": "string",
133 "description": "Optional name of a reference document to load (e.g. 'api-spec'). Use `Skill` without `ref` first to see available refs."
134 },
135 "section": {
136 "type": "string",
137 "description": "Optional name of a section to load (e.g. 'Overview'). Sections are delimited by ## headings in the skill body. Use `Skill` without `section` first to see available sections."
138 },
139 "params": {
140 "type": "object",
141 "description": "Optional parameter values for template substitution (e.g. {\"language\": \"python\"}). See skill index for declared params.",
142 "additionalProperties": {
143 "type": "string"
144 }
145 }
146 },
147 "required": ["name"]
148 }),
149 }
150 }
151
152 fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
153 crate::tools::ToolSideEffect::ReadOnly
154 }
155
156 async fn execute(&self, arguments: Value) -> Result<String> {
157 let name = arguments["name"]
158 .as_str()
159 .ok_or_else(|| Error::BadToolArgs {
160 name: "Skill".into(),
161 message: "missing required parameter: name".to_string(),
162 })?;
163
164 let skill = self
166 .skills
167 .iter()
168 .find(|s| s.name.to_lowercase() == name.to_lowercase())
169 .ok_or_else(|| Error::Tool {
170 name: "Skill".into(),
171 call_id: None,
172 message: format!("skill not found: {name}"),
173 })?;
174
175 if let Some(ref_name) = arguments["ref"].as_str() {
177 let ref_name = ref_name.trim();
178 if ref_name.is_empty() {
179 return Err(Error::BadToolArgs {
180 name: "Skill".into(),
181 message: "ref parameter must be a non-empty string".to_string(),
182 });
183 }
184
185 let skill_ref = skill
187 .refs
188 .iter()
189 .find(|r| r.name.to_lowercase() == ref_name.to_lowercase())
190 .ok_or_else(|| {
191 let available: Vec<&str> = skill.refs.iter().map(|r| r.name.as_str()).collect();
192 let available_list = if available.is_empty() {
193 "no refs available for this skill".to_string()
194 } else {
195 format!("available refs: {}", available.join(", "))
196 };
197 Error::Tool {
198 name: "Skill".into(),
199 call_id: None,
200 message: format!("ref not found: '{ref_name}'. {available_list}"),
201 }
202 })?;
203
204 let content = fs::read_to_string(&skill_ref.path).map_err(|e| Error::Tool {
205 name: "Skill".into(),
206 call_id: None,
207 message: format!("failed to read ref file: {e}"),
208 })?;
209
210 return Ok(content.trim().to_string());
211 }
212
213 if let Some(section_name) = arguments["section"].as_str() {
215 let section_name = section_name.trim();
216 if section_name.is_empty() {
217 return Err(Error::BadToolArgs {
218 name: "Skill".into(),
219 message: "section parameter must be a non-empty string".to_string(),
220 });
221 }
222
223 let section = skill
225 .sections
226 .iter()
227 .find(|s| s.name.to_lowercase() == section_name.to_lowercase())
228 .ok_or_else(|| {
229 let available: Vec<&str> =
230 skill.sections.iter().map(|s| s.name.as_str()).collect();
231 let available_list = if available.is_empty() {
232 "no sections available for this skill".to_string()
233 } else {
234 format!("available sections: {}", available.join(", "))
235 };
236 Error::Tool {
237 name: "Skill".into(),
238 call_id: None,
239 message: format!("section not found: '{section_name}'. {available_list}"),
240 }
241 })?;
242
243 let provided_params = arguments["params"].as_object();
245 let resolved = resolve_params(skill, provided_params)?;
246
247 let rendered = if resolved.is_empty() {
248 section.content.clone()
249 } else {
250 let mut result = section.content.clone();
251 for (key, value) in &resolved {
252 result = result.replace(&format!("{{{{{key}}}}}"), value);
253 }
254 result
255 };
256
257 let rendered = substitute_skill_dir(&rendered, skill);
263
264 return Ok(rendered);
265 }
266
267 let content = fs::read_to_string(&skill.path).map_err(|e| Error::Tool {
269 name: "Skill".into(),
270 call_id: None,
271 message: format!("failed to read skill file: {e}"),
272 })?;
273
274 let body = content
275 .strip_prefix("---")
276 .and_then(|rest| rest.find("---").map(|end| rest[end + 3..].trim()))
277 .unwrap_or(content.trim())
278 .to_string();
279
280 let provided_params = arguments["params"].as_object();
282 let resolved = resolve_params(skill, provided_params)?;
283
284 let rendered = if resolved.is_empty() {
286 body
287 } else {
288 let mut result = body;
289 for (key, value) in &resolved {
290 result = result.replace(&format!("{{{{{key}}}}}"), value);
291 }
292 result
293 };
294
295 let rendered = substitute_skill_dir(&rendered, skill);
300
301 let mut visited = HashSet::new();
303 visited.insert(skill.name.to_lowercase());
304 let deps = self.resolve_deps(skill, &mut visited, 1)?;
305
306 if deps.is_empty() {
307 return Ok(rendered);
308 }
309
310 let mut output = String::new();
312 for (dep_name, dep_body) in &deps {
313 output.push_str(&format!("=== Dependency: {dep_name} ===\n{dep_body}\n\n"));
314 }
315 output.push_str(&format!("=== Skill: {} ===\n{}", skill.name, rendered));
316
317 Ok(output)
318 }
319}
320
321fn substitute_skill_dir(content: &str, skill: &Skill) -> String {
334 const SKILL_DIR: &str = "${SKILL_DIR}";
335 const RECURSIVE_SKILL_DIR: &str = "${RECURSIVE_SKILL_DIR}";
336
337 let dir: Option<String> = skill.path.parent().map(|p| p.to_string_lossy().to_string());
338
339 let mut out = String::with_capacity(content.len());
340 let mut i = 0;
341 while i < content.len() {
342 let rest = &content[i..];
343 if let Some(after) = rest.strip_prefix('\\') {
347 if after.starts_with(SKILL_DIR) {
348 out.push_str(SKILL_DIR);
349 i += 1 + SKILL_DIR.len();
350 continue;
351 }
352 if after.starts_with(RECURSIVE_SKILL_DIR) {
353 out.push_str(RECURSIVE_SKILL_DIR);
354 i += 1 + RECURSIVE_SKILL_DIR.len();
355 continue;
356 }
357 }
361 if let Some(d) = &dir {
362 if rest.starts_with(SKILL_DIR) {
363 out.push_str(d);
364 i += SKILL_DIR.len();
365 continue;
366 }
367 if rest.starts_with(RECURSIVE_SKILL_DIR) {
368 out.push_str(d);
369 i += RECURSIVE_SKILL_DIR.len();
370 continue;
371 }
372 }
373 let next = i + 1;
376 let mut end = next;
377 while end < content.len() && !content.is_char_boundary(end) {
378 end += 1;
379 }
380 out.push_str(&content[i..end]);
381 i = end;
382 }
383 out
384}
385
386fn resolve_params(
389 skill: &Skill,
390 provided_params: Option<&serde_json::Map<String, Value>>,
391) -> Result<Vec<(String, String)>> {
392 let mut resolved = Vec::new();
393 let mut missing_required = Vec::new();
394
395 for param in &skill.params {
396 if let Some(obj) = provided_params {
398 if let Some(val) = obj.get(¶m.name).and_then(|v| v.as_str()) {
399 resolved.push((param.name.clone(), val.to_string()));
400 continue;
401 }
402 }
403
404 if let Some(ref default) = param.default {
406 resolved.push((param.name.clone(), default.clone()));
407 } else {
408 missing_required.push(param.name.clone());
410 }
411 }
412
413 if !missing_required.is_empty() {
414 return Err(Error::BadToolArgs {
415 name: "Skill".into(),
416 message: format!(
417 "missing required params: {}. Declared params: {}",
418 missing_required.join(", "),
419 skill
420 .params
421 .iter()
422 .map(|p| {
423 if let Some(ref d) = p.default {
424 format!("{}={}", p.name, d)
425 } else {
426 format!("{} (required)", p.name)
427 }
428 })
429 .collect::<Vec<_>>()
430 .join(", ")
431 ),
432 });
433 }
434
435 Ok(resolved)
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441 use crate::skills::{SkillMode, SkillSection};
442 use std::io::Write;
443 use std::path::PathBuf;
444
445 #[test]
446 fn load_skill_returns_body_for_known_skill() {
447 let tmp = tempfile::TempDir::new().unwrap();
448 let base = tmp.path();
449
450 let rust_dir = base.join("rust-traits");
452 fs::create_dir(&rust_dir).unwrap();
453 let mut file = fs::File::create(rust_dir.join("SKILL.md")).unwrap();
454 writeln!(file, "---").unwrap();
455 writeln!(file, "name: rust-traits").unwrap();
456 writeln!(file, "description: Explain traits").unwrap();
457 writeln!(file, "---").unwrap();
458 writeln!(file, "Skill body content here.").unwrap();
459
460 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
461 let tool = LoadSkill::new(skills);
462
463 let result = tokio::runtime::Runtime::new()
464 .unwrap()
465 .block_on(tool.execute(json!({"name": "rust-traits"})));
466
467 assert!(result.is_ok());
468 assert_eq!(result.unwrap(), "Skill body content here.");
469 }
470
471 #[test]
472 fn load_skill_errors_for_unknown_name() {
473 let tmp = tempfile::TempDir::new().unwrap();
474 let base = tmp.path();
475
476 let rust_dir = base.join("rust-traits");
478 fs::create_dir(&rust_dir).unwrap();
479 fs::write(rust_dir.join("SKILL.md"), "Some content").unwrap();
480
481 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
482 let tool = LoadSkill::new(skills);
483
484 let result = tokio::runtime::Runtime::new()
485 .unwrap()
486 .block_on(tool.execute(json!({"name": "nonexistent"})));
487
488 assert!(result.is_err());
489 }
490
491 #[test]
492 fn load_skill_case_insensitive() {
493 let tmp = tempfile::TempDir::new().unwrap();
494 let base = tmp.path();
495
496 let rust_dir = base.join("rust-traits");
497 fs::create_dir(&rust_dir).unwrap();
498 fs::write(rust_dir.join("SKILL.md"), "Body content").unwrap();
499
500 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
501 let tool = LoadSkill::new(skills);
502
503 let result = tokio::runtime::Runtime::new()
505 .unwrap()
506 .block_on(tool.execute(json!({"name": "RUST-TRAITS"})));
507
508 assert!(result.is_ok());
509 assert_eq!(result.unwrap(), "Body content");
510 }
511
512 #[test]
513 fn load_skill_with_ref_returns_ref_content() {
514 let tmp = tempfile::TempDir::new().unwrap();
515 let base = tmp.path();
516
517 let skill_dir = base.join("my-skill");
519 fs::create_dir(&skill_dir).unwrap();
520 fs::write(
521 skill_dir.join("SKILL.md"),
522 "---\nname: my-skill\ndescription: A skill with refs\n---\n\nBody",
523 )
524 .unwrap();
525
526 let refs_dir = skill_dir.join("refs");
527 fs::create_dir(&refs_dir).unwrap();
528 fs::write(refs_dir.join("api-spec.md"), "# API Spec\n\nDetails here.").unwrap();
529
530 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
531 let tool = LoadSkill::new(skills);
532
533 let result = tokio::runtime::Runtime::new()
534 .unwrap()
535 .block_on(tool.execute(json!({"name": "my-skill", "ref": "api-spec"})));
536
537 assert!(result.is_ok());
538 assert_eq!(result.unwrap(), "# API Spec\n\nDetails here.");
539 }
540
541 #[test]
542 fn load_skill_with_unknown_ref_returns_error_with_available() {
543 let tmp = tempfile::TempDir::new().unwrap();
544 let base = tmp.path();
545
546 let skill_dir = base.join("my-skill");
548 fs::create_dir(&skill_dir).unwrap();
549 fs::write(
550 skill_dir.join("SKILL.md"),
551 "---\nname: my-skill\ndescription: A skill with refs\n---\n\nBody",
552 )
553 .unwrap();
554
555 let refs_dir = skill_dir.join("refs");
556 fs::create_dir(&refs_dir).unwrap();
557 fs::write(refs_dir.join("api-spec.md"), "# API Spec").unwrap();
558 fs::write(refs_dir.join("examples.txt"), "Example 1").unwrap();
559
560 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
561 let tool = LoadSkill::new(skills);
562
563 let result = tokio::runtime::Runtime::new()
564 .unwrap()
565 .block_on(tool.execute(json!({"name": "my-skill", "ref": "nonexistent"})));
566
567 assert!(result.is_err());
568 let err = result.unwrap_err().to_string();
569 assert!(
570 err.contains("ref not found"),
571 "error should mention ref not found: {err}"
572 );
573 assert!(
574 err.contains("api-spec") && err.contains("examples"),
575 "error should list available refs: {err}"
576 );
577 }
578
579 #[test]
580 fn load_skill_with_ref_case_insensitive() {
581 let tmp = tempfile::TempDir::new().unwrap();
582 let base = tmp.path();
583
584 let skill_dir = base.join("my-skill");
585 fs::create_dir(&skill_dir).unwrap();
586 fs::write(
587 skill_dir.join("SKILL.md"),
588 "---\nname: my-skill\ndescription: test\n---\n\nBody",
589 )
590 .unwrap();
591
592 let refs_dir = skill_dir.join("refs");
593 fs::create_dir(&refs_dir).unwrap();
594 fs::write(refs_dir.join("API-Spec.md"), "Content").unwrap();
595
596 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
597 let tool = LoadSkill::new(skills);
598
599 let result = tokio::runtime::Runtime::new()
601 .unwrap()
602 .block_on(tool.execute(json!({"name": "my-skill", "ref": "api-spec"})));
603
604 assert!(result.is_ok());
605 assert_eq!(result.unwrap(), "Content");
606 }
607
608 #[test]
609 fn load_skill_with_empty_ref_returns_error() {
610 let tmp = tempfile::TempDir::new().unwrap();
611 let base = tmp.path();
612
613 let skill_dir = base.join("my-skill");
614 fs::create_dir(&skill_dir).unwrap();
615 fs::write(skill_dir.join("SKILL.md"), "Body").unwrap();
616
617 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
618 let tool = LoadSkill::new(skills);
619
620 let result = tokio::runtime::Runtime::new()
621 .unwrap()
622 .block_on(tool.execute(json!({"name": "my-skill", "ref": ""})));
623
624 assert!(result.is_err());
625 }
626
627 #[test]
628 fn load_skill_with_params_performs_substitution() {
629 let tmp = tempfile::TempDir::new().unwrap();
630 let base = tmp.path();
631
632 let skill_dir = base.join("code-review");
633 fs::create_dir(&skill_dir).unwrap();
634 fs::write(
635 skill_dir.join("SKILL.md"),
636 "---\n\
637 name: code-review\n\
638 description: Review code\n\
639 params:\n\
640 \x20\x20- name: language\n\
641 \x20\x20 description: Target language\n\
642 \x20\x20 default: rust\n\
643 ---\n\
644 \n\
645 Review {{language}} code.",
646 )
647 .unwrap();
648
649 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
650 let tool = LoadSkill::new(skills);
651
652 let result = tokio::runtime::Runtime::new().unwrap().block_on(
653 tool.execute(json!({"name": "code-review", "params": {"language": "python"}})),
654 );
655
656 assert!(result.is_ok());
657 assert_eq!(result.unwrap(), "Review python code.");
658 }
659
660 #[test]
661 fn load_skill_uses_defaults_when_params_not_provided() {
662 let tmp = tempfile::TempDir::new().unwrap();
663 let base = tmp.path();
664
665 let skill_dir = base.join("code-review");
666 fs::create_dir(&skill_dir).unwrap();
667 fs::write(
668 skill_dir.join("SKILL.md"),
669 "---\n\
670 name: code-review\n\
671 description: Review code\n\
672 params:\n\
673 \x20\x20- name: language\n\
674 \x20\x20 description: Target language\n\
675 \x20\x20 default: rust\n\
676 ---\n\
677 \n\
678 Review {{language}} code.",
679 )
680 .unwrap();
681
682 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
683 let tool = LoadSkill::new(skills);
684
685 let result = tokio::runtime::Runtime::new()
687 .unwrap()
688 .block_on(tool.execute(json!({"name": "code-review"})));
689
690 assert!(result.is_ok());
691 assert_eq!(result.unwrap(), "Review rust code.");
692 }
693
694 #[test]
695 fn load_skill_errors_on_missing_required_param() {
696 let tmp = tempfile::TempDir::new().unwrap();
697 let base = tmp.path();
698
699 let skill_dir = base.join("greeter");
700 fs::create_dir(&skill_dir).unwrap();
701 fs::write(
702 skill_dir.join("SKILL.md"),
703 "---\n\
704 name: greeter\n\
705 description: Greet someone\n\
706 params:\n\
707 \x20\x20- name: name\n\
708 \x20\x20 description: Name to greet\n\
709 ---\n\
710 \n\
711 Hello {{name}}!",
712 )
713 .unwrap();
714
715 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
716 let tool = LoadSkill::new(skills);
717
718 let result = tokio::runtime::Runtime::new()
720 .unwrap()
721 .block_on(tool.execute(json!({"name": "greeter"})));
722
723 assert!(result.is_err());
724 let err = result.unwrap_err().to_string();
725 assert!(
726 err.contains("missing required params"),
727 "error should mention missing required params: {err}"
728 );
729 assert!(
730 err.contains("name"),
731 "error should mention the missing param name: {err}"
732 );
733 }
734
735 #[test]
736 fn load_skill_with_params_substitutes_multiple() {
737 let tmp = tempfile::TempDir::new().unwrap();
738 let base = tmp.path();
739
740 let skill_dir = base.join("multi-param");
741 fs::create_dir(&skill_dir).unwrap();
742 fs::write(
743 skill_dir.join("SKILL.md"),
744 "---\n\
745 name: multi-param\n\
746 description: Multiple params\n\
747 params:\n\
748 \x20\x20- name: lang\n\
749 \x20\x20 description: Language\n\
750 \x20\x20 default: rust\n\
751 \x20\x20- name: mode\n\
752 \x20\x20 description: Mode\n\
753 \x20\x20 default: strict\n\
754 ---\n\
755 \n\
756 Lang={{lang}} Mode={{mode}}",
757 )
758 .unwrap();
759
760 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
761 let tool = LoadSkill::new(skills);
762
763 let result =
764 tokio::runtime::Runtime::new()
765 .unwrap()
766 .block_on(tool.execute(
767 json!({"name": "multi-param", "params": {"lang": "python", "mode": "lax"}}),
768 ));
769
770 assert!(result.is_ok());
771 assert_eq!(result.unwrap(), "Lang=python Mode=lax");
772 }
773
774 #[test]
777 fn load_skill_with_section_returns_section_content() {
778 let tmp = tempfile::TempDir::new().unwrap();
779 let base = tmp.path();
780
781 let skill_dir = base.join("sectioned-skill");
782 fs::create_dir(&skill_dir).unwrap();
783 fs::write(
784 skill_dir.join("SKILL.md"),
785 "---\nname: sectioned-skill\ndescription: Has sections\n---\n\n## Overview\n\nGeneral info.\n\n## Details\n\nSpecific content.",
786 )
787 .unwrap();
788
789 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
790 let tool = LoadSkill::new(skills);
791
792 let result = tokio::runtime::Runtime::new()
793 .unwrap()
794 .block_on(tool.execute(json!({"name": "sectioned-skill", "section": "Overview"})));
795
796 assert!(result.is_ok());
797 assert_eq!(result.unwrap(), "General info.");
798 }
799
800 #[test]
801 fn load_skill_with_section_case_insensitive() {
802 let tmp = tempfile::TempDir::new().unwrap();
803 let base = tmp.path();
804
805 let skill_dir = base.join("sectioned-skill");
806 fs::create_dir(&skill_dir).unwrap();
807 fs::write(
808 skill_dir.join("SKILL.md"),
809 "---\nname: sectioned-skill\ndescription: Has sections\n---\n\n## Overview\n\nGeneral info.",
810 )
811 .unwrap();
812
813 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
814 let tool = LoadSkill::new(skills);
815
816 let result = tokio::runtime::Runtime::new()
817 .unwrap()
818 .block_on(tool.execute(json!({"name": "sectioned-skill", "section": "overview"})));
819
820 assert!(result.is_ok());
821 assert_eq!(result.unwrap(), "General info.");
822 }
823
824 #[test]
825 fn load_skill_with_unknown_section_returns_error_with_available() {
826 let tmp = tempfile::TempDir::new().unwrap();
827 let base = tmp.path();
828
829 let skill_dir = base.join("sectioned-skill");
830 fs::create_dir(&skill_dir).unwrap();
831 fs::write(
832 skill_dir.join("SKILL.md"),
833 "---\nname: sectioned-skill\ndescription: Has sections\n---\n\n## Overview\n\nGeneral info.\n\n## Details\n\nSpecific content.",
834 )
835 .unwrap();
836
837 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
838 let tool = LoadSkill::new(skills);
839
840 let result = tokio::runtime::Runtime::new()
841 .unwrap()
842 .block_on(tool.execute(json!({"name": "sectioned-skill", "section": "Nonexistent"})));
843
844 assert!(result.is_err());
845 let err = result.unwrap_err().to_string();
846 assert!(
847 err.contains("section not found"),
848 "error should mention section not found: {err}"
849 );
850 assert!(
851 err.contains("Overview") && err.contains("Details"),
852 "error should list available sections: {err}"
853 );
854 }
855
856 #[test]
857 fn load_skill_with_empty_section_returns_error() {
858 let tmp = tempfile::TempDir::new().unwrap();
859 let base = tmp.path();
860
861 let skill_dir = base.join("sectioned-skill");
862 fs::create_dir(&skill_dir).unwrap();
863 fs::write(
864 skill_dir.join("SKILL.md"),
865 "---\nname: sectioned-skill\ndescription: Has sections\n---\n\n## Overview\n\nGeneral info.",
866 )
867 .unwrap();
868
869 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
870 let tool = LoadSkill::new(skills);
871
872 let result = tokio::runtime::Runtime::new()
873 .unwrap()
874 .block_on(tool.execute(json!({"name": "sectioned-skill", "section": ""})));
875
876 assert!(result.is_err());
877 }
878
879 #[test]
880 fn load_skill_with_section_and_params_performs_substitution() {
881 let tmp = tempfile::TempDir::new().unwrap();
882 let base = tmp.path();
883
884 let skill_dir = base.join("template-skill");
885 fs::create_dir(&skill_dir).unwrap();
886 fs::write(
887 skill_dir.join("SKILL.md"),
888 "---\n\
889 name: template-skill\n\
890 description: Has templates\n\
891 params:\n\
892 \x20\x20- name: lang\n\
893 \x20\x20 description: Language\n\
894 \x20\x20 default: rust\n\
895 ---\n\
896 \n\
897 ## Overview\n\
898 \n\
899 Review {{lang}} code.\n\
900 \n\
901 ## Details\n\
902 \n\
903 More about {{lang}}.",
904 )
905 .unwrap();
906
907 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
908 let tool = LoadSkill::new(skills);
909
910 let result = tokio::runtime::Runtime::new()
911 .unwrap()
912 .block_on(tool.execute(
913 json!({"name": "template-skill", "section": "Overview", "params": {"lang": "python"}}),
914 ));
915
916 assert!(result.is_ok());
917 assert_eq!(result.unwrap(), "Review python code.");
918 }
919
920 #[test]
923 fn load_skill_substitutes_skill_dir_in_body() {
924 let tmp = tempfile::TempDir::new().unwrap();
925 let base = tmp.path();
926
927 let skill_dir = base.join("my-skill");
928 fs::create_dir(&skill_dir).unwrap();
929 fs::write(
930 skill_dir.join("SKILL.md"),
931 "---\nname: my-skill\ndescription: A skill that runs a script\n---\n\n\
932 Run the linter with: `bash ${SKILL_DIR}/scripts/lint.sh`",
933 )
934 .unwrap();
935
936 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
937 let expected_dir = skills[0]
938 .path
939 .parent()
940 .unwrap()
941 .to_string_lossy()
942 .to_string();
943 let tool = LoadSkill::new(skills);
944
945 let result = tokio::runtime::Runtime::new()
946 .unwrap()
947 .block_on(tool.execute(json!({"name": "my-skill"})));
948
949 assert!(result.is_ok());
950 let expected = format!("Run the linter with: `bash {expected_dir}/scripts/lint.sh`");
951 assert_eq!(result.unwrap(), expected);
952 }
953
954 #[test]
955 fn load_skill_substitutes_recursive_skill_dir_alias() {
956 let tmp = tempfile::TempDir::new().unwrap();
957 let base = tmp.path();
958
959 let skill_dir = base.join("my-skill");
960 fs::create_dir(&skill_dir).unwrap();
961 fs::write(
962 skill_dir.join("SKILL.md"),
963 "---\nname: my-skill\ndescription: A skill that runs a script\n---\n\n\
964 Run with `${RECURSIVE_SKILL_DIR}/scripts/lint.sh`.",
965 )
966 .unwrap();
967
968 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
969 let expected_dir = skills[0]
970 .path
971 .parent()
972 .unwrap()
973 .to_string_lossy()
974 .to_string();
975 let tool = LoadSkill::new(skills);
976
977 let result = tokio::runtime::Runtime::new()
978 .unwrap()
979 .block_on(tool.execute(json!({"name": "my-skill"})));
980
981 assert!(result.is_ok());
982 let expected = format!("Run with `{expected_dir}/scripts/lint.sh`.");
983 assert_eq!(result.unwrap(), expected);
984 }
985
986 #[test]
987 fn load_skill_substitutes_skill_dir_in_section() {
988 let tmp = tempfile::TempDir::new().unwrap();
989 let base = tmp.path();
990
991 let skill_dir = base.join("my-skill");
992 fs::create_dir(&skill_dir).unwrap();
993 fs::write(
994 skill_dir.join("SKILL.md"),
995 "---\nname: my-skill\ndescription: Has a script-running section\n---\n\n\
996 ## Overview\n\n\
997 Intro without placeholders.\n\n\
998 ## Run\n\n\
999 Execute `${SKILL_DIR}/scripts/run.sh` to start.",
1000 )
1001 .unwrap();
1002
1003 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
1004 let expected_dir = skills[0]
1005 .path
1006 .parent()
1007 .unwrap()
1008 .to_string_lossy()
1009 .to_string();
1010 let tool = LoadSkill::new(skills);
1011
1012 let result = tokio::runtime::Runtime::new()
1013 .unwrap()
1014 .block_on(tool.execute(json!({"name": "my-skill", "section": "Run"})));
1015
1016 assert!(result.is_ok());
1017 let expected = format!("Execute `{expected_dir}/scripts/run.sh` to start.");
1018 assert_eq!(result.unwrap(), expected);
1019 }
1020
1021 #[test]
1022 fn load_skill_does_not_substitute_skill_dir_in_ref() {
1023 let tmp = tempfile::TempDir::new().unwrap();
1024 let base = tmp.path();
1025
1026 let skill_dir = base.join("my-skill");
1030 fs::create_dir(&skill_dir).unwrap();
1031 fs::write(
1032 skill_dir.join("SKILL.md"),
1033 "---\nname: my-skill\ndescription: A skill with a literal-ref\n---\n\nBody",
1034 )
1035 .unwrap();
1036
1037 let refs_dir = skill_dir.join("refs");
1038 fs::create_dir(&refs_dir).unwrap();
1039 fs::write(
1040 refs_dir.join("literal.md"),
1041 "The literal text `${SKILL_DIR}` and `${RECURSIVE_SKILL_DIR}` must pass through.",
1042 )
1043 .unwrap();
1044
1045 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
1046 let tool = LoadSkill::new(skills);
1047
1048 let result = tokio::runtime::Runtime::new()
1049 .unwrap()
1050 .block_on(tool.execute(json!({"name": "my-skill", "ref": "literal"})));
1051
1052 assert!(result.is_ok());
1053 let body = result.unwrap();
1054 assert!(
1055 body.contains("${SKILL_DIR}"),
1056 "ref should preserve literal ${{SKILL_DIR}} text: {body}"
1057 );
1058 assert!(
1059 body.contains("${RECURSIVE_SKILL_DIR}"),
1060 "ref should preserve literal ${{RECURSIVE_SKILL_DIR}} text: {body}"
1061 );
1062 }
1063
1064 #[test]
1065 fn load_skill_no_skill_dir_when_path_has_no_parent() {
1066 let skill = Skill {
1072 name: "weird-skill".to_string(),
1073 description: "Skill with no parent path".to_string(),
1074 path: PathBuf::from("/"),
1075 mode: SkillMode::Manual,
1076 triggers: vec![],
1077 hint: String::new(),
1078 depends_on: vec![],
1079 refs: vec![],
1080 params: vec![],
1081 scripts: vec![],
1082 sections: vec![SkillSection {
1083 name: "Overview".to_string(),
1084 content: "Run bash ${SKILL_DIR}/scripts/lint.sh".to_string(),
1085 }],
1086 globs: None,
1087 };
1088
1089 let tool = LoadSkill::new(vec![skill]);
1090
1091 let result = tokio::runtime::Runtime::new()
1092 .unwrap()
1093 .block_on(tool.execute(json!({"name": "weird-skill", "section": "Overview"})));
1094
1095 assert!(result.is_ok(), "should not panic: {result:?}");
1096 assert_eq!(result.unwrap(), "Run bash ${SKILL_DIR}/scripts/lint.sh");
1098 }
1099
1100 #[test]
1101 fn load_skill_escape_preserves_literal_skill_dir() {
1102 let tmp = tempfile::TempDir::new().unwrap();
1106 let base = tmp.path();
1107
1108 let skill_dir = base.join("my-skill");
1109 fs::create_dir(&skill_dir).unwrap();
1110 fs::write(
1111 skill_dir.join("SKILL.md"),
1112 "---\nname: my-skill\ndescription: documents the placeholder\n---\n\n\
1113 Use \\${SKILL_DIR} to reference this skill's directory.\n\
1114 Run `bash ${SKILL_DIR}/scripts/lint.sh`.",
1115 )
1116 .unwrap();
1117
1118 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
1119 let expected_dir = skills[0]
1120 .path
1121 .parent()
1122 .unwrap()
1123 .to_string_lossy()
1124 .to_string();
1125 let tool = LoadSkill::new(skills);
1126
1127 let result = tokio::runtime::Runtime::new()
1128 .unwrap()
1129 .block_on(tool.execute(json!({"name": "my-skill"})));
1130
1131 let expected = format!(
1132 "Use ${{SKILL_DIR}} to reference this skill's directory.\n\
1133 Run `bash {expected_dir}/scripts/lint.sh`."
1134 );
1135 assert_eq!(result.unwrap(), expected);
1136 }
1137
1138 #[test]
1139 fn load_skill_escape_preserves_literal_recursive_skill_dir() {
1140 let tmp = tempfile::TempDir::new().unwrap();
1141 let base = tmp.path();
1142
1143 let skill_dir = base.join("my-skill");
1144 fs::create_dir(&skill_dir).unwrap();
1145 fs::write(
1146 skill_dir.join("SKILL.md"),
1147 "---\nname: my-skill\ndescription: documents the alias\n---\n\n\
1148 Alias form: \\${RECURSIVE_SKILL_DIR} stays literal.",
1149 )
1150 .unwrap();
1151
1152 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
1153 let tool = LoadSkill::new(skills);
1154
1155 let result = tokio::runtime::Runtime::new()
1156 .unwrap()
1157 .block_on(tool.execute(json!({"name": "my-skill"})));
1158
1159 assert_eq!(
1160 result.unwrap(),
1161 "Alias form: ${RECURSIVE_SKILL_DIR} stays literal."
1162 );
1163 }
1164
1165 #[test]
1166 fn load_skill_lone_backslash_preserved() {
1167 let tmp = tempfile::TempDir::new().unwrap();
1171 let base = tmp.path();
1172
1173 let skill_dir = base.join("my-skill");
1174 fs::create_dir(&skill_dir).unwrap();
1175 fs::write(
1176 skill_dir.join("SKILL.md"),
1177 "---\nname: my-skill\ndescription: backslash handling\n---\n\n\
1178 Path C:\\Users\\foo and a trailing slash \\",
1179 )
1180 .unwrap();
1181
1182 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
1183 let tool = LoadSkill::new(skills);
1184
1185 let result = tokio::runtime::Runtime::new()
1186 .unwrap()
1187 .block_on(tool.execute(json!({"name": "my-skill"})));
1188
1189 assert_eq!(
1190 result.unwrap(),
1191 "Path C:\\Users\\foo and a trailing slash \\"
1192 );
1193 }
1194
1195 fn create_skill(base: &std::path::Path, name: &str, depends_on: &[&str], body: &str) {
1199 let dir = base.join(name);
1200 fs::create_dir(&dir).unwrap();
1201 let mut frontmatter = format!("---\nname: {name}\ndescription: {name} skill\n");
1202 if !depends_on.is_empty() {
1203 frontmatter.push_str(&format!("depends_on: {}\n", depends_on.join(", ")));
1204 }
1205 frontmatter.push_str("---\n\n");
1206 fs::write(dir.join("SKILL.md"), format!("{frontmatter}{body}")).unwrap();
1207 }
1208
1209 #[test]
1210 fn load_skill_resolves_single_dependency() {
1211 let tmp = tempfile::TempDir::new().unwrap();
1212 let base = tmp.path();
1213
1214 create_skill(base, "skill-b", &[], "Body of B");
1216 create_skill(base, "skill-a", &["skill-b"], "Body of A");
1218
1219 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
1220 let tool = LoadSkill::new(skills);
1221
1222 let result = tokio::runtime::Runtime::new()
1223 .unwrap()
1224 .block_on(tool.execute(json!({"name": "skill-a"})));
1225
1226 assert!(result.is_ok());
1227 let output = result.unwrap();
1228 assert_eq!(
1229 output,
1230 "=== Dependency: skill-b ===\nBody of B\n\n=== Skill: skill-a ===\nBody of A"
1231 );
1232 }
1233
1234 #[test]
1235 fn load_skill_resolves_multi_level_dependencies() {
1236 let tmp = tempfile::TempDir::new().unwrap();
1237 let base = tmp.path();
1238
1239 create_skill(base, "skill-c", &[], "Body of C");
1241 create_skill(base, "skill-b", &["skill-c"], "Body of B");
1243 create_skill(base, "skill-a", &["skill-b"], "Body of A");
1245
1246 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
1247 let tool = LoadSkill::new(skills);
1248
1249 let result = tokio::runtime::Runtime::new()
1250 .unwrap()
1251 .block_on(tool.execute(json!({"name": "skill-a"})));
1252
1253 assert!(result.is_ok());
1254 let output = result.unwrap();
1255 assert_eq!(
1256 output,
1257 "=== Dependency: skill-c ===\nBody of C\n\n=== Dependency: skill-b ===\nBody of B\n\n=== Skill: skill-a ===\nBody of A"
1258 );
1259 }
1260
1261 #[test]
1262 fn load_skill_detects_circular_dependency() {
1263 let tmp = tempfile::TempDir::new().unwrap();
1264 let base = tmp.path();
1265
1266 create_skill(base, "skill-a", &["skill-b"], "Body of A");
1268 create_skill(base, "skill-b", &["skill-a"], "Body of B");
1270
1271 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
1272 let tool = LoadSkill::new(skills);
1273
1274 let result = tokio::runtime::Runtime::new()
1275 .unwrap()
1276 .block_on(tool.execute(json!({"name": "skill-a"})));
1277
1278 assert!(result.is_ok());
1279 let output = result.unwrap();
1280 assert!(
1282 output.contains("circular dependency detected"),
1283 "output should mention circular dependency: {output}"
1284 );
1285 assert!(output.contains("=== Skill: skill-a ==="));
1287 assert!(output.contains("Body of A"));
1288 }
1289
1290 #[test]
1291 fn load_skill_respects_depth_limit() {
1292 let tmp = tempfile::TempDir::new().unwrap();
1293 let base = tmp.path();
1294
1295 create_skill(base, "skill-e", &[], "Body of E");
1297 create_skill(base, "skill-d", &["skill-e"], "Body of D");
1298 create_skill(base, "skill-c", &["skill-d"], "Body of C");
1299 create_skill(base, "skill-b", &["skill-c"], "Body of B");
1300 create_skill(base, "skill-a", &["skill-b"], "Body of A");
1301
1302 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
1303 let tool = LoadSkill::new(skills);
1304
1305 let result = tokio::runtime::Runtime::new()
1306 .unwrap()
1307 .block_on(tool.execute(json!({"name": "skill-a"})));
1308
1309 assert!(result.is_err());
1310 let err = result.unwrap_err().to_string();
1311 assert!(
1312 err.contains("dependency tree too deep"),
1313 "error should mention depth limit: {err}"
1314 );
1315 }
1316
1317 #[test]
1318 fn load_skill_depth_exactly_at_max_succeeds() {
1319 let tmp = tempfile::TempDir::new().unwrap();
1324 let base = tmp.path();
1325
1326 create_skill(base, "skill-c", &[], "Body of C");
1327 create_skill(base, "skill-b", &["skill-c"], "Body of B");
1328 create_skill(base, "skill-a", &["skill-b"], "Body of A");
1329
1330 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
1331 let tool = LoadSkill::new(skills);
1332
1333 let result = tokio::runtime::Runtime::new()
1334 .unwrap()
1335 .block_on(tool.execute(json!({"name": "skill-a"})));
1336
1337 assert!(
1338 result.is_ok(),
1339 "chain A→B→C (resolved at depth 1/2/3 = MAX_DEPTH) must succeed, got: {:?}",
1340 result
1341 );
1342 let output = result.unwrap();
1343 assert!(
1344 output.contains("Body of C"),
1345 "must include deepest dep body"
1346 );
1347 }
1348
1349 #[test]
1350 fn parse_skill_meta_single_depends_on() {
1351 let tmp = tempfile::TempDir::new().unwrap();
1352 let dir = tmp.path().join("test-skill");
1353 fs::create_dir(&dir).unwrap();
1354 let content =
1355 "---\nname: test-skill\ndescription: A test\ndepends_on: base-skill\n---\n\nBody text";
1356 let (_, _, _, _, _, depends_on, _, _) = crate::skills::parse_skill_meta(content, &dir);
1357 assert_eq!(depends_on, vec!["base-skill"]);
1358 }
1359
1360 #[test]
1361 fn parse_skill_meta_multiple_depends_on() {
1362 let tmp = tempfile::TempDir::new().unwrap();
1363 let dir = tmp.path().join("test-skill");
1364 fs::create_dir(&dir).unwrap();
1365 let content =
1366 "---\nname: test-skill\ndescription: A test\ndepends_on: base-skill, utils, logging\n---\n\nBody text";
1367 let (_, _, _, _, _, depends_on, _, _) = crate::skills::parse_skill_meta(content, &dir);
1368 assert_eq!(depends_on, vec!["base-skill", "utils", "logging"]);
1369 }
1370
1371 #[test]
1372 fn parse_skill_meta_no_depends_on() {
1373 let tmp = tempfile::TempDir::new().unwrap();
1374 let dir = tmp.path().join("test-skill");
1375 fs::create_dir(&dir).unwrap();
1376 let content = "---\nname: test-skill\ndescription: A test\n---\n\nBody text";
1377 let (_, _, _, _, _, depends_on, _, _) = crate::skills::parse_skill_meta(content, &dir);
1378 assert!(depends_on.is_empty());
1379 }
1380
1381 #[test]
1382 fn skill_index_shows_depends_on() {
1383 let skills = vec![
1384 Skill {
1385 name: "with-deps".to_string(),
1386 description: "Has dependencies".to_string(),
1387 path: PathBuf::from("/tmp/skills/with-deps/SKILL.md"),
1388 mode: SkillMode::Manual,
1389 triggers: vec![],
1390 hint: String::new(),
1391 depends_on: vec!["base".to_string(), "utils".to_string()],
1392 refs: vec![],
1393 params: vec![],
1394 scripts: vec![],
1395 sections: vec![],
1396 globs: None,
1397 },
1398 Skill {
1399 name: "no-deps".to_string(),
1400 description: "No dependencies".to_string(),
1401 path: PathBuf::from("/tmp/skills/no-deps/SKILL.md"),
1402 mode: SkillMode::Manual,
1403 triggers: vec![],
1404 hint: String::new(),
1405 depends_on: vec![],
1406 refs: vec![],
1407 params: vec![],
1408 scripts: vec![],
1409 sections: vec![],
1410 globs: None,
1411 },
1412 ];
1413
1414 let result = crate::skills::skill_index(&skills);
1415 assert!(
1416 result.contains("depends_on: base, utils"),
1417 "should show depends_on: {result}"
1418 );
1419 assert!(
1420 result.contains("- no-deps: No dependencies"),
1421 "should show skill without deps normally"
1422 );
1423 }
1424}