tycode_core/skills/
mod.rs1pub 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
47pub struct SkillsModule {
54 manager: SkillsManager,
55 state: Arc<InvokedSkillsState>,
56}
57
58impl SkillsModule {
59 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 pub fn with_manager(manager: SkillsManager) -> Self {
72 let state = Arc::new(InvokedSkillsState::new());
73 Self { manager, state }
74 }
75
76 pub fn manager(&self) -> &SkillsManager {
78 &self.manager
79 }
80
81 pub fn state(&self) -> &Arc<InvokedSkillsState> {
83 &self.state
84 }
85
86 pub fn reload(&self) {
88 self.manager.reload();
89 }
90
91 pub fn get_all_skills(&self) -> Vec<SkillMetadata> {
93 self.manager.get_all_metadata()
94 }
95
96 pub fn get_enabled_skills(&self) -> Vec<SkillMetadata> {
98 self.manager.get_enabled_metadata()
99 }
100
101 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
137struct 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 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}