1use std::collections::HashMap;
40use std::path::{Path, PathBuf};
41
42use serde::{Deserialize, Serialize};
43
44use crate::types::SlotConfig;
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum SkillSource {
50 Builtin,
52 Global,
54 Project,
56 Runtime,
58}
59
60impl std::fmt::Display for SkillSource {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 match self {
63 Self::Builtin => write!(f, "builtin"),
64 Self::Global => write!(f, "global"),
65 Self::Project => write!(f, "project"),
66 Self::Runtime => write!(f, "runtime"),
67 }
68 }
69}
70
71#[derive(Debug, Clone)]
73pub struct RegisteredSkill {
74 pub skill: Skill,
76 pub source: SkillSource,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
85#[serde(rename_all = "snake_case")]
86pub enum SkillScope {
87 #[default]
89 Task,
90
91 Coordinator,
94
95 Chain,
98}
99
100impl std::fmt::Display for SkillScope {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 match self {
103 Self::Task => write!(f, "task"),
104 Self::Coordinator => write!(f, "coordinator"),
105 Self::Chain => write!(f, "chain"),
106 }
107 }
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct Skill {
113 pub name: String,
115
116 pub description: String,
118
119 pub prompt: String,
121
122 pub arguments: Vec<SkillArgument>,
124
125 pub config: Option<SlotConfig>,
127
128 #[serde(default)]
130 pub scope: SkillScope,
131
132 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub argument_hint: Option<String>,
138
139 #[serde(skip)]
146 pub skill_dir: Option<PathBuf>,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct SkillArgument {
152 pub name: String,
154
155 pub description: String,
157
158 pub required: bool,
160}
161
162#[derive(Debug, Deserialize)]
168struct SkillFrontmatter {
169 name: String,
170 description: String,
171 #[serde(default, rename = "allowed-tools")]
174 allowed_tools: Option<String>,
175 #[serde(default, rename = "argument-hint")]
177 argument_hint: Option<String>,
178 #[serde(default)]
179 metadata: SkillMetadata,
180}
181
182#[derive(Debug, Default, Deserialize)]
184struct SkillMetadata {
185 #[serde(default)]
186 scope: Option<SkillScope>,
187 #[serde(default)]
188 arguments: Vec<SkillArgument>,
189 #[serde(default)]
190 config: Option<SlotConfig>,
191}
192
193fn parse_skill_md(content: &str) -> crate::Result<Skill> {
198 let content = content.trim();
199 if !content.starts_with("---") {
200 return Err(crate::Error::Store(
201 "SKILL.md must start with YAML frontmatter (---)".into(),
202 ));
203 }
204
205 let after_first = &content[3..];
206 let end = after_first.find("---").ok_or_else(|| {
207 crate::Error::Store("SKILL.md missing closing frontmatter delimiter (---)".into())
208 })?;
209
210 let yaml = &after_first[..end];
211 let body = after_first[end + 3..].trim();
212
213 let fm: SkillFrontmatter = serde_yaml::from_str(yaml)
214 .map_err(|e| crate::Error::Store(format!("SKILL.md YAML parse error: {e}")))?;
215
216 let scope = fm.metadata.scope.unwrap_or_else(|| infer_scope(&fm.name));
218
219 let config = if let Some(ref tools_str) = fm.allowed_tools {
221 let tools: Vec<String> = tools_str
222 .split(',')
223 .map(|s| s.trim().to_string())
224 .filter(|s| !s.is_empty())
225 .collect();
226 let mut config = fm.metadata.config.unwrap_or_default();
227 config.allowed_tools = Some(tools);
228 Some(config)
229 } else {
230 fm.metadata.config
231 };
232
233 Ok(Skill {
234 name: fm.name,
235 description: fm.description,
236 prompt: body.to_string(),
237 arguments: fm.metadata.arguments,
238 config,
239 scope,
240 argument_hint: fm.argument_hint,
241 skill_dir: None,
242 })
243}
244
245fn infer_scope(name: &str) -> SkillScope {
247 if name.starts_with("cps-coordinator") {
248 SkillScope::Coordinator
249 } else if name.starts_with("cps-chain") {
250 SkillScope::Chain
251 } else {
252 SkillScope::Task
253 }
254}
255
256impl Skill {
257 pub fn render(&self, args: &HashMap<String, String>) -> crate::Result<String> {
262 for arg in &self.arguments {
264 if arg.required && !args.contains_key(&arg.name) {
265 return Err(crate::Error::Store(format!(
266 "missing required argument '{}' for skill '{}'",
267 arg.name, self.name
268 )));
269 }
270 }
271
272 let mut rendered = self.prompt.clone();
273
274 for (key, value) in args {
276 rendered = rendered.replace(&format!("{{{key}}}"), value);
277 }
278
279 let positional: Vec<&str> = self
282 .arguments
283 .iter()
284 .filter_map(|a| args.get(&a.name).map(|v| v.as_str()))
285 .collect();
286
287 let all_args = positional.join(" ");
288 let has_arguments_var = rendered.contains("$ARGUMENTS") || rendered.contains("$0");
289
290 for (i, val) in positional.iter().enumerate() {
292 rendered = rendered.replace(&format!("$ARGUMENTS[{i}]"), val);
293 rendered = rendered.replace(&format!("${i}"), val);
294 }
295
296 rendered = rendered.replace("$ARGUMENTS", &all_args);
298
299 let had_legacy_placeholders = self
302 .arguments
303 .iter()
304 .any(|a| self.prompt.contains(&format!("{{{}}}", a.name)));
305 if !has_arguments_var && !had_legacy_placeholders && !all_args.is_empty() {
306 rendered.push_str(&format!("\n\nARGUMENTS: {all_args}"));
307 }
308
309 if let Some(ref dir) = self.skill_dir {
311 rendered = rendered.replace("${CLAUDE_SKILL_DIR}", &dir.display().to_string());
312 } else if rendered.contains("${CLAUDE_SKILL_DIR}") {
313 rendered = rendered.replace(
314 "${CLAUDE_SKILL_DIR}",
315 "[CLAUDE_SKILL_DIR unavailable: skill has no directory]",
316 );
317 }
318
319 rendered = execute_command_injections(&rendered);
321
322 Ok(rendered)
323 }
324}
325
326fn execute_command_injections(input: &str) -> String {
332 use std::process::Command;
333
334 let mut result = String::with_capacity(input.len());
335 let mut remaining = input;
336
337 while let Some(start) = remaining.find("!`") {
338 result.push_str(&remaining[..start]);
339 let after_marker = &remaining[start + 2..];
340 if let Some(end) = after_marker.find('`') {
341 let cmd = &after_marker[..end];
342 let output = Command::new("sh")
343 .arg("-c")
344 .arg(cmd)
345 .output()
346 .map(|o| {
347 if o.status.success() {
348 String::from_utf8_lossy(&o.stdout).trim().to_string()
349 } else {
350 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
351 format!("[command failed: {stderr}]")
352 }
353 })
354 .unwrap_or_else(|e| format!("[command error: {e}]"));
355 result.push_str(&output);
356 remaining = &after_marker[end + 1..];
357 } else {
358 result.push_str("!`");
360 remaining = after_marker;
361 }
362 }
363 result.push_str(remaining);
364 result
365}
366
367#[derive(Debug, Clone, Default)]
369pub struct SkillRegistry {
370 skills: HashMap<String, RegisteredSkill>,
371}
372
373impl SkillRegistry {
374 pub fn new() -> Self {
376 Self::default()
377 }
378
379 pub fn with_builtins() -> Self {
381 let mut registry = Self::new();
382 for skill in builtin_skills() {
383 registry.register(skill, SkillSource::Builtin);
384 }
385 registry
386 }
387
388 pub fn register(&mut self, skill: Skill, source: SkillSource) {
390 self.skills
391 .insert(skill.name.clone(), RegisteredSkill { skill, source });
392 }
393
394 pub fn get(&self, name: &str) -> Option<&Skill> {
396 self.skills.get(name).map(|rs| &rs.skill)
397 }
398
399 pub fn get_registered(&self, name: &str) -> Option<&RegisteredSkill> {
401 self.skills.get(name)
402 }
403
404 pub fn list(&self) -> Vec<&Skill> {
406 self.skills.values().map(|rs| &rs.skill).collect()
407 }
408
409 pub fn list_registered(&self) -> Vec<&RegisteredSkill> {
411 self.skills.values().collect()
412 }
413
414 pub fn remove(&mut self, name: &str) -> Option<Skill> {
416 self.skills.remove(name).map(|rs| rs.skill)
417 }
418
419 pub fn remove_many(&mut self, names: &[&str]) {
421 for name in names {
422 self.skills.remove(*name);
423 }
424 }
425
426 pub fn list_by_scope(&self, scope: SkillScope) -> Vec<&Skill> {
428 self.skills
429 .values()
430 .filter(|rs| rs.skill.scope == scope)
431 .map(|rs| &rs.skill)
432 .collect()
433 }
434
435 pub fn load_from_dir(&mut self, dir: &Path) -> crate::Result<usize> {
445 self.load_from_dir_with_source(dir, SkillSource::Project)
446 }
447
448 pub fn load_from_dir_with_source(
450 &mut self,
451 dir: &Path,
452 source: SkillSource,
453 ) -> crate::Result<usize> {
454 if !dir.is_dir() {
455 return Ok(0);
456 }
457
458 let mut entries: Vec<_> = std::fs::read_dir(dir)?.filter_map(|e| e.ok()).collect();
459 entries.sort_by_key(|e| e.file_name());
460
461 let mut count = 0;
462 for entry in entries {
463 let path = entry.path();
464
465 if path.is_dir() {
467 let skill_md = path.join("SKILL.md");
468 if skill_md.is_file() {
469 let contents = std::fs::read_to_string(&skill_md)?;
470 let mut skill = parse_skill_md(&contents)?;
471 skill.skill_dir = Some(path.clone());
472 self.register(skill, source);
473 count += 1;
474 }
475 continue;
476 }
477
478 if path.extension().is_some_and(|ext| ext == "json") {
480 tracing::warn!(
481 path = %path.display(),
482 "loading skill from JSON format (deprecated — migrate to SKILL.md folder)"
483 );
484 let contents = std::fs::read_to_string(&path)?;
485 let skill: Skill = serde_json::from_str(&contents)?;
486 self.register(skill, source);
487 count += 1;
488 }
489 }
490
491 Ok(count)
492 }
493}
494
495pub fn builtin_skills() -> Vec<Skill> {
501 const SKILL_SOURCES: &[&str] = &[
502 include_str!("../skills/code_review/SKILL.md"),
503 include_str!("../skills/implement/SKILL.md"),
504 include_str!("../skills/write_tests/SKILL.md"),
505 include_str!("../skills/refactor/SKILL.md"),
506 include_str!("../skills/summarize/SKILL.md"),
507 include_str!("../skills/pre_push/SKILL.md"),
508 include_str!("../skills/create_pr/SKILL.md"),
509 include_str!("../skills/issue_watcher/SKILL.md"),
510 include_str!("../skills/loop_monitor/SKILL.md"),
511 include_str!("../skills/pool_dashboard/SKILL.md"),
512 include_str!("../skills/chain_watcher/SKILL.md"),
513 include_str!("../skills/plan_then_execute/SKILL.md"),
514 include_str!("../skills/rebase_onto_main/SKILL.md"),
515 include_str!("../skills/chain_implement_issue/SKILL.md"),
516 include_str!("../skills/issue_triage/SKILL.md"),
517 ];
518
519 SKILL_SOURCES
520 .iter()
521 .map(|src| parse_skill_md(src).expect("builtin SKILL.md should be valid"))
522 .collect()
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528
529 #[test]
530 fn render_skill_template() {
531 let skill = Skill {
532 name: "greet".into(),
533 description: "Greet someone".into(),
534 prompt: "Hello, {name}! Welcome to {place}.".into(),
535 arguments: vec![
536 SkillArgument {
537 name: "name".into(),
538 description: "Name".into(),
539 required: true,
540 },
541 SkillArgument {
542 name: "place".into(),
543 description: "Place".into(),
544 required: false,
545 },
546 ],
547 config: None,
548 scope: SkillScope::Task,
549 argument_hint: None,
550 skill_dir: None,
551 };
552
553 let mut args = HashMap::new();
554 args.insert("name".into(), "Alice".into());
555 args.insert("place".into(), "the pool".into());
556
557 let rendered = skill.render(&args).unwrap();
558 assert_eq!(rendered, "Hello, Alice! Welcome to the pool.");
559 }
560
561 #[test]
562 fn missing_required_argument() {
563 let skill = Skill {
564 name: "test".into(),
565 description: "Test".into(),
566 prompt: "{x}".into(),
567 arguments: vec![SkillArgument {
568 name: "x".into(),
569 description: "X".into(),
570 required: true,
571 }],
572 config: None,
573 scope: SkillScope::Task,
574 argument_hint: None,
575 skill_dir: None,
576 };
577
578 let result = skill.render(&HashMap::new());
579 assert!(result.is_err());
580 }
581
582 #[test]
583 fn render_dollar_arguments_all() {
584 let skill = Skill {
585 name: "fix".into(),
586 description: "Fix issue".into(),
587 prompt: "Fix issue $ARGUMENTS following conventions.".into(),
588 arguments: vec![SkillArgument {
589 name: "issue".into(),
590 description: "Issue number".into(),
591 required: true,
592 }],
593 config: None,
594 scope: SkillScope::Task,
595 argument_hint: None,
596 skill_dir: None,
597 };
598
599 let mut args = HashMap::new();
600 args.insert("issue".into(), "123".into());
601 let rendered = skill.render(&args).unwrap();
602 assert_eq!(rendered, "Fix issue 123 following conventions.");
603 }
604
605 #[test]
606 fn render_dollar_positional() {
607 let skill = Skill {
608 name: "migrate".into(),
609 description: "Migrate component".into(),
610 prompt: "Migrate $0 from $1 to $2.".into(),
611 arguments: vec![
612 SkillArgument {
613 name: "component".into(),
614 description: "Component name".into(),
615 required: true,
616 },
617 SkillArgument {
618 name: "from".into(),
619 description: "Source framework".into(),
620 required: true,
621 },
622 SkillArgument {
623 name: "to".into(),
624 description: "Target framework".into(),
625 required: true,
626 },
627 ],
628 config: None,
629 scope: SkillScope::Task,
630 argument_hint: None,
631 skill_dir: None,
632 };
633
634 let mut args = HashMap::new();
635 args.insert("component".into(), "SearchBar".into());
636 args.insert("from".into(), "React".into());
637 args.insert("to".into(), "Vue".into());
638 let rendered = skill.render(&args).unwrap();
639 assert_eq!(rendered, "Migrate SearchBar from React to Vue.");
640 }
641
642 #[test]
643 fn render_dollar_arguments_n_bracket() {
644 let skill = Skill {
645 name: "test".into(),
646 description: "Test".into(),
647 prompt: "Process $ARGUMENTS[0] then $ARGUMENTS[1].".into(),
648 arguments: vec![
649 SkillArgument {
650 name: "a".into(),
651 description: "A".into(),
652 required: true,
653 },
654 SkillArgument {
655 name: "b".into(),
656 description: "B".into(),
657 required: true,
658 },
659 ],
660 config: None,
661 scope: SkillScope::Task,
662 argument_hint: None,
663 skill_dir: None,
664 };
665
666 let mut args = HashMap::new();
667 args.insert("a".into(), "foo".into());
668 args.insert("b".into(), "bar".into());
669 let rendered = skill.render(&args).unwrap();
670 assert_eq!(rendered, "Process foo then bar.");
671 }
672
673 #[test]
674 fn render_no_placeholder_appends_arguments() {
675 let skill = Skill {
676 name: "test".into(),
677 description: "Test".into(),
678 prompt: "Do the thing.".into(),
679 arguments: vec![SkillArgument {
680 name: "target".into(),
681 description: "Target".into(),
682 required: true,
683 }],
684 config: None,
685 scope: SkillScope::Task,
686 argument_hint: None,
687 skill_dir: None,
688 };
689
690 let mut args = HashMap::new();
691 args.insert("target".into(), "src/main.rs".into());
692 let rendered = skill.render(&args).unwrap();
693 assert_eq!(rendered, "Do the thing.\n\nARGUMENTS: src/main.rs");
694 }
695
696 #[test]
697 fn render_legacy_placeholder_no_append() {
698 let skill = Skill {
699 name: "test".into(),
700 description: "Test".into(),
701 prompt: "Review {target} carefully.".into(),
702 arguments: vec![SkillArgument {
703 name: "target".into(),
704 description: "Target".into(),
705 required: true,
706 }],
707 config: None,
708 scope: SkillScope::Task,
709 argument_hint: None,
710 skill_dir: None,
711 };
712
713 let mut args = HashMap::new();
714 args.insert("target".into(), "src/main.rs".into());
715 let rendered = skill.render(&args).unwrap();
716 assert_eq!(rendered, "Review src/main.rs carefully.");
718 }
719
720 #[test]
721 fn registry_crud() {
722 let mut registry = SkillRegistry::new();
723 assert!(registry.list().is_empty());
724
725 registry.register(
726 Skill {
727 name: "test".into(),
728 description: "A test skill".into(),
729 prompt: "do {thing}".into(),
730 arguments: vec![],
731 config: None,
732 scope: SkillScope::Task,
733 argument_hint: None,
734 skill_dir: None,
735 },
736 SkillSource::Runtime,
737 );
738
739 assert_eq!(registry.list().len(), 1);
740 assert!(registry.get("test").is_some());
741 assert!(registry.get("nope").is_none());
742
743 registry.remove("test");
744 assert!(registry.list().is_empty());
745 }
746
747 #[test]
748 fn load_from_nonexistent_dir() {
749 let mut registry = SkillRegistry::new();
750 let count = registry
751 .load_from_dir(Path::new("/tmp/does-not-exist-claude-pool-test"))
752 .unwrap();
753 assert_eq!(count, 0);
754 }
755
756 #[test]
757 fn load_from_dir_with_json_files() {
758 let dir = tempfile::tempdir().unwrap();
759
760 let skill_json = serde_json::json!({
761 "name": "my_skill",
762 "description": "A test skill",
763 "prompt": "Do {thing}",
764 "arguments": [
765 { "name": "thing", "description": "What to do", "required": true }
766 ],
767 "config": null
768 });
769 std::fs::write(
770 dir.path().join("my_skill.json"),
771 serde_json::to_string_pretty(&skill_json).unwrap(),
772 )
773 .unwrap();
774
775 std::fs::write(dir.path().join("readme.txt"), "not a skill").unwrap();
777
778 let mut registry = SkillRegistry::new();
779 let count = registry.load_from_dir(dir.path()).unwrap();
780 assert_eq!(count, 1);
781
782 let skill = registry.get("my_skill").unwrap();
783 assert_eq!(skill.description, "A test skill");
784 assert_eq!(skill.arguments.len(), 1);
785 assert!(skill.arguments[0].required);
786 }
787
788 #[test]
789 fn project_skills_override_builtins() {
790 let dir = tempfile::tempdir().unwrap();
791
792 let override_json = serde_json::json!({
793 "name": "code_review",
794 "description": "Custom project review",
795 "prompt": "Review with custom rules: {target}",
796 "arguments": [
797 { "name": "target", "description": "What to review", "required": true }
798 ],
799 "config": null
800 });
801 std::fs::write(
802 dir.path().join("code_review.json"),
803 serde_json::to_string_pretty(&override_json).unwrap(),
804 )
805 .unwrap();
806
807 let mut registry = SkillRegistry::with_builtins();
808 assert_eq!(
809 registry.get("code_review").unwrap().description,
810 "Review code for bugs, style issues, and improvements."
811 );
812
813 let count = registry.load_from_dir(dir.path()).unwrap();
814 assert_eq!(count, 1);
815 assert_eq!(
816 registry.get("code_review").unwrap().description,
817 "Custom project review"
818 );
819 }
820
821 #[test]
822 fn builtins_load() {
823 let registry = SkillRegistry::with_builtins();
824 assert_eq!(registry.list().len(), 15);
826 assert!(registry.get("code_review").is_some());
828 assert!(registry.get("implement").is_some());
829 assert!(registry.get("write_tests").is_some());
830 assert!(registry.get("refactor").is_some());
831 assert!(registry.get("summarize").is_some());
832 assert!(registry.get("pre_push").is_some());
833 assert!(registry.get("create_pr").is_some());
834 assert!(registry.get("rebase_onto_main").is_some());
835 assert!(registry.get("issue_watcher").is_some());
837 assert!(registry.get("loop_monitor").is_some());
838 assert!(registry.get("pool_dashboard").is_some());
839 assert!(registry.get("chain_watcher").is_some());
840 assert!(registry.get("issue_triage").is_some());
841 assert!(registry.get("chain_implement_issue").is_some());
843 }
844
845 #[test]
846 fn list_by_scope() {
847 let registry = SkillRegistry::with_builtins();
848 let tasks = registry.list_by_scope(SkillScope::Task);
849 let coordinators = registry.list_by_scope(SkillScope::Coordinator);
850 let chains = registry.list_by_scope(SkillScope::Chain);
851
852 assert_eq!(tasks.len(), 8);
853 assert_eq!(coordinators.len(), 5);
854 assert_eq!(chains.len(), 2);
855 }
856
857 #[test]
858 fn remove_many_skills() {
859 let mut registry = SkillRegistry::with_builtins();
860 let before = registry.list().len();
861 registry.remove_many(&["create_pr", "issue_watcher"]);
862 assert_eq!(registry.list().len(), before - 2);
863 assert!(registry.get("create_pr").is_none());
864 assert!(registry.get("issue_watcher").is_none());
865 }
866
867 #[test]
868 fn scope_default_is_task() {
869 assert_eq!(SkillScope::default(), SkillScope::Task);
870 }
871
872 #[test]
873 fn scope_serde_roundtrip() {
874 let json = serde_json::json!("coordinator");
875 let scope: SkillScope = serde_json::from_value(json).unwrap();
876 assert_eq!(scope, SkillScope::Coordinator);
877
878 let serialized = serde_json::to_value(scope).unwrap();
879 assert_eq!(serialized, "coordinator");
880 }
881
882 #[test]
883 fn source_tracking() {
884 let registry = SkillRegistry::with_builtins();
885 let rs = registry.get_registered("code_review").unwrap();
886 assert_eq!(rs.source, SkillSource::Builtin);
887 }
888
889 #[test]
890 fn list_registered_includes_source() {
891 let mut registry = SkillRegistry::new();
892 registry.register(
893 Skill {
894 name: "a".into(),
895 description: "A".into(),
896 prompt: "do a".into(),
897 arguments: vec![],
898 config: None,
899 scope: SkillScope::Task,
900 argument_hint: None,
901 skill_dir: None,
902 },
903 SkillSource::Builtin,
904 );
905 registry.register(
906 Skill {
907 name: "b".into(),
908 description: "B".into(),
909 prompt: "do b".into(),
910 arguments: vec![],
911 config: None,
912 scope: SkillScope::Task,
913 argument_hint: None,
914 skill_dir: None,
915 },
916 SkillSource::Runtime,
917 );
918
919 let all = registry.list_registered();
920 assert_eq!(all.len(), 2);
921
922 let builtin = registry.get_registered("a").unwrap();
923 assert_eq!(builtin.source, SkillSource::Builtin);
924
925 let runtime = registry.get_registered("b").unwrap();
926 assert_eq!(runtime.source, SkillSource::Runtime);
927 }
928
929 #[test]
930 fn project_skills_have_project_source() {
931 let dir = tempfile::tempdir().unwrap();
932 let skill_json = serde_json::json!({
933 "name": "proj_skill",
934 "description": "Project skill",
935 "prompt": "do {thing}",
936 "arguments": [
937 { "name": "thing", "description": "What", "required": true }
938 ]
939 });
940 std::fs::write(
941 dir.path().join("proj_skill.json"),
942 serde_json::to_string_pretty(&skill_json).unwrap(),
943 )
944 .unwrap();
945
946 let mut registry = SkillRegistry::new();
947 registry.load_from_dir(dir.path()).unwrap();
948
949 let rs = registry.get_registered("proj_skill").unwrap();
950 assert_eq!(rs.source, SkillSource::Project);
951 }
952
953 #[test]
954 fn source_serde_roundtrip() {
955 let json = serde_json::json!("runtime");
956 let source: SkillSource = serde_json::from_value(json).unwrap();
957 assert_eq!(source, SkillSource::Runtime);
958
959 let serialized = serde_json::to_value(source).unwrap();
960 assert_eq!(serialized, "runtime");
961 }
962
963 #[test]
964 fn source_display() {
965 assert_eq!(SkillSource::Builtin.to_string(), "builtin");
966 assert_eq!(SkillSource::Global.to_string(), "global");
967 assert_eq!(SkillSource::Project.to_string(), "project");
968 assert_eq!(SkillSource::Runtime.to_string(), "runtime");
969 }
970
971 #[test]
972 fn source_global_serde_roundtrip() {
973 let json = serde_json::json!("global");
974 let source: SkillSource = serde_json::from_value(json).unwrap();
975 assert_eq!(source, SkillSource::Global);
976
977 let serialized = serde_json::to_value(source).unwrap();
978 assert_eq!(serialized, "global");
979 }
980
981 #[test]
982 fn parse_skill_md_basic() {
983 let content = "\
984---
985name: test-skill
986description: A test skill for parsing.
987metadata:
988 arguments:
989 - name: target
990 description: What to test
991 required: true
992---
993
994Run tests on {target}.
995
996Report results.
997";
998
999 let skill = parse_skill_md(content).unwrap();
1000 assert_eq!(skill.name, "test-skill");
1001 assert_eq!(skill.description, "A test skill for parsing.");
1002 assert_eq!(skill.prompt, "Run tests on {target}.\n\nReport results.");
1003 assert_eq!(skill.arguments.len(), 1);
1004 assert_eq!(skill.arguments[0].name, "target");
1005 assert!(skill.arguments[0].required);
1006 assert_eq!(skill.scope, SkillScope::Task);
1007 }
1008
1009 #[test]
1010 fn parse_skill_md_with_scope() {
1011 let content = "\
1012---
1013name: cps-coordinator-watcher
1014description: Watches things.
1015metadata:
1016 scope: coordinator
1017---
1018
1019Watch stuff.
1020";
1021 let skill = parse_skill_md(content).unwrap();
1022 assert_eq!(skill.scope, SkillScope::Coordinator);
1023 }
1024
1025 #[test]
1026 fn parse_skill_md_infers_scope_from_prefix() {
1027 let content = "\
1028---
1029name: cps-chain-deploy
1030description: Deploy chain.
1031---
1032
1033Deploy stuff.
1034";
1035 let skill = parse_skill_md(content).unwrap();
1036 assert_eq!(skill.scope, SkillScope::Chain);
1037 }
1038
1039 #[test]
1040 fn parse_skill_md_no_metadata() {
1041 let content = "\
1042---
1043name: simple
1044description: Simple skill.
1045---
1046
1047Just do it.
1048";
1049 let skill = parse_skill_md(content).unwrap();
1050 assert_eq!(skill.name, "simple");
1051 assert!(skill.arguments.is_empty());
1052 assert_eq!(skill.scope, SkillScope::Task);
1053 }
1054
1055 #[test]
1056 fn parse_skill_md_missing_frontmatter() {
1057 let result = parse_skill_md("no frontmatter here");
1058 assert!(result.is_err());
1059 }
1060
1061 #[test]
1062 fn parse_skill_md_missing_closing_delimiter() {
1063 let result = parse_skill_md("---\nname: broken\n");
1064 assert!(result.is_err());
1065 }
1066
1067 #[test]
1068 fn load_from_dir_with_skill_md_folders() {
1069 let dir = tempfile::tempdir().unwrap();
1070
1071 let skill_dir = dir.path().join("my-skill");
1073 std::fs::create_dir(&skill_dir).unwrap();
1074 std::fs::write(
1075 skill_dir.join("SKILL.md"),
1076 "\
1077---
1078name: my-skill
1079description: A folder-based skill.
1080metadata:
1081 arguments:
1082 - name: input
1083 description: The input
1084 required: true
1085---
1086
1087Process {input}.
1088",
1089 )
1090 .unwrap();
1091
1092 let mut registry = SkillRegistry::new();
1093 let count = registry.load_from_dir(dir.path()).unwrap();
1094 assert_eq!(count, 1);
1095
1096 let skill = registry.get("my-skill").unwrap();
1097 assert_eq!(skill.description, "A folder-based skill.");
1098 assert_eq!(skill.prompt, "Process {input}.");
1099 assert_eq!(skill.arguments.len(), 1);
1100 }
1101
1102 #[test]
1103 fn load_from_dir_mixed_formats() {
1104 let dir = tempfile::tempdir().unwrap();
1105
1106 let skill_dir = dir.path().join("new-skill");
1108 std::fs::create_dir(&skill_dir).unwrap();
1109 std::fs::write(
1110 skill_dir.join("SKILL.md"),
1111 "---\nname: new-skill\ndescription: New format.\n---\n\nNew prompt.\n",
1112 )
1113 .unwrap();
1114
1115 let skill_json = serde_json::json!({
1117 "name": "old_skill",
1118 "description": "Legacy format",
1119 "prompt": "Old prompt",
1120 "arguments": []
1121 });
1122 std::fs::write(
1123 dir.path().join("old_skill.json"),
1124 serde_json::to_string_pretty(&skill_json).unwrap(),
1125 )
1126 .unwrap();
1127
1128 let mut registry = SkillRegistry::new();
1129 let count = registry.load_from_dir(dir.path()).unwrap();
1130 assert_eq!(count, 2);
1131 assert!(registry.get("new-skill").is_some());
1132 assert!(registry.get("old_skill").is_some());
1133 }
1134
1135 #[test]
1136 fn load_from_dir_with_source() {
1137 let dir = tempfile::tempdir().unwrap();
1138 let skill_dir = dir.path().join("global-skill");
1139 std::fs::create_dir(&skill_dir).unwrap();
1140 std::fs::write(
1141 skill_dir.join("SKILL.md"),
1142 "---\nname: global-skill\ndescription: Global.\n---\n\nDo global things.\n",
1143 )
1144 .unwrap();
1145
1146 let mut registry = SkillRegistry::new();
1147 let count = registry
1148 .load_from_dir_with_source(dir.path(), SkillSource::Global)
1149 .unwrap();
1150 assert_eq!(count, 1);
1151
1152 let rs = registry.get_registered("global-skill").unwrap();
1153 assert_eq!(rs.source, SkillSource::Global);
1154 }
1155
1156 #[test]
1157 fn skill_md_folder_overrides_builtin() {
1158 let dir = tempfile::tempdir().unwrap();
1159
1160 let skill_dir = dir.path().join("code_review");
1161 std::fs::create_dir(&skill_dir).unwrap();
1162 std::fs::write(
1163 skill_dir.join("SKILL.md"),
1164 "\
1165---
1166name: code_review
1167description: Custom review via SKILL.md.
1168metadata:
1169 arguments:
1170 - name: target
1171 description: What to review
1172 required: true
1173---
1174
1175Custom review: {target}
1176",
1177 )
1178 .unwrap();
1179
1180 let mut registry = SkillRegistry::with_builtins();
1181 assert_eq!(
1182 registry.get("code_review").unwrap().description,
1183 "Review code for bugs, style issues, and improvements."
1184 );
1185
1186 registry.load_from_dir(dir.path()).unwrap();
1187 assert_eq!(
1188 registry.get("code_review").unwrap().description,
1189 "Custom review via SKILL.md."
1190 );
1191 assert_eq!(
1192 registry.get_registered("code_review").unwrap().source,
1193 SkillSource::Project
1194 );
1195 }
1196
1197 #[test]
1198 fn parse_skill_md_allowed_tools() {
1199 let content = "\
1200---
1201name: safe-reader
1202description: Read-only exploration.
1203allowed-tools: Read, Grep, Glob
1204---
1205
1206Explore the codebase.
1207";
1208 let skill = parse_skill_md(content).unwrap();
1209 assert_eq!(skill.name, "safe-reader");
1210 let tools = skill.config.unwrap().allowed_tools.unwrap();
1211 assert_eq!(tools, vec!["Read", "Grep", "Glob"]);
1212 }
1213
1214 #[test]
1215 fn parse_skill_md_allowed_tools_overrides_metadata() {
1216 let content = "\
1217---
1218name: reader
1219description: Read stuff.
1220allowed-tools: Read, Grep
1221metadata:
1222 config:
1223 allowed_tools:
1224 - Bash
1225 - Write
1226---
1227
1228Read things.
1229";
1230 let skill = parse_skill_md(content).unwrap();
1231 let tools = skill.config.unwrap().allowed_tools.unwrap();
1233 assert_eq!(tools, vec!["Read", "Grep"]);
1234 }
1235
1236 #[test]
1237 fn parse_skill_md_argument_hint() {
1238 let content = "\
1239---
1240name: fix-issue
1241description: Fix a GitHub issue.
1242argument-hint: \"[issue-number]\"
1243metadata:
1244 arguments:
1245 - name: issue
1246 description: Issue number
1247 required: true
1248---
1249
1250Fix issue $ARGUMENTS.
1251";
1252 let skill = parse_skill_md(content).unwrap();
1253 assert_eq!(skill.argument_hint.as_deref(), Some("[issue-number]"));
1254 }
1255
1256 #[test]
1257 fn command_injection_basic() {
1258 let result = execute_command_injections("before !`echo hello` after");
1259 assert_eq!(result, "before hello after");
1260 }
1261
1262 #[test]
1263 fn command_injection_no_markers() {
1264 let input = "no commands here";
1265 assert_eq!(execute_command_injections(input), input);
1266 }
1267
1268 #[test]
1269 fn command_injection_failed_command() {
1270 let result = execute_command_injections("result: !`false`");
1271 assert!(result.starts_with("result: [command failed"));
1272 }
1273
1274 #[test]
1275 fn command_injection_multiple() {
1276 let result = execute_command_injections("!`echo a` and !`echo b`");
1277 assert_eq!(result, "a and b");
1278 }
1279
1280 #[test]
1281 fn command_injection_unclosed_backtick() {
1282 let result = execute_command_injections("before !`unclosed");
1283 assert_eq!(result, "before !`unclosed");
1284 }
1285
1286 #[test]
1287 fn render_with_command_injection() {
1288 let skill = Skill {
1289 name: "test".into(),
1290 description: "Test".into(),
1291 prompt: "Context: !`echo injected`\n\nDo {task}.".into(),
1292 arguments: vec![SkillArgument {
1293 name: "task".into(),
1294 description: "Task".into(),
1295 required: true,
1296 }],
1297 config: None,
1298 scope: SkillScope::Task,
1299 argument_hint: None,
1300 skill_dir: None,
1301 };
1302
1303 let mut args = HashMap::new();
1304 args.insert("task".into(), "the thing".into());
1305 let rendered = skill.render(&args).unwrap();
1306 assert_eq!(rendered, "Context: injected\n\nDo the thing.");
1307 }
1308
1309 #[test]
1310 fn skill_dir_substitution() {
1311 let skill = Skill {
1312 name: "vis".into(),
1313 description: "Visualize".into(),
1314 prompt: "Run: python ${CLAUDE_SKILL_DIR}/scripts/viz.py .".into(),
1315 arguments: vec![],
1316 config: None,
1317 scope: SkillScope::Task,
1318 argument_hint: None,
1319 skill_dir: Some(PathBuf::from("/home/user/.claude-pool/skills/vis")),
1320 };
1321
1322 let rendered = skill.render(&HashMap::new()).unwrap();
1323 assert_eq!(
1324 rendered,
1325 "Run: python /home/user/.claude-pool/skills/vis/scripts/viz.py ."
1326 );
1327 }
1328
1329 #[test]
1330 fn skill_dir_substitution_missing() {
1331 let skill = Skill {
1332 name: "vis".into(),
1333 description: "Visualize".into(),
1334 prompt: "Run: python ${CLAUDE_SKILL_DIR}/scripts/viz.py .".into(),
1335 arguments: vec![],
1336 config: None,
1337 scope: SkillScope::Task,
1338 argument_hint: None,
1339 skill_dir: None,
1340 };
1341
1342 let rendered = skill.render(&HashMap::new()).unwrap();
1343 assert!(rendered.contains("[CLAUDE_SKILL_DIR unavailable"));
1344 }
1345
1346 #[test]
1347 fn skill_dir_no_substitution_when_absent() {
1348 let skill = Skill {
1349 name: "simple".into(),
1350 description: "Simple".into(),
1351 prompt: "Do the thing.".into(),
1352 arguments: vec![],
1353 config: None,
1354 scope: SkillScope::Task,
1355 argument_hint: None,
1356 skill_dir: None,
1357 };
1358
1359 let rendered = skill.render(&HashMap::new()).unwrap();
1360 assert_eq!(rendered, "Do the thing.");
1361 }
1362
1363 #[test]
1364 fn skill_dir_set_from_directory_load() {
1365 let dir = tempfile::tempdir().unwrap();
1366 let skill_dir = dir.path().join("my_skill");
1367 std::fs::create_dir(&skill_dir).unwrap();
1368 std::fs::write(
1369 skill_dir.join("SKILL.md"),
1370 "---\nname: my_skill\ndescription: Test\n---\n\nRun ${CLAUDE_SKILL_DIR}/run.sh",
1371 )
1372 .unwrap();
1373
1374 let mut registry = SkillRegistry::new();
1375 registry.load_from_dir(dir.path()).unwrap();
1376
1377 let skill = registry.get("my_skill").unwrap();
1378 assert_eq!(skill.skill_dir.as_deref(), Some(skill_dir.as_path()));
1379
1380 let rendered = skill.render(&HashMap::new()).unwrap();
1381 assert!(rendered.contains(&skill_dir.display().to_string()));
1382 }
1383}