Skip to main content

a3s_code_core/tools/
skill.rs

1//! Skill System
2//!
3//! Provides support for loading skills in markdown format.
4//! Skills use a simple format focused on prompts/instructions
5//! with optional tool permissions.
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashSet;
9use std::path::Path;
10
11/// Skill kind classification
12///
13/// Determines how the skill is injected into the agent session:
14/// - `Instruction`: Prompt/instruction content injected into system prompt
15/// - `Tool`: Registers executable tools via the skill loader
16/// - `Agent`: Agent definition (future: registered in AgentRegistry)
17#[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/// Skill definition
27///
28/// Represents a skill with:
29/// - name: skill identifier
30/// - description: what the skill does
31/// - allowed_tools: tool permissions (e.g., "Bash(gh:*)")
32/// - disable_model_invocation: whether to disable model calls
33/// - content: the prompt/instruction content
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct Skill {
36    /// Skill name (from frontmatter or filename)
37    #[serde(default)]
38    pub name: String,
39
40    /// Skill description
41    #[serde(default)]
42    pub description: String,
43
44    /// Allowed tools (Claude Code format: "Bash(pattern:*)")
45    #[serde(default, rename = "allowed-tools")]
46    pub allowed_tools: Option<String>,
47
48    /// Whether to disable model invocation
49    #[serde(default, rename = "disable-model-invocation")]
50    pub disable_model_invocation: bool,
51
52    /// Skill kind (instruction, tool, or agent)
53    #[serde(default)]
54    pub kind: SkillKind,
55
56    /// Skill content (markdown instructions)
57    #[serde(skip)]
58    pub content: String,
59}
60
61impl Skill {
62    /// Parse a skill from markdown content
63    pub fn parse(content: &str) -> Option<Self> {
64        // Parse frontmatter (YAML between --- markers)
65        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        // Parse YAML frontmatter
75        let mut skill: Skill = serde_yaml::from_str(frontmatter).ok()?;
76        skill.content = body.to_string();
77
78        Some(skill)
79    }
80
81    /// Parse allowed tools into a set of tool patterns
82    ///
83    /// Claude Code format: "Bash(gh issue view:*), Bash(gh search:*)"
84    /// Returns patterns like: ["Bash:gh issue view:*", "Bash:gh search:*"]
85    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        // Parse comma-separated tool permissions
93        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    /// Check if a tool call is allowed by this skill
104    pub fn is_tool_allowed(&self, tool_name: &str, args: &str) -> bool {
105        let permissions = self.parse_allowed_tools();
106
107        // If no permissions specified, all tools are allowed
108        if permissions.is_empty() {
109            return true;
110        }
111
112        // Check if any permission matches
113        permissions.iter().any(|p| p.matches(tool_name, args))
114    }
115}
116
117/// Tool permission pattern
118#[derive(Debug, Clone, PartialEq, Eq, Hash)]
119pub struct ToolPermission {
120    /// Tool name (e.g., "Bash")
121    pub tool: String,
122    /// Pattern to match (e.g., "gh issue view:*")
123    pub pattern: String,
124}
125
126impl ToolPermission {
127    /// Parse a tool permission from Claude Code format
128    ///
129    /// Format: "ToolName(pattern)" or "ToolName(pattern:*)"
130    pub fn parse(s: &str) -> Option<Self> {
131        let s = s.trim();
132
133        // Find the opening parenthesis
134        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    /// Check if this permission matches a tool call
148    pub fn matches(&self, tool_name: &str, args: &str) -> bool {
149        // Tool name must match
150        if self.tool != tool_name {
151            return false;
152        }
153
154        // Check pattern match
155        self.pattern_matches(args)
156    }
157
158    /// Check if the pattern matches the given arguments
159    fn pattern_matches(&self, args: &str) -> bool {
160        let pattern = &self.pattern;
161
162        // Handle wildcard patterns
163        if pattern == "*" {
164            return true;
165        }
166
167        // Handle prefix wildcard (e.g., "gh:*" matches "gh status")
168        if let Some(prefix) = pattern.strip_suffix(":*") {
169            return args.starts_with(prefix);
170        }
171
172        // Handle suffix wildcard (e.g., "*:view" matches "gh issue view")
173        if let Some(suffix) = pattern.strip_prefix("*:") {
174            return args.ends_with(suffix);
175        }
176
177        // Handle glob-style wildcards
178        if pattern.contains('*') {
179            return glob_match(pattern, args);
180        }
181
182        // Exact match
183        pattern == args
184    }
185}
186
187/// Simple glob matching for patterns with *
188fn 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    // First part must match at start (if not empty)
198    if !parts[0].is_empty() {
199        if !text.starts_with(parts[0]) {
200            return false;
201        }
202        pos = parts[0].len();
203    }
204
205    // Middle parts must be found in order
206    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    // Last part must match at end (if not empty)
218    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
227/// Built-in skills compiled into the binary
228///
229/// These skills are always available without loading from disk.
230pub 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
246/// Load skills from a directory
247///
248/// Scans for .md files and parses them as skills.
249/// Returns skills that have valid frontmatter.
250pub 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        // Skip non-files
262        if !path.is_file() {
263            continue;
264        }
265
266        // Only process .md files
267        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        // Read and parse the skill file
276        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            // Use filename as name if not specified
283            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        // No restrictions means all tools allowed
402        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        // Create a Claude Code skill file
420        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        // Create another skill
433        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    // ===================
729    // Built-in Skills Tests
730    // ===================
731
732    #[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        // Verify find-skills is present
741        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        // Verify key content sections exist
773        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        // Verify key content sections exist
801        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    // ===================
816    // SkillKind Tests
817    // ===================
818
819    #[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}