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