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 skills
239}
240
241pub fn load_skills(dir: &Path) -> Vec<Skill> {
246 let mut skills = Vec::new();
247
248 let Ok(entries) = std::fs::read_dir(dir) else {
249 tracing::warn!("Failed to read skills directory: {}", dir.display());
250 return skills;
251 };
252
253 for entry in entries.flatten() {
254 let path = entry.path();
255
256 if !path.is_file() {
258 continue;
259 }
260
261 let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
263 continue;
264 };
265
266 if ext != "md" {
267 continue;
268 }
269
270 let Ok(content) = std::fs::read_to_string(&path) else {
272 tracing::warn!("Failed to read skill file: {}", path.display());
273 continue;
274 };
275
276 if let Some(mut skill) = Skill::parse(&content) {
277 if skill.name.is_empty() {
279 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
280 skill.name = stem.to_string();
281 }
282 }
283 tracing::debug!("Loaded Claude Code skill: {}", skill.name);
284 skills.push(skill);
285 }
286 }
287
288 skills
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[test]
296 fn test_parse_skill() {
297 let content = r#"---
298name: test-skill
299description: A test skill
300allowed-tools: Bash(gh:*)
301---
302This is the skill content.
303"#;
304
305 let skill = Skill::parse(content).unwrap();
306 assert_eq!(skill.name, "test-skill");
307 assert_eq!(skill.description, "A test skill");
308 assert_eq!(skill.allowed_tools, Some("Bash(gh:*)".to_string()));
309 assert!(!skill.disable_model_invocation);
310 assert_eq!(skill.content, "This is the skill content.");
311 }
312
313 #[test]
314 fn test_parse_skill_with_disable_model() {
315 let content = r#"---
316name: restricted-skill
317disable-model-invocation: true
318---
319Content here.
320"#;
321
322 let skill = Skill::parse(content).unwrap();
323 assert_eq!(skill.name, "restricted-skill");
324 assert!(skill.disable_model_invocation);
325 }
326
327 #[test]
328 fn test_parse_tool_permission() {
329 let perm = ToolPermission::parse("Bash(gh issue view:*)").unwrap();
330 assert_eq!(perm.tool, "Bash");
331 assert_eq!(perm.pattern, "gh issue view:*");
332 }
333
334 #[test]
335 fn test_parse_tool_permission_simple() {
336 let perm = ToolPermission::parse("Read(*)").unwrap();
337 assert_eq!(perm.tool, "Read");
338 assert_eq!(perm.pattern, "*");
339 }
340
341 #[test]
342 fn test_tool_permission_matches_wildcard() {
343 let perm = ToolPermission::parse("Bash(*)").unwrap();
344 assert!(perm.matches("Bash", "any command"));
345 assert!(perm.matches("Bash", ""));
346 assert!(!perm.matches("Read", "file.txt"));
347 }
348
349 #[test]
350 fn test_tool_permission_matches_prefix() {
351 let perm = ToolPermission::parse("Bash(gh:*)").unwrap();
352 assert!(perm.matches("Bash", "gh status"));
353 assert!(perm.matches("Bash", "gh pr view"));
354 assert!(!perm.matches("Bash", "git status"));
355 }
356
357 #[test]
358 fn test_parse_allowed_tools() {
359 let content = r#"---
360name: multi-tool
361allowed-tools: Bash(gh issue view:*), Bash(gh pr:*), Read(*)
362---
363"#;
364
365 let skill = Skill::parse(content).unwrap();
366 let permissions = skill.parse_allowed_tools();
367
368 assert_eq!(permissions.len(), 3);
369 }
370
371 #[test]
372 fn test_is_tool_allowed() {
373 let content = r#"---
374name: github-skill
375allowed-tools: Bash(gh:*)
376---
377"#;
378
379 let skill = Skill::parse(content).unwrap();
380
381 assert!(skill.is_tool_allowed("Bash", "gh status"));
382 assert!(skill.is_tool_allowed("Bash", "gh pr view 123"));
383 assert!(!skill.is_tool_allowed("Bash", "rm -rf /"));
384 assert!(!skill.is_tool_allowed("Read", "file.txt"));
385 }
386
387 #[test]
388 fn test_is_tool_allowed_no_restrictions() {
389 let content = r#"---
390name: open-skill
391---
392"#;
393
394 let skill = Skill::parse(content).unwrap();
395
396 assert!(skill.is_tool_allowed("Bash", "any command"));
398 assert!(skill.is_tool_allowed("Read", "any file"));
399 }
400
401 #[test]
402 fn test_glob_match() {
403 assert!(glob_match("gh*", "gh status"));
404 assert!(glob_match("*view", "gh pr view"));
405 assert!(glob_match("gh*view", "gh pr view"));
406 assert!(glob_match("*", "anything"));
407 assert!(!glob_match("gh*", "git status"));
408 }
409
410 #[test]
411 fn test_load_skills() {
412 let temp_dir = tempfile::tempdir().unwrap();
413
414 std::fs::write(
416 temp_dir.path().join("github.md"),
417 r#"---
418name: github-commands
419description: GitHub CLI commands
420allowed-tools: Bash(gh:*)
421---
422Use gh CLI for GitHub operations.
423"#,
424 )
425 .unwrap();
426
427 std::fs::write(
429 temp_dir.path().join("code-review.md"),
430 r#"---
431name: code-review
432description: Code review skill
433allowed-tools: Bash(gh pr:*), Read(*)
434disable-model-invocation: false
435---
436Review pull requests.
437"#,
438 )
439 .unwrap();
440
441 let skills = load_skills(temp_dir.path());
442 assert_eq!(skills.len(), 2);
443
444 let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
445 assert!(names.contains(&"github-commands"));
446 assert!(names.contains(&"code-review"));
447 }
448
449 #[test]
450 fn test_parse_skill_minimal() {
451 let content = r#"---
452name: minimal
453---
454Content only.
455"#;
456 let skill = Skill::parse(content).unwrap();
457 assert_eq!(skill.name, "minimal");
458 assert_eq!(skill.description, "");
459 assert!(skill.allowed_tools.is_none());
460 assert!(!skill.disable_model_invocation);
461 }
462
463 #[test]
464 fn test_parse_skill_invalid_frontmatter() {
465 let content = r#"---
466invalid yaml: [
467---
468Content
469"#;
470 let skill = Skill::parse(content);
471 assert!(skill.is_none());
472 }
473
474 #[test]
475 fn test_parse_skill_no_frontmatter() {
476 let content = "Just content without frontmatter";
477 let skill = Skill::parse(content);
478 assert!(skill.is_none());
479 }
480
481 #[test]
482 fn test_parse_skill_single_separator() {
483 let content = r#"---
484name: test
485"#;
486 let skill = Skill::parse(content);
487 assert!(skill.is_none());
488 }
489
490 #[test]
491 fn test_tool_permission_parse_invalid() {
492 assert!(ToolPermission::parse("NoParenthesis").is_none());
493 assert!(ToolPermission::parse("Missing(").is_none());
494 assert!(ToolPermission::parse("Reversed)pattern(").is_none());
495 assert!(ToolPermission::parse("").is_none());
496 }
497
498 #[test]
499 fn test_tool_permission_matches_exact() {
500 let perm = ToolPermission::parse("Bash(gh status)").unwrap();
501 assert!(perm.matches("Bash", "gh status"));
502 assert!(!perm.matches("Bash", "gh pr"));
503 assert!(!perm.matches("Read", "gh status"));
504 }
505
506 #[test]
507 fn test_tool_permission_matches_suffix_wildcard() {
508 let perm = ToolPermission::parse("Bash(*:view)").unwrap();
509 assert!(perm.matches("Bash", "gh issue view"));
510 assert!(perm.matches("Bash", "gh pr view"));
511 assert!(!perm.matches("Bash", "gh issue list"));
512 }
513
514 #[test]
515 fn test_tool_permission_matches_middle_wildcard() {
516 let perm = ToolPermission::parse("Bash(gh*view)").unwrap();
517 assert!(perm.matches("Bash", "gh issue view"));
518 assert!(perm.matches("Bash", "gh pr view"));
519 assert!(!perm.matches("Bash", "gh status"));
520 }
521
522 #[test]
523 fn test_glob_match_only_wildcard() {
524 assert!(glob_match("*", "anything"));
525 assert!(glob_match("*", ""));
526 }
527
528 #[test]
529 fn test_glob_match_multiple_wildcards() {
530 assert!(glob_match("*test*file*", "my test data file here"));
531 assert!(!glob_match("*test*file*", "my data here"));
532 }
533
534 #[test]
535 fn test_glob_match_start_wildcard() {
536 assert!(glob_match("*end", "start middle end"));
537 assert!(!glob_match("*end", "start middle"));
538 }
539
540 #[test]
541 fn test_glob_match_end_wildcard() {
542 assert!(glob_match("start*", "start middle end"));
543 assert!(!glob_match("start*", "middle end"));
544 }
545
546 #[test]
547 fn test_parse_allowed_tools_empty() {
548 let content = r#"---
549name: test
550allowed-tools: ""
551---
552"#;
553 let skill = Skill::parse(content).unwrap();
554 let permissions = skill.parse_allowed_tools();
555 assert_eq!(permissions.len(), 0);
556 }
557
558 #[test]
559 fn test_parse_allowed_tools_whitespace() {
560 let content = r#"---
561name: test
562allowed-tools: " Bash(gh:*) , Read(*) "
563---
564"#;
565 let skill = Skill::parse(content).unwrap();
566 let permissions = skill.parse_allowed_tools();
567 assert_eq!(permissions.len(), 2);
568 }
569
570 #[test]
571 fn test_is_tool_allowed_multiple_patterns() {
572 let content = r#"---
573name: test
574allowed-tools: Bash(gh:*), Bash(git:*), Read(*)
575---
576"#;
577 let skill = Skill::parse(content).unwrap();
578 assert!(skill.is_tool_allowed("Bash", "gh status"));
579 assert!(skill.is_tool_allowed("Bash", "git log"));
580 assert!(skill.is_tool_allowed("Read", "file.txt"));
581 assert!(!skill.is_tool_allowed("Write", "file.txt"));
582 }
583
584 #[test]
585 fn test_tool_permission_equality() {
586 let perm1 = ToolPermission {
587 tool: "Bash".to_string(),
588 pattern: "gh:*".to_string(),
589 };
590 let perm2 = ToolPermission {
591 tool: "Bash".to_string(),
592 pattern: "gh:*".to_string(),
593 };
594 let perm3 = ToolPermission {
595 tool: "Read".to_string(),
596 pattern: "*".to_string(),
597 };
598 assert_eq!(perm1, perm2);
599 assert_ne!(perm1, perm3);
600 }
601
602 #[test]
603 fn test_tool_permission_clone() {
604 let perm = ToolPermission {
605 tool: "Bash".to_string(),
606 pattern: "test:*".to_string(),
607 };
608 let cloned = perm.clone();
609 assert_eq!(perm, cloned);
610 }
611
612 #[test]
613 fn test_tool_permission_debug() {
614 let perm = ToolPermission {
615 tool: "Bash".to_string(),
616 pattern: "gh:*".to_string(),
617 };
618 let debug_str = format!("{:?}", perm);
619 assert!(debug_str.contains("Bash"));
620 assert!(debug_str.contains("gh:*"));
621 }
622
623 #[test]
624 fn test_skill_clone() {
625 let skill = Skill {
626 name: "test".to_string(),
627 description: "desc".to_string(),
628 allowed_tools: Some("Bash(*)".to_string()),
629 disable_model_invocation: true,
630 kind: SkillKind::Instruction,
631 content: "content".to_string(),
632 };
633 let cloned = skill.clone();
634 assert_eq!(skill.name, cloned.name);
635 assert_eq!(skill.description, cloned.description);
636 assert_eq!(
637 skill.disable_model_invocation,
638 cloned.disable_model_invocation
639 );
640 }
641
642 #[test]
643 fn test_skill_debug() {
644 let skill = Skill {
645 name: "test".to_string(),
646 description: "desc".to_string(),
647 allowed_tools: None,
648 disable_model_invocation: false,
649 kind: SkillKind::Instruction,
650 content: "content".to_string(),
651 };
652 let debug_str = format!("{:?}", skill);
653 assert!(debug_str.contains("test"));
654 }
655
656 #[test]
657 fn test_load_skills_nonexistent_dir() {
658 let skills = load_skills(std::path::Path::new("/nonexistent/path"));
659 assert_eq!(skills.len(), 0);
660 }
661
662 #[test]
663 fn test_load_skills_skip_non_md() {
664 let temp_dir = tempfile::tempdir().unwrap();
665 std::fs::write(
666 temp_dir.path().join("skill.txt"),
667 r#"---
668name: test
669---
670"#,
671 )
672 .unwrap();
673 let skills = load_skills(temp_dir.path());
674 assert_eq!(skills.len(), 0);
675 }
676
677 #[test]
678 fn test_load_skills_use_filename() {
679 let temp_dir = tempfile::tempdir().unwrap();
680 std::fs::write(
681 temp_dir.path().join("my-skill.md"),
682 r#"---
683description: Test skill
684---
685Content
686"#,
687 )
688 .unwrap();
689 let skills = load_skills(temp_dir.path());
690 assert_eq!(skills.len(), 1);
691 assert_eq!(skills[0].name, "my-skill");
692 }
693
694 #[test]
695 fn test_load_skills_skip_subdirs() {
696 let temp_dir = tempfile::tempdir().unwrap();
697 let subdir = temp_dir.path().join("subdir");
698 std::fs::create_dir(&subdir).unwrap();
699 std::fs::write(
700 subdir.join("skill.md"),
701 r#"---
702name: test
703---
704"#,
705 )
706 .unwrap();
707 let skills = load_skills(temp_dir.path());
708 assert_eq!(skills.len(), 0);
709 }
710
711 #[test]
712 fn test_parse_allowed_tools_invalid_format() {
713 let content = r#"---
714name: test
715allowed-tools: InvalidFormat, AlsoInvalid
716---
717"#;
718 let skill = Skill::parse(content).unwrap();
719 let permissions = skill.parse_allowed_tools();
720 assert_eq!(permissions.len(), 0);
721 }
722
723 #[test]
728 fn test_builtin_skills() {
729 let skills = builtin_skills();
730 assert!(
731 !skills.is_empty(),
732 "Should have at least one built-in Claude Code skill"
733 );
734
735 let find_skills = skills.iter().find(|s| s.name == "find-skills");
737 assert!(find_skills.is_some(), "find-skills skill should be present");
738
739 let skill = find_skills.unwrap();
740 assert!(
741 !skill.description.is_empty(),
742 "find-skills should have a description"
743 );
744 assert!(!skill.content.is_empty(), "find-skills should have content");
745 }
746
747 #[test]
748 fn test_builtin_find_skills_content() {
749 let skills = builtin_skills();
750 let skill = skills.iter().find(|s| s.name == "find-skills").unwrap();
751
752 assert!(
754 skill.content.contains("search_skills"),
755 "Should reference search_skills tool"
756 );
757 assert!(
758 skill.content.contains("install_skill"),
759 "Should reference install_skill tool"
760 );
761 assert!(
762 skill.content.contains("skills.sh"),
763 "Should reference skills.sh"
764 );
765 }
766
767 #[test]
772 fn test_skill_kind_default_is_instruction() {
773 let kind = SkillKind::default();
774 assert_eq!(kind, SkillKind::Instruction);
775 }
776
777 #[test]
778 fn test_parse_skill_kind_instruction() {
779 let content = r#"---
780name: guide
781kind: instruction
782---
783Some instructions.
784"#;
785 let skill = Skill::parse(content).unwrap();
786 assert_eq!(skill.kind, SkillKind::Instruction);
787 }
788
789 #[test]
790 fn test_parse_skill_kind_tool() {
791 let content = r#"---
792name: my-tool
793kind: tool
794---
795Tool content.
796"#;
797 let skill = Skill::parse(content).unwrap();
798 assert_eq!(skill.kind, SkillKind::Tool);
799 }
800
801 #[test]
802 fn test_parse_skill_kind_agent() {
803 let content = r#"---
804name: my-agent
805kind: agent
806---
807Agent content.
808"#;
809 let skill = Skill::parse(content).unwrap();
810 assert_eq!(skill.kind, SkillKind::Agent);
811 }
812
813 #[test]
814 fn test_parse_skill_kind_missing_defaults_to_instruction() {
815 let content = r#"---
816name: old-skill
817description: No kind field
818---
819Content here.
820"#;
821 let skill = Skill::parse(content).unwrap();
822 assert_eq!(skill.kind, SkillKind::Instruction);
823 }
824
825 #[test]
826 fn test_skill_kind_serialize() {
827 assert_eq!(
828 serde_json::to_string(&SkillKind::Instruction).unwrap(),
829 "\"instruction\""
830 );
831 assert_eq!(serde_json::to_string(&SkillKind::Tool).unwrap(), "\"tool\"");
832 assert_eq!(
833 serde_json::to_string(&SkillKind::Agent).unwrap(),
834 "\"agent\""
835 );
836 }
837
838 #[test]
839 fn test_skill_kind_clone_copy() {
840 let kind = SkillKind::Tool;
841 let cloned = kind.clone();
842 let copied = kind;
843 assert_eq!(kind, cloned);
844 assert_eq!(kind, copied);
845 }
846}