Skip to main content

a3s_code_core/skills/
mod.rs

1//! Skill System
2//!
3//! Provides a lightweight skill system compatible with Claude Code skill format.
4//! Skills are defined in Markdown files with YAML frontmatter.
5//!
6//! ## Skill Format
7//!
8//! ```markdown
9//! ---
10//! name: my-skill
11//! description: What the skill does
12//! allowed-tools: "read(*), grep(*)"
13//! kind: instruction
14//! ---
15//! # Skill Instructions
16//!
17//! You are a specialized assistant that...
18//! ```
19
20mod builtin;
21mod registry;
22
23pub use builtin::builtin_skills;
24pub use registry::SkillRegistry;
25
26use serde::{Deserialize, Serialize};
27use std::collections::HashSet;
28use std::path::Path;
29
30/// Skill kind classification
31///
32/// Determines how the skill is used:
33/// - `Instruction`: Prompt/instruction content injected into system prompt
34/// - `Tool`: Registers executable tools (future)
35/// - `Agent`: Agent definition (future)
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
37#[serde(rename_all = "lowercase")]
38pub enum SkillKind {
39    #[default]
40    Instruction,
41    Tool,
42    Agent,
43}
44
45/// Tool permission pattern
46///
47/// Represents a tool permission in Claude Code format:
48/// - `Bash(gh issue view:*)` -> tool: "Bash", pattern: "gh issue view:*"
49/// - `read(*)` -> tool: "read", pattern: "*"
50#[derive(Debug, Clone, PartialEq, Eq, Hash)]
51pub struct ToolPermission {
52    pub tool: String,
53    pub pattern: String,
54}
55
56impl ToolPermission {
57    /// Parse a tool permission from Claude Code format
58    ///
59    /// Examples:
60    /// - "Bash(gh issue view:*)" -> ToolPermission { tool: "Bash", pattern: "gh issue view:*" }
61    /// - "read(*)" -> ToolPermission { tool: "read", pattern: "*" }
62    pub fn parse(s: &str) -> Option<Self> {
63        let s = s.trim();
64
65        // Find opening parenthesis
66        let open = s.find('(')?;
67        let close = s.rfind(')')?;
68
69        if close <= open {
70            return None;
71        }
72
73        let tool = s[..open].trim().to_string();
74        let pattern = s[open + 1..close].trim().to_string();
75
76        Some(ToolPermission { tool, pattern })
77    }
78}
79
80/// Skill definition (Claude Code compatible)
81///
82/// Represents a skill loaded from a Markdown file with YAML frontmatter.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct Skill {
85    /// Skill name (from frontmatter or filename)
86    #[serde(default)]
87    pub name: String,
88
89    /// Skill description
90    #[serde(default)]
91    pub description: String,
92
93    /// Allowed tools (Claude Code format: "Bash(pattern:*), read(*)")
94    #[serde(default, rename = "allowed-tools")]
95    pub allowed_tools: Option<String>,
96
97    /// Whether to disable model invocation
98    #[serde(default, rename = "disable-model-invocation")]
99    pub disable_model_invocation: bool,
100
101    /// Skill kind (instruction, tool, or agent)
102    #[serde(default)]
103    pub kind: SkillKind,
104
105    /// Skill content (markdown instructions)
106    #[serde(skip)]
107    pub content: String,
108
109    /// Optional tags for categorization
110    #[serde(default)]
111    pub tags: Vec<String>,
112
113    /// Optional version
114    #[serde(default)]
115    pub version: Option<String>,
116}
117
118impl Skill {
119    /// Parse a skill from markdown content
120    ///
121    /// Expected format:
122    /// ```markdown
123    /// ---
124    /// name: skill-name
125    /// description: What it does
126    /// allowed-tools: "read(*), grep(*)"
127    /// ---
128    /// # Instructions
129    /// ...
130    /// ```
131    pub fn parse(content: &str) -> Option<Self> {
132        // Parse frontmatter (YAML between --- markers)
133        let parts: Vec<&str> = content.splitn(3, "---").collect();
134
135        if parts.len() < 3 {
136            return None;
137        }
138
139        let frontmatter = parts[1].trim();
140        let body = parts[2].trim();
141
142        // Parse YAML frontmatter
143        let mut skill: Skill = serde_yaml::from_str(frontmatter).ok()?;
144        skill.content = body.to_string();
145
146        Some(skill)
147    }
148
149    /// Load a skill from a file
150    pub fn from_file(path: impl AsRef<Path>) -> anyhow::Result<Self> {
151        let content = std::fs::read_to_string(path.as_ref())?;
152        let mut skill =
153            Self::parse(&content).ok_or_else(|| anyhow::anyhow!("Failed to parse skill file"))?;
154
155        // Use filename as name if not specified
156        if skill.name.is_empty() {
157            if let Some(stem) = path.as_ref().file_stem() {
158                skill.name = stem.to_string_lossy().to_string();
159            }
160        }
161
162        Ok(skill)
163    }
164
165    /// Parse allowed tools into a set of tool permissions
166    ///
167    /// Claude Code format: "Bash(gh issue view:*), Bash(gh search:*)"
168    /// Returns patterns like: ["Bash:gh issue view:*", "Bash:gh search:*"]
169    pub fn parse_allowed_tools(&self) -> HashSet<ToolPermission> {
170        let mut permissions = HashSet::new();
171
172        let Some(allowed) = &self.allowed_tools else {
173            return permissions;
174        };
175
176        // Parse comma-separated tool permissions
177        for part in allowed.split(',') {
178            let part = part.trim();
179            if let Some(perm) = ToolPermission::parse(part) {
180                permissions.insert(perm);
181            }
182        }
183
184        permissions
185    }
186
187    /// Check if a tool is allowed by this skill
188    pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
189        let permissions = self.parse_allowed_tools();
190
191        if permissions.is_empty() {
192            // No restrictions = all tools allowed
193            return true;
194        }
195
196        // Check if any permission matches
197        permissions
198            .iter()
199            .any(|perm| perm.tool.eq_ignore_ascii_case(tool_name) && perm.pattern == "*")
200    }
201
202    /// Get the skill content formatted for injection into system prompt
203    pub fn to_system_prompt(&self) -> String {
204        format!(
205            "# Skill: {}\n\n{}\n\n{}",
206            self.name, self.description, self.content
207        )
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn test_parse_skill() {
217        let content = r#"---
218name: test-skill
219description: A test skill
220allowed-tools: "read(*), grep(*)"
221kind: instruction
222---
223# Instructions
224
225You are a test assistant.
226"#;
227
228        let skill = Skill::parse(content).unwrap();
229        assert_eq!(skill.name, "test-skill");
230        assert_eq!(skill.description, "A test skill");
231        assert_eq!(skill.kind, SkillKind::Instruction);
232        assert!(skill.content.contains("You are a test assistant"));
233    }
234
235    #[test]
236    fn test_parse_tool_permission() {
237        let perm = ToolPermission::parse("Bash(gh issue view:*)").unwrap();
238        assert_eq!(perm.tool, "Bash");
239        assert_eq!(perm.pattern, "gh issue view:*");
240
241        let perm = ToolPermission::parse("read(*)").unwrap();
242        assert_eq!(perm.tool, "read");
243        assert_eq!(perm.pattern, "*");
244    }
245
246    #[test]
247    fn test_parse_allowed_tools() {
248        let skill = Skill {
249            name: "test".to_string(),
250            description: "test".to_string(),
251            allowed_tools: Some("read(*), grep(*), Bash(gh:*)".to_string()),
252            disable_model_invocation: false,
253            kind: SkillKind::Instruction,
254            content: String::new(),
255            tags: Vec::new(),
256            version: None,
257        };
258
259        let permissions = skill.parse_allowed_tools();
260        assert_eq!(permissions.len(), 3);
261    }
262
263    #[test]
264    fn test_is_tool_allowed() {
265        let skill = Skill {
266            name: "test".to_string(),
267            description: "test".to_string(),
268            allowed_tools: Some("read(*), grep(*)".to_string()),
269            disable_model_invocation: false,
270            kind: SkillKind::Instruction,
271            content: String::new(),
272            tags: Vec::new(),
273            version: None,
274        };
275
276        assert!(skill.is_tool_allowed("read"));
277        assert!(skill.is_tool_allowed("grep"));
278        assert!(!skill.is_tool_allowed("write"));
279    }
280}