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