1use serde::{Deserialize, Serialize};
8use std::collections::HashSet;
9use std::path::Path;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
18#[serde(rename_all = "lowercase")]
19pub enum SkillKind {
20 #[default]
21 Instruction,
22 Tool,
23 Agent,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct Skill {
36 #[serde(default)]
38 pub name: String,
39
40 #[serde(default)]
42 pub description: String,
43
44 #[serde(default, rename = "allowed-tools")]
46 pub allowed_tools: Option<String>,
47
48 #[serde(default, rename = "disable-model-invocation")]
50 pub disable_model_invocation: bool,
51
52 #[serde(default)]
54 pub kind: SkillKind,
55
56 #[serde(skip)]
58 pub content: String,
59}
60
61impl Skill {
62 pub fn parse(content: &str) -> Option<Self> {
64 let parts: Vec<&str> = content.splitn(3, "---").collect();
66
67 if parts.len() < 3 {
68 return None;
69 }
70
71 let frontmatter = parts[1].trim();
72 let body = parts[2].trim();
73
74 let mut skill: Skill = serde_yaml::from_str(frontmatter).ok()?;
76 skill.content = body.to_string();
77
78 Some(skill)
79 }
80
81 pub fn parse_allowed_tools(&self) -> HashSet<ToolPermission> {
86 let mut permissions = HashSet::new();
87
88 let Some(allowed) = &self.allowed_tools else {
89 return permissions;
90 };
91
92 for part in allowed.split(',') {
94 let part = part.trim();
95 if let Some(perm) = ToolPermission::parse(part) {
96 permissions.insert(perm);
97 }
98 }
99
100 permissions
101 }
102
103 pub fn is_tool_allowed(&self, tool_name: &str, args: &str) -> bool {
105 let permissions = self.parse_allowed_tools();
106
107 if permissions.is_empty() {
109 return true;
110 }
111
112 permissions.iter().any(|p| p.matches(tool_name, args))
114 }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Hash)]
119pub struct ToolPermission {
120 pub tool: String,
122 pub pattern: String,
124}
125
126impl ToolPermission {
127 pub fn parse(s: &str) -> Option<Self> {
131 let s = s.trim();
132
133 let paren_start = s.find('(')?;
135 let paren_end = s.rfind(')')?;
136
137 if paren_start >= paren_end {
138 return None;
139 }
140
141 let tool = s[..paren_start].trim().to_string();
142 let pattern = s[paren_start + 1..paren_end].trim().to_string();
143
144 Some(Self { tool, pattern })
145 }
146
147 pub fn matches(&self, tool_name: &str, args: &str) -> bool {
149 if self.tool != tool_name {
151 return false;
152 }
153
154 self.pattern_matches(args)
156 }
157
158 fn pattern_matches(&self, args: &str) -> bool {
160 let pattern = &self.pattern;
161
162 if pattern == "*" {
164 return true;
165 }
166
167 if let Some(prefix) = pattern.strip_suffix(":*") {
169 return args.starts_with(prefix);
170 }
171
172 if let Some(suffix) = pattern.strip_prefix("*:") {
174 return args.ends_with(suffix);
175 }
176
177 if pattern.contains('*') {
179 return glob_match(pattern, args);
180 }
181
182 pattern == args
184 }
185}
186
187fn glob_match(pattern: &str, text: &str) -> bool {
189 let parts: Vec<&str> = pattern.split('*').collect();
190
191 if parts.is_empty() {
192 return true;
193 }
194
195 let mut pos = 0;
196
197 if !parts[0].is_empty() {
199 if !text.starts_with(parts[0]) {
200 return false;
201 }
202 pos = parts[0].len();
203 }
204
205 for part in &parts[1..parts.len() - 1] {
207 if part.is_empty() {
208 continue;
209 }
210 if let Some(found) = text[pos..].find(part) {
211 pos += found + part.len();
212 } else {
213 return false;
214 }
215 }
216
217 if let Some(last) = parts.last() {
219 if !last.is_empty() && !text[pos..].ends_with(last) {
220 return false;
221 }
222 }
223
224 true
225}
226
227pub fn builtin_skills() -> Vec<Skill> {
231 let mut skills = Vec::new();
232
233 let find_skills_content = include_str!("../../skills/find-skills.md");
234 if let Some(skill) = Skill::parse(find_skills_content) {
235 skills.push(skill);
236 }
237
238 let delegate_task_content = include_str!("../../skills/delegate-task.md");
239 if let Some(skill) = Skill::parse(delegate_task_content) {
240 skills.push(skill);
241 }
242
243 skills
244}
245
246pub fn load_skills(dir: &Path) -> Vec<Skill> {
251 let mut skills = Vec::new();
252
253 let Ok(entries) = std::fs::read_dir(dir) else {
254 tracing::warn!("Failed to read skills directory: {}", dir.display());
255 return skills;
256 };
257
258 for entry in entries.flatten() {
259 let path = entry.path();
260
261 if !path.is_file() {
263 continue;
264 }
265
266 let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
268 continue;
269 };
270
271 if ext != "md" {
272 continue;
273 }
274
275 let Ok(content) = std::fs::read_to_string(&path) else {
277 tracing::warn!("Failed to read skill file: {}", path.display());
278 continue;
279 };
280
281 if let Some(mut skill) = Skill::parse(&content) {
282 if skill.name.is_empty() {
284 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
285 skill.name = stem.to_string();
286 }
287 }
288 tracing::debug!("Loaded Claude Code skill: {}", skill.name);
289 skills.push(skill);
290 }
291 }
292
293 skills
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn test_parse_skill() {
302 let content = r#"---
303name: test-skill
304description: A test skill
305allowed-tools: Bash(gh:*)
306---
307This is the skill content.
308"#;
309
310 let skill = Skill::parse(content).unwrap();
311 assert_eq!(skill.name, "test-skill");
312 assert_eq!(skill.description, "A test skill");
313 assert_eq!(skill.allowed_tools, Some("Bash(gh:*)".to_string()));
314 assert!(!skill.disable_model_invocation);
315 assert_eq!(skill.content, "This is the skill content.");
316 }
317
318 #[test]
319 fn test_parse_skill_with_disable_model() {
320 let content = r#"---
321name: restricted-skill
322disable-model-invocation: true
323---
324Content here.
325"#;
326
327 let skill = Skill::parse(content).unwrap();
328 assert_eq!(skill.name, "restricted-skill");
329 assert!(skill.disable_model_invocation);
330 }
331
332 #[test]
333 fn test_parse_tool_permission() {
334 let perm = ToolPermission::parse("Bash(gh issue view:*)").unwrap();
335 assert_eq!(perm.tool, "Bash");
336 assert_eq!(perm.pattern, "gh issue view:*");
337 }
338
339 #[test]
340 fn test_parse_tool_permission_simple() {
341 let perm = ToolPermission::parse("Read(*)").unwrap();
342 assert_eq!(perm.tool, "Read");
343 assert_eq!(perm.pattern, "*");
344 }
345
346 #[test]
347 fn test_tool_permission_matches_wildcard() {
348 let perm = ToolPermission::parse("Bash(*)").unwrap();
349 assert!(perm.matches("Bash", "any command"));
350 assert!(perm.matches("Bash", ""));
351 assert!(!perm.matches("Read", "file.txt"));
352 }
353
354 #[test]
355 fn test_tool_permission_matches_prefix() {
356 let perm = ToolPermission::parse("Bash(gh:*)").unwrap();
357 assert!(perm.matches("Bash", "gh status"));
358 assert!(perm.matches("Bash", "gh pr view"));
359 assert!(!perm.matches("Bash", "git status"));
360 }
361
362 #[test]
363 fn test_parse_allowed_tools() {
364 let content = r#"---
365name: multi-tool
366allowed-tools: Bash(gh issue view:*), Bash(gh pr:*), Read(*)
367---
368"#;
369
370 let skill = Skill::parse(content).unwrap();
371 let permissions = skill.parse_allowed_tools();
372
373 assert_eq!(permissions.len(), 3);
374 }
375
376 #[test]
377 fn test_is_tool_allowed() {
378 let content = r#"---
379name: github-skill
380allowed-tools: Bash(gh:*)
381---
382"#;
383
384 let skill = Skill::parse(content).unwrap();
385
386 assert!(skill.is_tool_allowed("Bash", "gh status"));
387 assert!(skill.is_tool_allowed("Bash", "gh pr view 123"));
388 assert!(!skill.is_tool_allowed("Bash", "rm -rf /"));
389 assert!(!skill.is_tool_allowed("Read", "file.txt"));
390 }
391
392 #[test]
393 fn test_is_tool_allowed_no_restrictions() {
394 let content = r#"---
395name: open-skill
396---
397"#;
398
399 let skill = Skill::parse(content).unwrap();
400
401 assert!(skill.is_tool_allowed("Bash", "any command"));
403 assert!(skill.is_tool_allowed("Read", "any file"));
404 }
405
406 #[test]
407 fn test_glob_match() {
408 assert!(glob_match("gh*", "gh status"));
409 assert!(glob_match("*view", "gh pr view"));
410 assert!(glob_match("gh*view", "gh pr view"));
411 assert!(glob_match("*", "anything"));
412 assert!(!glob_match("gh*", "git status"));
413 }
414
415 #[test]
416 fn test_load_skills() {
417 let temp_dir = tempfile::tempdir().unwrap();
418
419 std::fs::write(
421 temp_dir.path().join("github.md"),
422 r#"---
423name: github-commands
424description: GitHub CLI commands
425allowed-tools: Bash(gh:*)
426---
427Use gh CLI for GitHub operations.
428"#,
429 )
430 .unwrap();
431
432 std::fs::write(
434 temp_dir.path().join("code-review.md"),
435 r#"---
436name: code-review
437description: Code review skill
438allowed-tools: Bash(gh pr:*), Read(*)
439disable-model-invocation: false
440---
441Review pull requests.
442"#,
443 )
444 .unwrap();
445
446 let skills = load_skills(temp_dir.path());
447 assert_eq!(skills.len(), 2);
448
449 let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
450 assert!(names.contains(&"github-commands"));
451 assert!(names.contains(&"code-review"));
452 }
453
454 #[test]
455 fn test_parse_skill_minimal() {
456 let content = r#"---
457name: minimal
458---
459Content only.
460"#;
461 let skill = Skill::parse(content).unwrap();
462 assert_eq!(skill.name, "minimal");
463 assert_eq!(skill.description, "");
464 assert!(skill.allowed_tools.is_none());
465 assert!(!skill.disable_model_invocation);
466 }
467
468 #[test]
469 fn test_parse_skill_invalid_frontmatter() {
470 let content = r#"---
471invalid yaml: [
472---
473Content
474"#;
475 let skill = Skill::parse(content);
476 assert!(skill.is_none());
477 }
478
479 #[test]
480 fn test_parse_skill_no_frontmatter() {
481 let content = "Just content without frontmatter";
482 let skill = Skill::parse(content);
483 assert!(skill.is_none());
484 }
485
486 #[test]
487 fn test_parse_skill_single_separator() {
488 let content = r#"---
489name: test
490"#;
491 let skill = Skill::parse(content);
492 assert!(skill.is_none());
493 }
494
495 #[test]
496 fn test_tool_permission_parse_invalid() {
497 assert!(ToolPermission::parse("NoParenthesis").is_none());
498 assert!(ToolPermission::parse("Missing(").is_none());
499 assert!(ToolPermission::parse("Reversed)pattern(").is_none());
500 assert!(ToolPermission::parse("").is_none());
501 }
502
503 #[test]
504 fn test_tool_permission_matches_exact() {
505 let perm = ToolPermission::parse("Bash(gh status)").unwrap();
506 assert!(perm.matches("Bash", "gh status"));
507 assert!(!perm.matches("Bash", "gh pr"));
508 assert!(!perm.matches("Read", "gh status"));
509 }
510
511 #[test]
512 fn test_tool_permission_matches_suffix_wildcard() {
513 let perm = ToolPermission::parse("Bash(*:view)").unwrap();
514 assert!(perm.matches("Bash", "gh issue view"));
515 assert!(perm.matches("Bash", "gh pr view"));
516 assert!(!perm.matches("Bash", "gh issue list"));
517 }
518
519 #[test]
520 fn test_tool_permission_matches_middle_wildcard() {
521 let perm = ToolPermission::parse("Bash(gh*view)").unwrap();
522 assert!(perm.matches("Bash", "gh issue view"));
523 assert!(perm.matches("Bash", "gh pr view"));
524 assert!(!perm.matches("Bash", "gh status"));
525 }
526
527 #[test]
528 fn test_glob_match_only_wildcard() {
529 assert!(glob_match("*", "anything"));
530 assert!(glob_match("*", ""));
531 }
532
533 #[test]
534 fn test_glob_match_multiple_wildcards() {
535 assert!(glob_match("*test*file*", "my test data file here"));
536 assert!(!glob_match("*test*file*", "my data here"));
537 }
538
539 #[test]
540 fn test_glob_match_start_wildcard() {
541 assert!(glob_match("*end", "start middle end"));
542 assert!(!glob_match("*end", "start middle"));
543 }
544
545 #[test]
546 fn test_glob_match_end_wildcard() {
547 assert!(glob_match("start*", "start middle end"));
548 assert!(!glob_match("start*", "middle end"));
549 }
550
551 #[test]
552 fn test_parse_allowed_tools_empty() {
553 let content = r#"---
554name: test
555allowed-tools: ""
556---
557"#;
558 let skill = Skill::parse(content).unwrap();
559 let permissions = skill.parse_allowed_tools();
560 assert_eq!(permissions.len(), 0);
561 }
562
563 #[test]
564 fn test_parse_allowed_tools_whitespace() {
565 let content = r#"---
566name: test
567allowed-tools: " Bash(gh:*) , Read(*) "
568---
569"#;
570 let skill = Skill::parse(content).unwrap();
571 let permissions = skill.parse_allowed_tools();
572 assert_eq!(permissions.len(), 2);
573 }
574
575 #[test]
576 fn test_is_tool_allowed_multiple_patterns() {
577 let content = r#"---
578name: test
579allowed-tools: Bash(gh:*), Bash(git:*), Read(*)
580---
581"#;
582 let skill = Skill::parse(content).unwrap();
583 assert!(skill.is_tool_allowed("Bash", "gh status"));
584 assert!(skill.is_tool_allowed("Bash", "git log"));
585 assert!(skill.is_tool_allowed("Read", "file.txt"));
586 assert!(!skill.is_tool_allowed("Write", "file.txt"));
587 }
588
589 #[test]
590 fn test_tool_permission_equality() {
591 let perm1 = ToolPermission {
592 tool: "Bash".to_string(),
593 pattern: "gh:*".to_string(),
594 };
595 let perm2 = ToolPermission {
596 tool: "Bash".to_string(),
597 pattern: "gh:*".to_string(),
598 };
599 let perm3 = ToolPermission {
600 tool: "Read".to_string(),
601 pattern: "*".to_string(),
602 };
603 assert_eq!(perm1, perm2);
604 assert_ne!(perm1, perm3);
605 }
606
607 #[test]
608 fn test_tool_permission_clone() {
609 let perm = ToolPermission {
610 tool: "Bash".to_string(),
611 pattern: "test:*".to_string(),
612 };
613 let cloned = perm.clone();
614 assert_eq!(perm, cloned);
615 }
616
617 #[test]
618 fn test_tool_permission_debug() {
619 let perm = ToolPermission {
620 tool: "Bash".to_string(),
621 pattern: "gh:*".to_string(),
622 };
623 let debug_str = format!("{:?}", perm);
624 assert!(debug_str.contains("Bash"));
625 assert!(debug_str.contains("gh:*"));
626 }
627
628 #[test]
629 fn test_skill_clone() {
630 let skill = Skill {
631 name: "test".to_string(),
632 description: "desc".to_string(),
633 allowed_tools: Some("Bash(*)".to_string()),
634 disable_model_invocation: true,
635 kind: SkillKind::Instruction,
636 content: "content".to_string(),
637 };
638 let cloned = skill.clone();
639 assert_eq!(skill.name, cloned.name);
640 assert_eq!(skill.description, cloned.description);
641 assert_eq!(
642 skill.disable_model_invocation,
643 cloned.disable_model_invocation
644 );
645 }
646
647 #[test]
648 fn test_skill_debug() {
649 let skill = Skill {
650 name: "test".to_string(),
651 description: "desc".to_string(),
652 allowed_tools: None,
653 disable_model_invocation: false,
654 kind: SkillKind::Instruction,
655 content: "content".to_string(),
656 };
657 let debug_str = format!("{:?}", skill);
658 assert!(debug_str.contains("test"));
659 }
660
661 #[test]
662 fn test_load_skills_nonexistent_dir() {
663 let skills = load_skills(std::path::Path::new("/nonexistent/path"));
664 assert_eq!(skills.len(), 0);
665 }
666
667 #[test]
668 fn test_load_skills_skip_non_md() {
669 let temp_dir = tempfile::tempdir().unwrap();
670 std::fs::write(
671 temp_dir.path().join("skill.txt"),
672 r#"---
673name: test
674---
675"#,
676 )
677 .unwrap();
678 let skills = load_skills(temp_dir.path());
679 assert_eq!(skills.len(), 0);
680 }
681
682 #[test]
683 fn test_load_skills_use_filename() {
684 let temp_dir = tempfile::tempdir().unwrap();
685 std::fs::write(
686 temp_dir.path().join("my-skill.md"),
687 r#"---
688description: Test skill
689---
690Content
691"#,
692 )
693 .unwrap();
694 let skills = load_skills(temp_dir.path());
695 assert_eq!(skills.len(), 1);
696 assert_eq!(skills[0].name, "my-skill");
697 }
698
699 #[test]
700 fn test_load_skills_skip_subdirs() {
701 let temp_dir = tempfile::tempdir().unwrap();
702 let subdir = temp_dir.path().join("subdir");
703 std::fs::create_dir(&subdir).unwrap();
704 std::fs::write(
705 subdir.join("skill.md"),
706 r#"---
707name: test
708---
709"#,
710 )
711 .unwrap();
712 let skills = load_skills(temp_dir.path());
713 assert_eq!(skills.len(), 0);
714 }
715
716 #[test]
717 fn test_parse_allowed_tools_invalid_format() {
718 let content = r#"---
719name: test
720allowed-tools: InvalidFormat, AlsoInvalid
721---
722"#;
723 let skill = Skill::parse(content).unwrap();
724 let permissions = skill.parse_allowed_tools();
725 assert_eq!(permissions.len(), 0);
726 }
727
728 #[test]
733 fn test_builtin_skills() {
734 let skills = builtin_skills();
735 assert!(
736 skills.len() >= 2,
737 "Should have at least two built-in Claude Code skills"
738 );
739
740 let find_skills = skills.iter().find(|s| s.name == "find-skills");
742 assert!(find_skills.is_some(), "find-skills skill should be present");
743
744 let skill = find_skills.unwrap();
745 assert!(
746 !skill.description.is_empty(),
747 "find-skills should have a description"
748 );
749 assert!(!skill.content.is_empty(), "find-skills should have content");
750 }
751
752 #[test]
753 fn test_builtin_skills_includes_delegate_task() {
754 let skills = builtin_skills();
755 let delegate = skills.iter().find(|s| s.name == "delegate-task");
756 assert!(delegate.is_some(), "delegate-task skill should be present");
757
758 let d = delegate.unwrap();
759 assert_eq!(d.kind, SkillKind::Instruction);
760 assert!(!d.content.is_empty(), "delegate-task should have content");
761 assert!(
762 d.description.contains("sub-agent"),
763 "delegate-task description should mention sub-agents"
764 );
765 }
766
767 #[test]
768 fn test_builtin_delegate_task_content() {
769 let skills = builtin_skills();
770 let skill = skills.iter().find(|s| s.name == "delegate-task").unwrap();
771
772 assert!(
774 skill.content.contains("explore"),
775 "Should reference explore agent"
776 );
777 assert!(
778 skill.content.contains("general"),
779 "Should reference general agent"
780 );
781 assert!(
782 skill.content.contains("plan"),
783 "Should reference plan agent"
784 );
785 assert!(
786 skill.content.contains("parallel_task"),
787 "Should reference parallel_task tool"
788 );
789 assert!(
790 skill.content.contains("agent_dirs"),
791 "Should mention custom agents from agent_dirs"
792 );
793 }
794
795 #[test]
796 fn test_builtin_find_skills_content() {
797 let skills = builtin_skills();
798 let skill = skills.iter().find(|s| s.name == "find-skills").unwrap();
799
800 assert!(
802 skill.content.contains("search_skills"),
803 "Should reference search_skills tool"
804 );
805 assert!(
806 skill.content.contains("install_skill"),
807 "Should reference install_skill tool"
808 );
809 assert!(
810 skill.content.contains("skills.sh"),
811 "Should reference skills.sh"
812 );
813 }
814
815 #[test]
820 fn test_skill_kind_default_is_instruction() {
821 let kind = SkillKind::default();
822 assert_eq!(kind, SkillKind::Instruction);
823 }
824
825 #[test]
826 fn test_parse_skill_kind_instruction() {
827 let content = r#"---
828name: guide
829kind: instruction
830---
831Some instructions.
832"#;
833 let skill = Skill::parse(content).unwrap();
834 assert_eq!(skill.kind, SkillKind::Instruction);
835 }
836
837 #[test]
838 fn test_parse_skill_kind_tool() {
839 let content = r#"---
840name: my-tool
841kind: tool
842---
843Tool content.
844"#;
845 let skill = Skill::parse(content).unwrap();
846 assert_eq!(skill.kind, SkillKind::Tool);
847 }
848
849 #[test]
850 fn test_parse_skill_kind_agent() {
851 let content = r#"---
852name: my-agent
853kind: agent
854---
855Agent content.
856"#;
857 let skill = Skill::parse(content).unwrap();
858 assert_eq!(skill.kind, SkillKind::Agent);
859 }
860
861 #[test]
862 fn test_parse_skill_kind_missing_defaults_to_instruction() {
863 let content = r#"---
864name: old-skill
865description: No kind field
866---
867Content here.
868"#;
869 let skill = Skill::parse(content).unwrap();
870 assert_eq!(skill.kind, SkillKind::Instruction);
871 }
872
873 #[test]
874 fn test_skill_kind_serialize() {
875 assert_eq!(
876 serde_json::to_string(&SkillKind::Instruction).unwrap(),
877 "\"instruction\""
878 );
879 assert_eq!(serde_json::to_string(&SkillKind::Tool).unwrap(), "\"tool\"");
880 assert_eq!(
881 serde_json::to_string(&SkillKind::Agent).unwrap(),
882 "\"agent\""
883 );
884 }
885
886 #[test]
887 fn test_skill_kind_clone_copy() {
888 let kind = SkillKind::Tool;
889 let cloned = kind.clone();
890 let copied = kind;
891 assert_eq!(kind, cloned);
892 assert_eq!(kind, copied);
893 }
894}