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 = crate::bounded_io::read_utf8_file_bounded(
167            path.as_ref(),
168            crate::bounded_io::MAX_SKILL_FILE_BYTES,
169        )?;
170        let mut skill =
171            Self::parse(&content).ok_or_else(|| anyhow::anyhow!("Failed to parse skill file"))?;
172
173        // Use filename as name if not specified
174        if skill.name.is_empty() {
175            if let Some(stem) = path.as_ref().file_stem() {
176                skill.name = stem.to_string_lossy().to_string();
177            }
178        }
179
180        Ok(skill)
181    }
182
183    /// Parse allowed tools into a set of tool permissions
184    ///
185    /// Claude Code format: "Bash(gh issue view:*), Bash(gh search:*)"
186    /// Returns patterns like: ["Bash:gh issue view:*", "Bash:gh search:*"]
187    pub fn parse_allowed_tools(&self) -> HashSet<ToolPermission> {
188        let mut permissions = HashSet::new();
189
190        let Some(allowed) = &self.allowed_tools else {
191            return permissions;
192        };
193
194        // Parse Claude-style comma-separated permissions, plus legacy
195        // whitespace-only tool lists such as "Read Write Edit Bash".
196        // A single Bash(...) permission may itself contain spaces, so try the
197        // canonical single-rule form before falling back to legacy splitting.
198        let parts: Vec<&str> = if allowed.contains(',') {
199            allowed.split(',').collect()
200        } else if ToolPermission::parse(allowed).is_some() {
201            vec![allowed.as_str()]
202        } else {
203            let parts: Vec<&str> = allowed.split_whitespace().collect();
204            if parts.len() > 1 {
205                tracing::warn!(
206                    skill = %self.name,
207                    allowed_tools = %allowed,
208                    "Legacy whitespace-separated allowed-tools is deprecated; use comma-separated permissions such as Read(*), Write(*), Bash(*) or a YAML list"
209                );
210            }
211            parts
212        };
213        for part in parts {
214            let part = part.trim();
215            if part.is_empty() {
216                continue;
217            }
218            if let Some(perm) = ToolPermission::parse(part) {
219                permissions.insert(perm);
220            } else {
221                permissions.insert(ToolPermission {
222                    tool: part.to_string(),
223                    pattern: "*".to_string(),
224                });
225            }
226        }
227
228        permissions
229    }
230
231    /// True when a skill still uses the legacy whitespace-only tool list.
232    pub fn uses_legacy_allowed_tools_syntax(&self) -> bool {
233        let Some(allowed) = &self.allowed_tools else {
234            return false;
235        };
236        !allowed.contains(',')
237            && ToolPermission::parse(allowed).is_none()
238            && allowed.split_whitespace().count() > 1
239    }
240
241    /// Check if a tool is allowed by this skill
242    pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
243        let permissions = self.parse_allowed_tools();
244
245        if permissions.is_empty() {
246            return false;
247        }
248
249        // Check if any permission matches
250        permissions.iter().any(|perm| {
251            (perm.tool == "*" || perm.tool.eq_ignore_ascii_case(tool_name)) && perm.pattern == "*"
252        })
253    }
254
255    /// Get the skill content formatted for injection into system prompt
256    pub fn to_system_prompt(&self) -> String {
257        format!(
258            "# Skill: {}\n\n{}\n\n{}",
259            self.name, self.description, self.content
260        )
261    }
262}
263
264fn deserialize_allowed_tools<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
265where
266    D: Deserializer<'de>,
267{
268    let value = Option::<serde_yaml::Value>::deserialize(deserializer)?;
269    match value {
270        None | Some(serde_yaml::Value::Null) => Ok(None),
271        Some(serde_yaml::Value::String(s)) => Ok(Some(s)),
272        Some(serde_yaml::Value::Sequence(items)) => {
273            let mut tools = Vec::new();
274            for item in items {
275                match item {
276                    serde_yaml::Value::String(s) => tools.push(s),
277                    other => {
278                        return Err(de::Error::custom(format!(
279                            "allowed-tools list entries must be strings, got {other:?}"
280                        )));
281                    }
282                }
283            }
284            Ok(Some(tools.join(", ")))
285        }
286        Some(other) => Err(de::Error::custom(format!(
287            "allowed-tools must be a string or a list of strings, got {other:?}"
288        ))),
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn test_parse_skill() {
298        let content = r#"---
299name: test-skill
300description: A test skill
301allowed-tools: "read(*), grep(*)"
302kind: instruction
303---
304# Instructions
305
306You are a test assistant.
307"#;
308
309        let skill = Skill::parse(content).unwrap();
310        assert_eq!(skill.name, "test-skill");
311        assert_eq!(skill.description, "A test skill");
312        assert_eq!(skill.kind, SkillKind::Instruction);
313        assert!(skill.content.contains("You are a test assistant"));
314    }
315
316    #[test]
317    fn test_parse_tool_permission() {
318        let perm = ToolPermission::parse("Bash(gh issue view:*)").unwrap();
319        assert_eq!(perm.tool, "Bash");
320        assert_eq!(perm.pattern, "gh issue view:*");
321
322        let perm = ToolPermission::parse("read(*)").unwrap();
323        assert_eq!(perm.tool, "read");
324        assert_eq!(perm.pattern, "*");
325    }
326
327    #[test]
328    fn test_parse_allowed_tools() {
329        let skill = Skill {
330            name: "test".to_string(),
331            description: "test".to_string(),
332            allowed_tools: Some("read(*), grep(*), Bash(gh:*)".to_string()),
333            disable_model_invocation: false,
334            kind: SkillKind::Instruction,
335            content: String::new(),
336            tags: Vec::new(),
337            version: None,
338        };
339
340        let permissions = skill.parse_allowed_tools();
341        assert_eq!(permissions.len(), 3);
342    }
343
344    #[test]
345    fn test_parse_legacy_whitespace_allowed_tools() {
346        let skill = Skill {
347            name: "test".to_string(),
348            description: "test".to_string(),
349            allowed_tools: Some("Read Write Edit Bash".to_string()),
350            disable_model_invocation: false,
351            kind: SkillKind::Instruction,
352            content: String::new(),
353            tags: Vec::new(),
354            version: None,
355        };
356
357        let permissions = skill.parse_allowed_tools();
358        assert_eq!(permissions.len(), 4);
359        assert!(skill.uses_legacy_allowed_tools_syntax());
360        assert!(permissions
361            .iter()
362            .any(|perm| perm.tool == "Bash" && perm.pattern == "*"));
363    }
364
365    #[test]
366    fn test_parse_single_allowed_tool_with_spaces() {
367        let skill = Skill {
368            name: "test".to_string(),
369            description: "test".to_string(),
370            allowed_tools: Some("Bash(uv run skills analyze-ci:*)".to_string()),
371            disable_model_invocation: false,
372            kind: SkillKind::Instruction,
373            content: String::new(),
374            tags: Vec::new(),
375            version: None,
376        };
377
378        let permissions = skill.parse_allowed_tools();
379        assert_eq!(permissions.len(), 1);
380        assert!(permissions
381            .iter()
382            .any(|perm| { perm.tool == "Bash" && perm.pattern == "uv run skills analyze-ci:*" }));
383        assert!(!skill.uses_legacy_allowed_tools_syntax());
384    }
385
386    #[test]
387    fn test_parse_allowed_tools_yaml_list() {
388        let content = r#"---
389name: test-skill
390description: A test skill
391allowed-tools:
392  - Read
393  - Write
394  - Bash(uv run skills analyze-ci:*)
395---
396# Instructions
397"#;
398
399        let skill = Skill::parse(content).unwrap();
400        assert_eq!(
401            skill.allowed_tools.as_deref(),
402            Some("Read, Write, Bash(uv run skills analyze-ci:*)")
403        );
404        let permissions = skill.parse_allowed_tools();
405        assert_eq!(permissions.len(), 3);
406        assert!(permissions
407            .iter()
408            .any(|perm| perm.tool == "Read" && perm.pattern == "*"));
409    }
410
411    #[test]
412    fn test_is_tool_allowed() {
413        let skill = Skill {
414            name: "test".to_string(),
415            description: "test".to_string(),
416            allowed_tools: Some("read(*), grep(*)".to_string()),
417            disable_model_invocation: false,
418            kind: SkillKind::Instruction,
419            content: String::new(),
420            tags: Vec::new(),
421            version: None,
422        };
423
424        assert!(skill.is_tool_allowed("read"));
425        assert!(skill.is_tool_allowed("grep"));
426        assert!(!skill.is_tool_allowed("write"));
427    }
428
429    #[test]
430    fn test_wildcard_allowed_tools_allows_any_tool() {
431        let skill = Skill {
432            name: "test".to_string(),
433            description: "test".to_string(),
434            allowed_tools: Some("*".to_string()),
435            disable_model_invocation: false,
436            kind: SkillKind::Instruction,
437            content: String::new(),
438            tags: Vec::new(),
439            version: None,
440        };
441
442        assert!(skill.is_tool_allowed("read"));
443        assert!(skill.is_tool_allowed("bash"));
444        assert!(skill.is_tool_allowed("parallel_task"));
445    }
446
447    #[test]
448    fn test_omitted_allowed_tools_does_not_allow_tools() {
449        let skill = Skill {
450            name: "test".to_string(),
451            description: "test".to_string(),
452            allowed_tools: None,
453            disable_model_invocation: false,
454            kind: SkillKind::Instruction,
455            content: String::new(),
456            tags: Vec::new(),
457            version: None,
458        };
459
460        assert!(!skill.is_tool_allowed("read"));
461    }
462}