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;
27mod registry;
28pub mod validator;
29
30pub use builtin::builtin_skills;
31pub use registry::SkillRegistry;
32pub use validator::{
33    DefaultSkillValidator, SkillValidationError, SkillValidator, ValidationErrorKind,
34};
35
36use serde::{de, Deserialize, Deserializer, Serialize};
37use std::collections::HashSet;
38use std::path::Path;
39
40/// Skill kind classification
41///
42/// Determines how the skill is used:
43/// - `Instruction`: Prompt/instruction content injected into system prompt
44/// - `Persona`: Session-level system prompt (bound at session creation, not injected globally)
45/// - `Tool`: Tool-like skill that provides specialized functionality (treated as instruction)
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
47#[serde(rename_all = "lowercase")]
48pub enum SkillKind {
49    #[default]
50    Instruction,
51    Persona,
52    Tool,
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(
105        default,
106        rename = "allowed-tools",
107        deserialize_with = "deserialize_allowed_tools"
108    )]
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 Claude-style comma-separated permissions, plus legacy
191        // whitespace-only tool lists such as "Read Write Edit Bash".
192        // A single Bash(...) permission may itself contain spaces, so try the
193        // canonical single-rule form before falling back to legacy splitting.
194        let parts: Vec<&str> = if allowed.contains(',') {
195            allowed.split(',').collect()
196        } else if ToolPermission::parse(allowed).is_some() {
197            vec![allowed.as_str()]
198        } else {
199            let parts: Vec<&str> = allowed.split_whitespace().collect();
200            if parts.len() > 1 {
201                tracing::warn!(
202                    skill = %self.name,
203                    allowed_tools = %allowed,
204                    "Legacy whitespace-separated allowed-tools is deprecated; use comma-separated permissions such as Read(*), Write(*), Bash(*) or a YAML list"
205                );
206            }
207            parts
208        };
209        for part in parts {
210            let part = part.trim();
211            if part.is_empty() {
212                continue;
213            }
214            if let Some(perm) = ToolPermission::parse(part) {
215                permissions.insert(perm);
216            } else {
217                permissions.insert(ToolPermission {
218                    tool: part.to_string(),
219                    pattern: "*".to_string(),
220                });
221            }
222        }
223
224        permissions
225    }
226
227    /// True when a skill still uses the legacy whitespace-only tool list.
228    pub fn uses_legacy_allowed_tools_syntax(&self) -> bool {
229        let Some(allowed) = &self.allowed_tools else {
230            return false;
231        };
232        !allowed.contains(',')
233            && ToolPermission::parse(allowed).is_none()
234            && allowed.split_whitespace().count() > 1
235    }
236
237    /// Check if a tool is allowed by this skill
238    pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
239        let permissions = self.parse_allowed_tools();
240
241        if permissions.is_empty() {
242            return false;
243        }
244
245        // Check if any permission matches
246        permissions
247            .iter()
248            .any(|perm| perm.tool.eq_ignore_ascii_case(tool_name) && perm.pattern == "*")
249    }
250
251    /// Get the skill content formatted for injection into system prompt
252    pub fn to_system_prompt(&self) -> String {
253        format!(
254            "# Skill: {}\n\n{}\n\n{}",
255            self.name, self.description, self.content
256        )
257    }
258}
259
260fn deserialize_allowed_tools<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
261where
262    D: Deserializer<'de>,
263{
264    let value = Option::<serde_yaml::Value>::deserialize(deserializer)?;
265    match value {
266        None | Some(serde_yaml::Value::Null) => Ok(None),
267        Some(serde_yaml::Value::String(s)) => Ok(Some(s)),
268        Some(serde_yaml::Value::Sequence(items)) => {
269            let mut tools = Vec::new();
270            for item in items {
271                match item {
272                    serde_yaml::Value::String(s) => tools.push(s),
273                    other => {
274                        return Err(de::Error::custom(format!(
275                            "allowed-tools list entries must be strings, got {other:?}"
276                        )));
277                    }
278                }
279            }
280            Ok(Some(tools.join(", ")))
281        }
282        Some(other) => Err(de::Error::custom(format!(
283            "allowed-tools must be a string or a list of strings, got {other:?}"
284        ))),
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn test_parse_skill() {
294        let content = r#"---
295name: test-skill
296description: A test skill
297allowed-tools: "read(*), grep(*)"
298kind: instruction
299---
300# Instructions
301
302You are a test assistant.
303"#;
304
305        let skill = Skill::parse(content).unwrap();
306        assert_eq!(skill.name, "test-skill");
307        assert_eq!(skill.description, "A test skill");
308        assert_eq!(skill.kind, SkillKind::Instruction);
309        assert!(skill.content.contains("You are a test assistant"));
310    }
311
312    #[test]
313    fn test_parse_tool_permission() {
314        let perm = ToolPermission::parse("Bash(gh issue view:*)").unwrap();
315        assert_eq!(perm.tool, "Bash");
316        assert_eq!(perm.pattern, "gh issue view:*");
317
318        let perm = ToolPermission::parse("read(*)").unwrap();
319        assert_eq!(perm.tool, "read");
320        assert_eq!(perm.pattern, "*");
321    }
322
323    #[test]
324    fn test_parse_allowed_tools() {
325        let skill = Skill {
326            name: "test".to_string(),
327            description: "test".to_string(),
328            allowed_tools: Some("read(*), grep(*), Bash(gh:*)".to_string()),
329            disable_model_invocation: false,
330            kind: SkillKind::Instruction,
331            content: String::new(),
332            tags: Vec::new(),
333            version: None,
334        };
335
336        let permissions = skill.parse_allowed_tools();
337        assert_eq!(permissions.len(), 3);
338    }
339
340    #[test]
341    fn test_parse_legacy_whitespace_allowed_tools() {
342        let skill = Skill {
343            name: "test".to_string(),
344            description: "test".to_string(),
345            allowed_tools: Some("Read Write Edit Bash".to_string()),
346            disable_model_invocation: false,
347            kind: SkillKind::Instruction,
348            content: String::new(),
349            tags: Vec::new(),
350            version: None,
351        };
352
353        let permissions = skill.parse_allowed_tools();
354        assert_eq!(permissions.len(), 4);
355        assert!(skill.uses_legacy_allowed_tools_syntax());
356        assert!(permissions
357            .iter()
358            .any(|perm| perm.tool == "Bash" && perm.pattern == "*"));
359    }
360
361    #[test]
362    fn test_parse_single_allowed_tool_with_spaces() {
363        let skill = Skill {
364            name: "test".to_string(),
365            description: "test".to_string(),
366            allowed_tools: Some("Bash(uv run skills analyze-ci:*)".to_string()),
367            disable_model_invocation: false,
368            kind: SkillKind::Instruction,
369            content: String::new(),
370            tags: Vec::new(),
371            version: None,
372        };
373
374        let permissions = skill.parse_allowed_tools();
375        assert_eq!(permissions.len(), 1);
376        assert!(permissions
377            .iter()
378            .any(|perm| { perm.tool == "Bash" && perm.pattern == "uv run skills analyze-ci:*" }));
379        assert!(!skill.uses_legacy_allowed_tools_syntax());
380    }
381
382    #[test]
383    fn test_parse_allowed_tools_yaml_list() {
384        let content = r#"---
385name: test-skill
386description: A test skill
387allowed-tools:
388  - Read
389  - Write
390  - Bash(uv run skills analyze-ci:*)
391---
392# Instructions
393"#;
394
395        let skill = Skill::parse(content).unwrap();
396        assert_eq!(
397            skill.allowed_tools.as_deref(),
398            Some("Read, Write, Bash(uv run skills analyze-ci:*)")
399        );
400        let permissions = skill.parse_allowed_tools();
401        assert_eq!(permissions.len(), 3);
402        assert!(permissions
403            .iter()
404            .any(|perm| perm.tool == "Read" && perm.pattern == "*"));
405    }
406
407    #[test]
408    fn test_is_tool_allowed() {
409        let skill = Skill {
410            name: "test".to_string(),
411            description: "test".to_string(),
412            allowed_tools: Some("read(*), grep(*)".to_string()),
413            disable_model_invocation: false,
414            kind: SkillKind::Instruction,
415            content: String::new(),
416            tags: Vec::new(),
417            version: None,
418        };
419
420        assert!(skill.is_tool_allowed("read"));
421        assert!(skill.is_tool_allowed("grep"));
422        assert!(!skill.is_tool_allowed("write"));
423    }
424
425    #[test]
426    fn test_omitted_allowed_tools_does_not_allow_tools() {
427        let skill = Skill {
428            name: "test".to_string(),
429            description: "test".to_string(),
430            allowed_tools: None,
431            disable_model_invocation: false,
432            kind: SkillKind::Instruction,
433            content: String::new(),
434            tags: Vec::new(),
435            version: None,
436        };
437
438        assert!(!skill.is_tool_allowed("read"));
439    }
440}