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