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