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