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: "load_skill".into(),
56 message: "dependency tree too deep (max 3 levels)".to_string(),
57 });
58 }
59
60 let mut deps = Vec::new();
61
62 for dep_name in &skill.depends_on {
63 if !visited.insert(dep_name.to_lowercase()) {
65 deps.push((
67 dep_name.clone(),
68 format!(
69 "[WARNING: circular dependency detected, skipping {}]",
70 dep_name
71 ),
72 ));
73 continue;
74 }
75
76 let dep_skill = self
78 .skills
79 .iter()
80 .find(|s| s.name.to_lowercase() == dep_name.to_lowercase())
81 .ok_or_else(|| Error::Tool {
82 name: "load_skill".into(),
83 message: format!(
84 "dependency '{}' not found (required by '{}')",
85 dep_name, skill.name
86 ),
87 })?;
88
89 let sub_deps = self.resolve_deps(dep_skill, visited, depth + 1)?;
91 deps.extend(sub_deps);
92
93 let content = fs::read_to_string(&dep_skill.path).map_err(|e| Error::Tool {
95 name: "load_skill".into(),
96 message: format!("failed to read dependency '{}': {e}", dep_skill.name),
97 })?;
98
99 let body = content
100 .strip_prefix("---")
101 .and_then(|rest| rest.find("---").map(|end| rest[end + 3..].trim()))
102 .unwrap_or(content.trim())
103 .to_string();
104
105 deps.push((dep_skill.name.clone(), body));
106
107 visited.remove(&dep_name.to_lowercase());
109 }
110
111 Ok(deps)
112 }
113}
114
115#[async_trait]
116impl Tool for LoadSkill {
117 fn spec(&self) -> ToolSpec {
118 ToolSpec {
119 name: "load_skill".into(),
120 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(),
121 parameters: json!({
122 "type": "object",
123 "properties": {
124 "name": {
125 "type": "string",
126 "description": "Name of the skill to load"
127 },
128 "ref": {
129 "type": "string",
130 "description": "Optional name of a reference document to load (e.g. 'api-spec'). Use `load_skill` without `ref` first to see available refs."
131 },
132 "section": {
133 "type": "string",
134 "description": "Optional name of a section to load (e.g. 'Overview'). Sections are delimited by ## headings in the skill body. Use `load_skill` without `section` first to see available sections."
135 },
136 "params": {
137 "type": "object",
138 "description": "Optional parameter values for template substitution (e.g. {\"language\": \"python\"}). See skill index for declared params.",
139 "additionalProperties": {
140 "type": "string"
141 }
142 }
143 },
144 "required": ["name"]
145 }),
146 }
147 }
148
149 fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
150 crate::tools::ToolSideEffect::ReadOnly
151 }
152
153 async fn execute(&self, arguments: Value) -> Result<String> {
154 let name = arguments["name"]
155 .as_str()
156 .ok_or_else(|| Error::BadToolArgs {
157 name: "load_skill".into(),
158 message: "missing required parameter: name".to_string(),
159 })?;
160
161 let skill = self
163 .skills
164 .iter()
165 .find(|s| s.name.to_lowercase() == name.to_lowercase())
166 .ok_or_else(|| Error::Tool {
167 name: "load_skill".into(),
168 message: format!("skill not found: {name}"),
169 })?;
170
171 if let Some(ref_name) = arguments["ref"].as_str() {
173 let ref_name = ref_name.trim();
174 if ref_name.is_empty() {
175 return Err(Error::BadToolArgs {
176 name: "load_skill".into(),
177 message: "ref parameter must be a non-empty string".to_string(),
178 });
179 }
180
181 let skill_ref = skill
183 .refs
184 .iter()
185 .find(|r| r.name.to_lowercase() == ref_name.to_lowercase())
186 .ok_or_else(|| {
187 let available: Vec<&str> = skill.refs.iter().map(|r| r.name.as_str()).collect();
188 let available_list = if available.is_empty() {
189 "no refs available for this skill".to_string()
190 } else {
191 format!("available refs: {}", available.join(", "))
192 };
193 Error::Tool {
194 name: "load_skill".into(),
195 message: format!("ref not found: '{ref_name}'. {available_list}"),
196 }
197 })?;
198
199 let content = fs::read_to_string(&skill_ref.path).map_err(|e| Error::Tool {
200 name: "load_skill".into(),
201 message: format!("failed to read ref file: {e}"),
202 })?;
203
204 return Ok(content.trim().to_string());
205 }
206
207 if let Some(section_name) = arguments["section"].as_str() {
209 let section_name = section_name.trim();
210 if section_name.is_empty() {
211 return Err(Error::BadToolArgs {
212 name: "load_skill".into(),
213 message: "section parameter must be a non-empty string".to_string(),
214 });
215 }
216
217 let section = skill
219 .sections
220 .iter()
221 .find(|s| s.name.to_lowercase() == section_name.to_lowercase())
222 .ok_or_else(|| {
223 let available: Vec<&str> =
224 skill.sections.iter().map(|s| s.name.as_str()).collect();
225 let available_list = if available.is_empty() {
226 "no sections available for this skill".to_string()
227 } else {
228 format!("available sections: {}", available.join(", "))
229 };
230 Error::Tool {
231 name: "load_skill".into(),
232 message: format!("section not found: '{section_name}'. {available_list}"),
233 }
234 })?;
235
236 let provided_params = arguments["params"].as_object();
238 let resolved = resolve_params(skill, provided_params)?;
239
240 let rendered = if resolved.is_empty() {
241 section.content.clone()
242 } else {
243 let mut result = section.content.clone();
244 for (key, value) in &resolved {
245 result = result.replace(&format!("{{{{{key}}}}}"), value);
246 }
247 result
248 };
249
250 return Ok(rendered);
251 }
252
253 let content = fs::read_to_string(&skill.path).map_err(|e| Error::Tool {
255 name: "load_skill".into(),
256 message: format!("failed to read skill file: {e}"),
257 })?;
258
259 let body = content
260 .strip_prefix("---")
261 .and_then(|rest| rest.find("---").map(|end| rest[end + 3..].trim()))
262 .unwrap_or(content.trim())
263 .to_string();
264
265 let provided_params = arguments["params"].as_object();
267 let resolved = resolve_params(skill, provided_params)?;
268
269 let rendered = if resolved.is_empty() {
271 body
272 } else {
273 let mut result = body;
274 for (key, value) in &resolved {
275 result = result.replace(&format!("{{{{{key}}}}}"), value);
276 }
277 result
278 };
279
280 let mut visited = HashSet::new();
282 visited.insert(skill.name.to_lowercase());
283 let deps = self.resolve_deps(skill, &mut visited, 1)?;
284
285 if deps.is_empty() {
286 return Ok(rendered);
287 }
288
289 let mut output = String::new();
291 for (dep_name, dep_body) in &deps {
292 output.push_str(&format!("=== Dependency: {dep_name} ===\n{dep_body}\n\n"));
293 }
294 output.push_str(&format!("=== Skill: {} ===\n{}", skill.name, rendered));
295
296 Ok(output)
297 }
298}
299
300fn resolve_params(
303 skill: &Skill,
304 provided_params: Option<&serde_json::Map<String, Value>>,
305) -> Result<Vec<(String, String)>> {
306 let mut resolved = Vec::new();
307 let mut missing_required = Vec::new();
308
309 for param in &skill.params {
310 if let Some(obj) = provided_params {
312 if let Some(val) = obj.get(¶m.name).and_then(|v| v.as_str()) {
313 resolved.push((param.name.clone(), val.to_string()));
314 continue;
315 }
316 }
317
318 if let Some(ref default) = param.default {
320 resolved.push((param.name.clone(), default.clone()));
321 } else {
322 missing_required.push(param.name.clone());
324 }
325 }
326
327 if !missing_required.is_empty() {
328 return Err(Error::BadToolArgs {
329 name: "load_skill".into(),
330 message: format!(
331 "missing required params: {}. Declared params: {}",
332 missing_required.join(", "),
333 skill
334 .params
335 .iter()
336 .map(|p| {
337 if let Some(ref d) = p.default {
338 format!("{}={}", p.name, d)
339 } else {
340 format!("{} (required)", p.name)
341 }
342 })
343 .collect::<Vec<_>>()
344 .join(", ")
345 ),
346 });
347 }
348
349 Ok(resolved)
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use crate::skills::SkillMode;
356 use std::io::Write;
357 use std::path::PathBuf;
358
359 #[test]
360 fn load_skill_returns_body_for_known_skill() {
361 let tmp = tempfile::TempDir::new().unwrap();
362 let base = tmp.path();
363
364 let rust_dir = base.join("rust-traits");
366 fs::create_dir(&rust_dir).unwrap();
367 let mut file = fs::File::create(rust_dir.join("SKILL.md")).unwrap();
368 writeln!(file, "---").unwrap();
369 writeln!(file, "name: rust-traits").unwrap();
370 writeln!(file, "description: Explain traits").unwrap();
371 writeln!(file, "---").unwrap();
372 writeln!(file, "Skill body content here.").unwrap();
373
374 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
375 let tool = LoadSkill::new(skills);
376
377 let result = tokio::runtime::Runtime::new()
378 .unwrap()
379 .block_on(tool.execute(json!({"name": "rust-traits"})));
380
381 assert!(result.is_ok());
382 assert_eq!(result.unwrap(), "Skill body content here.");
383 }
384
385 #[test]
386 fn load_skill_errors_for_unknown_name() {
387 let tmp = tempfile::TempDir::new().unwrap();
388 let base = tmp.path();
389
390 let rust_dir = base.join("rust-traits");
392 fs::create_dir(&rust_dir).unwrap();
393 fs::write(rust_dir.join("SKILL.md"), "Some content").unwrap();
394
395 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
396 let tool = LoadSkill::new(skills);
397
398 let result = tokio::runtime::Runtime::new()
399 .unwrap()
400 .block_on(tool.execute(json!({"name": "nonexistent"})));
401
402 assert!(result.is_err());
403 }
404
405 #[test]
406 fn load_skill_case_insensitive() {
407 let tmp = tempfile::TempDir::new().unwrap();
408 let base = tmp.path();
409
410 let rust_dir = base.join("rust-traits");
411 fs::create_dir(&rust_dir).unwrap();
412 fs::write(rust_dir.join("SKILL.md"), "Body content").unwrap();
413
414 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
415 let tool = LoadSkill::new(skills);
416
417 let result = tokio::runtime::Runtime::new()
419 .unwrap()
420 .block_on(tool.execute(json!({"name": "RUST-TRAITS"})));
421
422 assert!(result.is_ok());
423 assert_eq!(result.unwrap(), "Body content");
424 }
425
426 #[test]
427 fn load_skill_with_ref_returns_ref_content() {
428 let tmp = tempfile::TempDir::new().unwrap();
429 let base = tmp.path();
430
431 let skill_dir = base.join("my-skill");
433 fs::create_dir(&skill_dir).unwrap();
434 fs::write(
435 skill_dir.join("SKILL.md"),
436 "---\nname: my-skill\ndescription: A skill with refs\n---\n\nBody",
437 )
438 .unwrap();
439
440 let refs_dir = skill_dir.join("refs");
441 fs::create_dir(&refs_dir).unwrap();
442 fs::write(refs_dir.join("api-spec.md"), "# API Spec\n\nDetails here.").unwrap();
443
444 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
445 let tool = LoadSkill::new(skills);
446
447 let result = tokio::runtime::Runtime::new()
448 .unwrap()
449 .block_on(tool.execute(json!({"name": "my-skill", "ref": "api-spec"})));
450
451 assert!(result.is_ok());
452 assert_eq!(result.unwrap(), "# API Spec\n\nDetails here.");
453 }
454
455 #[test]
456 fn load_skill_with_unknown_ref_returns_error_with_available() {
457 let tmp = tempfile::TempDir::new().unwrap();
458 let base = tmp.path();
459
460 let skill_dir = base.join("my-skill");
462 fs::create_dir(&skill_dir).unwrap();
463 fs::write(
464 skill_dir.join("SKILL.md"),
465 "---\nname: my-skill\ndescription: A skill with refs\n---\n\nBody",
466 )
467 .unwrap();
468
469 let refs_dir = skill_dir.join("refs");
470 fs::create_dir(&refs_dir).unwrap();
471 fs::write(refs_dir.join("api-spec.md"), "# API Spec").unwrap();
472 fs::write(refs_dir.join("examples.txt"), "Example 1").unwrap();
473
474 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
475 let tool = LoadSkill::new(skills);
476
477 let result = tokio::runtime::Runtime::new()
478 .unwrap()
479 .block_on(tool.execute(json!({"name": "my-skill", "ref": "nonexistent"})));
480
481 assert!(result.is_err());
482 let err = result.unwrap_err().to_string();
483 assert!(
484 err.contains("ref not found"),
485 "error should mention ref not found: {err}"
486 );
487 assert!(
488 err.contains("api-spec") && err.contains("examples"),
489 "error should list available refs: {err}"
490 );
491 }
492
493 #[test]
494 fn load_skill_with_ref_case_insensitive() {
495 let tmp = tempfile::TempDir::new().unwrap();
496 let base = tmp.path();
497
498 let skill_dir = base.join("my-skill");
499 fs::create_dir(&skill_dir).unwrap();
500 fs::write(
501 skill_dir.join("SKILL.md"),
502 "---\nname: my-skill\ndescription: test\n---\n\nBody",
503 )
504 .unwrap();
505
506 let refs_dir = skill_dir.join("refs");
507 fs::create_dir(&refs_dir).unwrap();
508 fs::write(refs_dir.join("API-Spec.md"), "Content").unwrap();
509
510 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
511 let tool = LoadSkill::new(skills);
512
513 let result = tokio::runtime::Runtime::new()
515 .unwrap()
516 .block_on(tool.execute(json!({"name": "my-skill", "ref": "api-spec"})));
517
518 assert!(result.is_ok());
519 assert_eq!(result.unwrap(), "Content");
520 }
521
522 #[test]
523 fn load_skill_with_empty_ref_returns_error() {
524 let tmp = tempfile::TempDir::new().unwrap();
525 let base = tmp.path();
526
527 let skill_dir = base.join("my-skill");
528 fs::create_dir(&skill_dir).unwrap();
529 fs::write(skill_dir.join("SKILL.md"), "Body").unwrap();
530
531 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
532 let tool = LoadSkill::new(skills);
533
534 let result = tokio::runtime::Runtime::new()
535 .unwrap()
536 .block_on(tool.execute(json!({"name": "my-skill", "ref": ""})));
537
538 assert!(result.is_err());
539 }
540
541 #[test]
542 fn load_skill_with_params_performs_substitution() {
543 let tmp = tempfile::TempDir::new().unwrap();
544 let base = tmp.path();
545
546 let skill_dir = base.join("code-review");
547 fs::create_dir(&skill_dir).unwrap();
548 fs::write(
549 skill_dir.join("SKILL.md"),
550 "---\n\
551 name: code-review\n\
552 description: Review code\n\
553 params:\n\
554 \x20\x20- name: language\n\
555 \x20\x20 description: Target language\n\
556 \x20\x20 default: rust\n\
557 ---\n\
558 \n\
559 Review {{language}} code.",
560 )
561 .unwrap();
562
563 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
564 let tool = LoadSkill::new(skills);
565
566 let result = tokio::runtime::Runtime::new().unwrap().block_on(
567 tool.execute(json!({"name": "code-review", "params": {"language": "python"}})),
568 );
569
570 assert!(result.is_ok());
571 assert_eq!(result.unwrap(), "Review python code.");
572 }
573
574 #[test]
575 fn load_skill_uses_defaults_when_params_not_provided() {
576 let tmp = tempfile::TempDir::new().unwrap();
577 let base = tmp.path();
578
579 let skill_dir = base.join("code-review");
580 fs::create_dir(&skill_dir).unwrap();
581 fs::write(
582 skill_dir.join("SKILL.md"),
583 "---\n\
584 name: code-review\n\
585 description: Review code\n\
586 params:\n\
587 \x20\x20- name: language\n\
588 \x20\x20 description: Target language\n\
589 \x20\x20 default: rust\n\
590 ---\n\
591 \n\
592 Review {{language}} code.",
593 )
594 .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": "code-review"})));
603
604 assert!(result.is_ok());
605 assert_eq!(result.unwrap(), "Review rust code.");
606 }
607
608 #[test]
609 fn load_skill_errors_on_missing_required_param() {
610 let tmp = tempfile::TempDir::new().unwrap();
611 let base = tmp.path();
612
613 let skill_dir = base.join("greeter");
614 fs::create_dir(&skill_dir).unwrap();
615 fs::write(
616 skill_dir.join("SKILL.md"),
617 "---\n\
618 name: greeter\n\
619 description: Greet someone\n\
620 params:\n\
621 \x20\x20- name: name\n\
622 \x20\x20 description: Name to greet\n\
623 ---\n\
624 \n\
625 Hello {{name}}!",
626 )
627 .unwrap();
628
629 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
630 let tool = LoadSkill::new(skills);
631
632 let result = tokio::runtime::Runtime::new()
634 .unwrap()
635 .block_on(tool.execute(json!({"name": "greeter"})));
636
637 assert!(result.is_err());
638 let err = result.unwrap_err().to_string();
639 assert!(
640 err.contains("missing required params"),
641 "error should mention missing required params: {err}"
642 );
643 assert!(
644 err.contains("name"),
645 "error should mention the missing param name: {err}"
646 );
647 }
648
649 #[test]
650 fn load_skill_with_params_substitutes_multiple() {
651 let tmp = tempfile::TempDir::new().unwrap();
652 let base = tmp.path();
653
654 let skill_dir = base.join("multi-param");
655 fs::create_dir(&skill_dir).unwrap();
656 fs::write(
657 skill_dir.join("SKILL.md"),
658 "---\n\
659 name: multi-param\n\
660 description: Multiple params\n\
661 params:\n\
662 \x20\x20- name: lang\n\
663 \x20\x20 description: Language\n\
664 \x20\x20 default: rust\n\
665 \x20\x20- name: mode\n\
666 \x20\x20 description: Mode\n\
667 \x20\x20 default: strict\n\
668 ---\n\
669 \n\
670 Lang={{lang}} Mode={{mode}}",
671 )
672 .unwrap();
673
674 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
675 let tool = LoadSkill::new(skills);
676
677 let result =
678 tokio::runtime::Runtime::new()
679 .unwrap()
680 .block_on(tool.execute(
681 json!({"name": "multi-param", "params": {"lang": "python", "mode": "lax"}}),
682 ));
683
684 assert!(result.is_ok());
685 assert_eq!(result.unwrap(), "Lang=python Mode=lax");
686 }
687
688 #[test]
691 fn load_skill_with_section_returns_section_content() {
692 let tmp = tempfile::TempDir::new().unwrap();
693 let base = tmp.path();
694
695 let skill_dir = base.join("sectioned-skill");
696 fs::create_dir(&skill_dir).unwrap();
697 fs::write(
698 skill_dir.join("SKILL.md"),
699 "---\nname: sectioned-skill\ndescription: Has sections\n---\n\n## Overview\n\nGeneral info.\n\n## Details\n\nSpecific content.",
700 )
701 .unwrap();
702
703 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
704 let tool = LoadSkill::new(skills);
705
706 let result = tokio::runtime::Runtime::new()
707 .unwrap()
708 .block_on(tool.execute(json!({"name": "sectioned-skill", "section": "Overview"})));
709
710 assert!(result.is_ok());
711 assert_eq!(result.unwrap(), "General info.");
712 }
713
714 #[test]
715 fn load_skill_with_section_case_insensitive() {
716 let tmp = tempfile::TempDir::new().unwrap();
717 let base = tmp.path();
718
719 let skill_dir = base.join("sectioned-skill");
720 fs::create_dir(&skill_dir).unwrap();
721 fs::write(
722 skill_dir.join("SKILL.md"),
723 "---\nname: sectioned-skill\ndescription: Has sections\n---\n\n## Overview\n\nGeneral info.",
724 )
725 .unwrap();
726
727 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
728 let tool = LoadSkill::new(skills);
729
730 let result = tokio::runtime::Runtime::new()
731 .unwrap()
732 .block_on(tool.execute(json!({"name": "sectioned-skill", "section": "overview"})));
733
734 assert!(result.is_ok());
735 assert_eq!(result.unwrap(), "General info.");
736 }
737
738 #[test]
739 fn load_skill_with_unknown_section_returns_error_with_available() {
740 let tmp = tempfile::TempDir::new().unwrap();
741 let base = tmp.path();
742
743 let skill_dir = base.join("sectioned-skill");
744 fs::create_dir(&skill_dir).unwrap();
745 fs::write(
746 skill_dir.join("SKILL.md"),
747 "---\nname: sectioned-skill\ndescription: Has sections\n---\n\n## Overview\n\nGeneral info.\n\n## Details\n\nSpecific content.",
748 )
749 .unwrap();
750
751 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
752 let tool = LoadSkill::new(skills);
753
754 let result = tokio::runtime::Runtime::new()
755 .unwrap()
756 .block_on(tool.execute(json!({"name": "sectioned-skill", "section": "Nonexistent"})));
757
758 assert!(result.is_err());
759 let err = result.unwrap_err().to_string();
760 assert!(
761 err.contains("section not found"),
762 "error should mention section not found: {err}"
763 );
764 assert!(
765 err.contains("Overview") && err.contains("Details"),
766 "error should list available sections: {err}"
767 );
768 }
769
770 #[test]
771 fn load_skill_with_empty_section_returns_error() {
772 let tmp = tempfile::TempDir::new().unwrap();
773 let base = tmp.path();
774
775 let skill_dir = base.join("sectioned-skill");
776 fs::create_dir(&skill_dir).unwrap();
777 fs::write(
778 skill_dir.join("SKILL.md"),
779 "---\nname: sectioned-skill\ndescription: Has sections\n---\n\n## Overview\n\nGeneral info.",
780 )
781 .unwrap();
782
783 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
784 let tool = LoadSkill::new(skills);
785
786 let result = tokio::runtime::Runtime::new()
787 .unwrap()
788 .block_on(tool.execute(json!({"name": "sectioned-skill", "section": ""})));
789
790 assert!(result.is_err());
791 }
792
793 #[test]
794 fn load_skill_with_section_and_params_performs_substitution() {
795 let tmp = tempfile::TempDir::new().unwrap();
796 let base = tmp.path();
797
798 let skill_dir = base.join("template-skill");
799 fs::create_dir(&skill_dir).unwrap();
800 fs::write(
801 skill_dir.join("SKILL.md"),
802 "---\n\
803 name: template-skill\n\
804 description: Has templates\n\
805 params:\n\
806 \x20\x20- name: lang\n\
807 \x20\x20 description: Language\n\
808 \x20\x20 default: rust\n\
809 ---\n\
810 \n\
811 ## Overview\n\
812 \n\
813 Review {{lang}} code.\n\
814 \n\
815 ## Details\n\
816 \n\
817 More about {{lang}}.",
818 )
819 .unwrap();
820
821 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
822 let tool = LoadSkill::new(skills);
823
824 let result = tokio::runtime::Runtime::new()
825 .unwrap()
826 .block_on(tool.execute(
827 json!({"name": "template-skill", "section": "Overview", "params": {"lang": "python"}}),
828 ));
829
830 assert!(result.is_ok());
831 assert_eq!(result.unwrap(), "Review python code.");
832 }
833
834 fn create_skill(base: &std::path::Path, name: &str, depends_on: &[&str], body: &str) {
838 let dir = base.join(name);
839 fs::create_dir(&dir).unwrap();
840 let mut frontmatter = format!("---\nname: {name}\ndescription: {name} skill\n");
841 if !depends_on.is_empty() {
842 frontmatter.push_str(&format!("depends_on: {}\n", depends_on.join(", ")));
843 }
844 frontmatter.push_str("---\n\n");
845 fs::write(dir.join("SKILL.md"), format!("{frontmatter}{body}")).unwrap();
846 }
847
848 #[test]
849 fn load_skill_resolves_single_dependency() {
850 let tmp = tempfile::TempDir::new().unwrap();
851 let base = tmp.path();
852
853 create_skill(base, "skill-b", &[], "Body of B");
855 create_skill(base, "skill-a", &["skill-b"], "Body of A");
857
858 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
859 let tool = LoadSkill::new(skills);
860
861 let result = tokio::runtime::Runtime::new()
862 .unwrap()
863 .block_on(tool.execute(json!({"name": "skill-a"})));
864
865 assert!(result.is_ok());
866 let output = result.unwrap();
867 assert_eq!(
868 output,
869 "=== Dependency: skill-b ===\nBody of B\n\n=== Skill: skill-a ===\nBody of A"
870 );
871 }
872
873 #[test]
874 fn load_skill_resolves_multi_level_dependencies() {
875 let tmp = tempfile::TempDir::new().unwrap();
876 let base = tmp.path();
877
878 create_skill(base, "skill-c", &[], "Body of C");
880 create_skill(base, "skill-b", &["skill-c"], "Body of B");
882 create_skill(base, "skill-a", &["skill-b"], "Body of A");
884
885 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
886 let tool = LoadSkill::new(skills);
887
888 let result = tokio::runtime::Runtime::new()
889 .unwrap()
890 .block_on(tool.execute(json!({"name": "skill-a"})));
891
892 assert!(result.is_ok());
893 let output = result.unwrap();
894 assert_eq!(
895 output,
896 "=== Dependency: skill-c ===\nBody of C\n\n=== Dependency: skill-b ===\nBody of B\n\n=== Skill: skill-a ===\nBody of A"
897 );
898 }
899
900 #[test]
901 fn load_skill_detects_circular_dependency() {
902 let tmp = tempfile::TempDir::new().unwrap();
903 let base = tmp.path();
904
905 create_skill(base, "skill-a", &["skill-b"], "Body of A");
907 create_skill(base, "skill-b", &["skill-a"], "Body of B");
909
910 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
911 let tool = LoadSkill::new(skills);
912
913 let result = tokio::runtime::Runtime::new()
914 .unwrap()
915 .block_on(tool.execute(json!({"name": "skill-a"})));
916
917 assert!(result.is_ok());
918 let output = result.unwrap();
919 assert!(
921 output.contains("circular dependency detected"),
922 "output should mention circular dependency: {output}"
923 );
924 assert!(output.contains("=== Skill: skill-a ==="));
926 assert!(output.contains("Body of A"));
927 }
928
929 #[test]
930 fn load_skill_respects_depth_limit() {
931 let tmp = tempfile::TempDir::new().unwrap();
932 let base = tmp.path();
933
934 create_skill(base, "skill-e", &[], "Body of E");
936 create_skill(base, "skill-d", &["skill-e"], "Body of D");
937 create_skill(base, "skill-c", &["skill-d"], "Body of C");
938 create_skill(base, "skill-b", &["skill-c"], "Body of B");
939 create_skill(base, "skill-a", &["skill-b"], "Body of A");
940
941 let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
942 let tool = LoadSkill::new(skills);
943
944 let result = tokio::runtime::Runtime::new()
945 .unwrap()
946 .block_on(tool.execute(json!({"name": "skill-a"})));
947
948 assert!(result.is_err());
949 let err = result.unwrap_err().to_string();
950 assert!(
951 err.contains("dependency tree too deep"),
952 "error should mention depth limit: {err}"
953 );
954 }
955
956 #[test]
957 fn parse_skill_meta_single_depends_on() {
958 let tmp = tempfile::TempDir::new().unwrap();
959 let dir = tmp.path().join("test-skill");
960 fs::create_dir(&dir).unwrap();
961 let content =
962 "---\nname: test-skill\ndescription: A test\ndepends_on: base-skill\n---\n\nBody text";
963 let (_, _, _, _, _, depends_on, _) = crate::skills::parse_skill_meta(content, &dir);
964 assert_eq!(depends_on, vec!["base-skill"]);
965 }
966
967 #[test]
968 fn parse_skill_meta_multiple_depends_on() {
969 let tmp = tempfile::TempDir::new().unwrap();
970 let dir = tmp.path().join("test-skill");
971 fs::create_dir(&dir).unwrap();
972 let content =
973 "---\nname: test-skill\ndescription: A test\ndepends_on: base-skill, utils, logging\n---\n\nBody text";
974 let (_, _, _, _, _, depends_on, _) = crate::skills::parse_skill_meta(content, &dir);
975 assert_eq!(depends_on, vec!["base-skill", "utils", "logging"]);
976 }
977
978 #[test]
979 fn parse_skill_meta_no_depends_on() {
980 let tmp = tempfile::TempDir::new().unwrap();
981 let dir = tmp.path().join("test-skill");
982 fs::create_dir(&dir).unwrap();
983 let content = "---\nname: test-skill\ndescription: A test\n---\n\nBody text";
984 let (_, _, _, _, _, depends_on, _) = crate::skills::parse_skill_meta(content, &dir);
985 assert!(depends_on.is_empty());
986 }
987
988 #[test]
989 fn skill_index_shows_depends_on() {
990 let skills = vec![
991 Skill {
992 name: "with-deps".to_string(),
993 description: "Has dependencies".to_string(),
994 path: PathBuf::from("/tmp/skills/with-deps/SKILL.md"),
995 mode: SkillMode::Manual,
996 triggers: vec![],
997 hint: String::new(),
998 depends_on: vec!["base".to_string(), "utils".to_string()],
999 refs: vec![],
1000 params: vec![],
1001 scripts: vec![],
1002 sections: vec![],
1003 },
1004 Skill {
1005 name: "no-deps".to_string(),
1006 description: "No dependencies".to_string(),
1007 path: PathBuf::from("/tmp/skills/no-deps/SKILL.md"),
1008 mode: SkillMode::Manual,
1009 triggers: vec![],
1010 hint: String::new(),
1011 depends_on: vec![],
1012 refs: vec![],
1013 params: vec![],
1014 scripts: vec![],
1015 sections: vec![],
1016 },
1017 ];
1018
1019 let result = crate::skills::skill_index(&skills);
1020 assert!(
1021 result.contains("depends_on: base, utils"),
1022 "should show depends_on: {result}"
1023 );
1024 assert!(
1025 result.contains("- no-deps: No dependencies"),
1026 "should show skill without deps normally"
1027 );
1028 }
1029}