Skip to main content

tycode_core/skills/
mod.rs

1//! Skills system for extending agent capabilities.
2//!
3//! This module provides support for Claude Code Agent Skills - modular capabilities
4//! that extend the agent's functionality. Skills are discovered from (in priority order):
5//!
6//! 1. `~/.claude/skills/` (user-level Claude Code compatibility)
7//! 2. `~/.tycode/skills/` (user-level)
8//! 3. `.claude/skills/` in each workspace (project-level Claude Code compatibility)
9//! 4. `.tycode/skills/` in each workspace (project-level, highest priority)
10//!
11//! Later sources override earlier ones if the same skill name is found.
12//!
13//! Each skill is a directory containing a `SKILL.md` file with YAML frontmatter
14//! defining the skill's name and description, followed by markdown instructions.
15
16pub mod command;
17pub mod context;
18pub mod discovery;
19pub mod parser;
20pub mod prompt;
21pub mod tool;
22pub mod types;
23
24use std::path::PathBuf;
25use std::sync::Arc;
26
27use anyhow::Result;
28use serde_json::Value;
29
30use crate::module::ContextComponent;
31use crate::module::PromptComponent;
32use crate::module::{Module, SessionStateComponent, SlashCommand};
33use crate::settings::config::SkillsConfig;
34use crate::tools::r#trait::ToolExecutor;
35
36use command::{SkillInvokeCommand, SkillsListCommand};
37
38use context::{InvokedSkillsState, SkillsContextComponent};
39use discovery::SkillsManager;
40use prompt::SkillsPromptComponent;
41use tool::InvokeSkillTool;
42
43pub use context::InvokedSkill;
44pub use discovery::SkillsManager as Manager;
45pub use types::{SkillInstructions, SkillMetadata, SkillSource};
46
47/// Module that provides skills functionality.
48///
49/// SkillsModule bundles:
50/// - `SkillsPromptComponent` - Lists available skills in system prompt
51/// - `SkillsContextComponent` - Shows currently active/invoked skills
52/// - `InvokeSkillTool` - Tool for loading skill instructions
53pub struct SkillsModule {
54    manager: SkillsManager,
55    state: Arc<InvokedSkillsState>,
56}
57
58impl SkillsModule {
59    /// Creates a new SkillsModule by discovering skills from configured directories.
60    pub fn new(
61        workspace_roots: &[PathBuf],
62        home_dir: &std::path::Path,
63        config: &SkillsConfig,
64    ) -> Self {
65        let manager = SkillsManager::discover(workspace_roots, home_dir, config);
66        let state = Arc::new(InvokedSkillsState::new());
67        Self { manager, state }
68    }
69
70    /// Creates a SkillsModule with an existing manager (for testing).
71    pub fn with_manager(manager: SkillsManager) -> Self {
72        let state = Arc::new(InvokedSkillsState::new());
73        Self { manager, state }
74    }
75
76    /// Returns a reference to the skills manager.
77    pub fn manager(&self) -> &SkillsManager {
78        &self.manager
79    }
80
81    /// Returns a reference to the invoked skills state.
82    pub fn state(&self) -> &Arc<InvokedSkillsState> {
83        &self.state
84    }
85
86    /// Reloads skills from all directories.
87    pub fn reload(&self) {
88        self.manager.reload();
89    }
90
91    /// Returns metadata for all discovered skills.
92    pub fn get_all_skills(&self) -> Vec<SkillMetadata> {
93        self.manager.get_all_metadata()
94    }
95
96    /// Returns metadata for enabled skills only.
97    pub fn get_enabled_skills(&self) -> Vec<SkillMetadata> {
98        self.manager.get_enabled_metadata()
99    }
100
101    /// Gets a skill by name.
102    pub fn get_skill(&self, name: &str) -> Option<types::SkillInstructions> {
103        self.manager.get_skill(name)
104    }
105}
106
107impl Module for SkillsModule {
108    fn prompt_components(&self) -> Vec<Arc<dyn PromptComponent>> {
109        vec![Arc::new(SkillsPromptComponent::new(self.manager.clone()))]
110    }
111
112    fn context_components(&self) -> Vec<Arc<dyn ContextComponent>> {
113        vec![Arc::new(SkillsContextComponent::new(self.state.clone()))]
114    }
115
116    fn tools(&self) -> Vec<Arc<dyn ToolExecutor>> {
117        vec![Arc::new(InvokeSkillTool::new(
118            self.manager.clone(),
119            self.state.clone(),
120        ))]
121    }
122
123    fn session_state(&self) -> Option<Arc<dyn SessionStateComponent>> {
124        Some(Arc::new(SkillsSessionState {
125            state: self.state.clone(),
126        }))
127    }
128
129    fn slash_commands(&self) -> Vec<Arc<dyn SlashCommand>> {
130        vec![
131            Arc::new(SkillsListCommand::new(self.manager.clone())),
132            Arc::new(SkillInvokeCommand::new(self.manager.clone())),
133        ]
134    }
135}
136
137/// Session state component for persisting invoked skills.
138struct SkillsSessionState {
139    state: Arc<InvokedSkillsState>,
140}
141
142impl SessionStateComponent for SkillsSessionState {
143    fn key(&self) -> &str {
144        "skills"
145    }
146
147    fn save(&self) -> Value {
148        let invoked = self.state.get_invoked();
149        serde_json::json!({
150            "invoked": invoked.iter().map(|s| {
151                serde_json::json!({
152                    "name": s.name,
153                    "instructions": s.instructions,
154                })
155            }).collect::<Vec<_>>()
156        })
157    }
158
159    fn load(&self, state: Value) -> Result<()> {
160        self.state.clear();
161
162        if let Some(invoked) = state.get("invoked").and_then(|v| v.as_array()) {
163            for skill in invoked {
164                if let (Some(name), Some(instructions)) = (
165                    skill.get("name").and_then(|v| v.as_str()),
166                    skill.get("instructions").and_then(|v| v.as_str()),
167                ) {
168                    self.state
169                        .add_invoked(name.to_string(), instructions.to_string());
170                }
171            }
172        }
173
174        Ok(())
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use std::fs;
182    use tempfile::TempDir;
183
184    fn create_test_skill(dir: &std::path::Path, name: &str, description: &str) {
185        let skill_dir = dir.join(name);
186        fs::create_dir_all(&skill_dir).unwrap();
187
188        let content = format!(
189            r#"---
190name: {}
191description: {}
192---
193
194# {} Instructions
195
196Follow these steps.
197"#,
198            name, description, name
199        );
200
201        fs::write(skill_dir.join("SKILL.md"), content).unwrap();
202    }
203
204    #[test]
205    fn test_skills_module_creation() {
206        let temp = TempDir::new().unwrap();
207        let skills_dir = temp.path().join(".tycode").join("skills");
208        fs::create_dir_all(&skills_dir).unwrap();
209
210        create_test_skill(&skills_dir, "test-skill", "A test skill");
211
212        let config = SkillsConfig::default();
213        let module = SkillsModule::new(&[], temp.path(), &config);
214
215        assert_eq!(module.get_all_skills().len(), 1);
216    }
217
218    #[test]
219    fn test_module_provides_components() {
220        let temp = TempDir::new().unwrap();
221        let config = SkillsConfig::default();
222        let module = SkillsModule::new(&[], temp.path(), &config);
223
224        assert_eq!(module.prompt_components().len(), 1);
225        assert_eq!(module.context_components().len(), 1);
226        assert_eq!(module.tools().len(), 1);
227        assert!(module.session_state().is_some());
228    }
229
230    #[test]
231    fn test_session_state_save_load() {
232        let state = Arc::new(InvokedSkillsState::new());
233        state.add_invoked("test".to_string(), "instructions".to_string());
234
235        let session = SkillsSessionState {
236            state: state.clone(),
237        };
238
239        let saved = session.save();
240
241        // Clear and reload
242        state.clear();
243        assert_eq!(state.get_invoked().len(), 0);
244
245        session.load(saved).unwrap();
246        assert_eq!(state.get_invoked().len(), 1);
247        assert!(state.is_invoked("test"));
248    }
249}