Skip to main content

vtcode_core/exec/
skill_manager.rs

1#![allow(
2    clippy::let_underscore_must_use,
3    reason = "Intentional compatibility, platform, or test-only suppression."
4)]
5//! Skill persistence and management for reusable code functions.
6//!
7//! Agents can save working code implementations as reusable "skills" in the
8//! `.agents/skills/` directory. Legacy `.vtcode/skills/` locations remain
9//! readable for backward compatibility. Each skill includes:
10//! - Function implementation (Python or JavaScript)
11//! - `SKILL.md` documentation
12//! - Input/output type hints
13//! - Usage examples
14//!
15//! Skills can be loaded across conversations and shared with other agents.
16
17use crate::exec::ToolDependency;
18use crate::utils::error_messages::*;
19use crate::utils::file_utils::{ensure_dir_exists, read_file_with_context, write_file_with_context};
20use anyhow::{Context, Result, anyhow};
21use serde::{Deserialize, Serialize};
22use std::fmt::Write;
23use std::path::{Path, PathBuf};
24use tracing::{debug, info};
25
26/// Metadata about a saved skill.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct SkillMetadata {
29    /// Skill name (snake_case)
30    pub name: String,
31    /// Brief description
32    pub description: String,
33    /// Programming language (python3 or javascript)
34    pub language: String,
35    /// Input parameters documentation
36    pub inputs: Vec<ParameterDoc>,
37    /// Output documentation
38    pub output: String,
39    /// Usage examples
40    pub examples: Vec<String>,
41    /// Tags for searching/categorizing
42    pub tags: Vec<String>,
43    /// When the skill was created (ISO 8601)
44    pub created_at: String,
45    /// When the skill was last modified (ISO 8601)
46    pub modified_at: String,
47    /// Tool dependencies with version constraints
48    #[serde(default)]
49    pub tool_dependencies: Vec<ToolDependency>,
50}
51
52/// Parameter documentation for a skill.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct ParameterDoc {
55    /// Parameter name.
56    pub name: String,
57    /// Parameter type (e.g., "str", "int", "list").
58    pub r#type: String,
59    /// Human-readable description of the parameter.
60    pub description: String,
61    /// Whether this parameter is required.
62    pub required: bool,
63}
64
65/// A saved skill with code and metadata.
66#[derive(Debug, Clone)]
67pub struct Skill {
68    /// Descriptive metadata for the skill.
69    pub metadata: SkillMetadata,
70    /// The skill implementation source code.
71    pub code: String,
72}
73
74#[derive(Debug, Clone, Copy)]
75enum SkillOrigin {
76    Primary,
77    Legacy,
78}
79
80#[derive(Debug, Clone)]
81struct SkillEntry {
82    metadata: SkillMetadata,
83    origin: SkillOrigin,
84}
85
86/// Manager for skill storage and retrieval.
87#[derive(Clone)]
88pub struct SkillManager {
89    skills_dir: PathBuf,
90    legacy_skills_dir: PathBuf,
91}
92
93impl SkillManager {
94    /// Create a new skill manager.
95    pub fn new(workspace_root: &Path) -> Self {
96        Self {
97            skills_dir: workspace_root.join(".agents").join("skills"),
98            legacy_skills_dir: workspace_root.join(".vtcode").join("skills"),
99        }
100    }
101
102    /// Save a skill to disk.
103    ///
104    /// # Arguments
105    /// * `skill` - The skill to save
106    /// * `code` - The skill implementation code
107    pub async fn save_skill(&self, skill: Skill) -> Result<()> {
108        // Create skills directory
109        ensure_dir_exists(&self.skills_dir).await.context(ERR_CREATE_SKILLS_DIR)?;
110
111        let skill_dir = self.skills_dir.join(&skill.metadata.name);
112        ensure_dir_exists(&skill_dir).await.context(ERR_CREATE_SKILL_DIR)?;
113
114        // Save code file
115        let code_filename = match skill.metadata.language.as_str() {
116            "python3" | "python" => "skill.py",
117            "javascript" | "js" => "skill.js",
118            lang => return Err(anyhow!("unsupported language: {lang}")),
119        };
120
121        let code_path = skill_dir.join(code_filename);
122        write_file_with_context(&code_path, &skill.code, "skill code")
123            .await
124            .context(ERR_WRITE_SKILL_CODE)?;
125
126        // Save metadata
127        let metadata_path = skill_dir.join("skill.json");
128        let metadata_json = serde_json::to_string_pretty(&skill.metadata).context(ERR_SERIALIZE_METADATA)?;
129        write_file_with_context(&metadata_path, &metadata_json, "skill metadata")
130            .await
131            .context(ERR_WRITE_SKILL_METADATA)?;
132
133        // Save documentation
134        let doc_path = skill_dir.join("SKILL.md");
135        let documentation = Self::generate_markdown(&skill);
136        write_file_with_context(&doc_path, &documentation, "skill documentation")
137            .await
138            .context(ERR_WRITE_SKILL_DOCS)?;
139
140        info!(
141            skill_name = %skill.metadata.name,
142            skill_dir = ?skill_dir,
143            "Skill saved successfully"
144        );
145
146        // Regenerate index after saving new skill
147        let _ = self.generate_index().await;
148
149        Ok(())
150    }
151
152    /// Load a skill by name.
153    pub async fn load_skill(&self, name: &str) -> Result<Skill> {
154        let skill_dir = self.skills_dir.join(name);
155        let legacy_skill_dir = self.legacy_skills_dir.join(name);
156
157        // Try to find code file (python or javascript)
158        let (code_path, language, skill_root) =
159            if tokio::fs::try_exists(skill_dir.join("skill.py")).await.unwrap_or(false) {
160                (skill_dir.join("skill.py"), "python3", skill_dir)
161            } else if tokio::fs::try_exists(skill_dir.join("skill.js")).await.unwrap_or(false) {
162                (skill_dir.join("skill.js"), "javascript", skill_dir)
163            } else if tokio::fs::try_exists(legacy_skill_dir.join("skill.py")).await.unwrap_or(false) {
164                (legacy_skill_dir.join("skill.py"), "python3", legacy_skill_dir)
165            } else if tokio::fs::try_exists(legacy_skill_dir.join("skill.js")).await.unwrap_or(false) {
166                (legacy_skill_dir.join("skill.js"), "javascript", legacy_skill_dir)
167            } else {
168                return Err(anyhow!("skill '{name}' not found"));
169            };
170
171        // Load code
172        let code = read_file_with_context(&code_path, "skill code")
173            .await
174            .context(ERR_READ_SKILL_CODE)?;
175
176        // Load metadata
177        let metadata_path = skill_root.join("skill.json");
178        let metadata_json = read_file_with_context(&metadata_path, "skill metadata")
179            .await
180            .context(ERR_READ_SKILL_METADATA)?;
181        let metadata: SkillMetadata = serde_json::from_str(&metadata_json).context(ERR_PARSE_SKILL_METADATA)?;
182
183        // Ensure language matches
184        if metadata.language != language {
185            return Err(anyhow!("skill language mismatch: expected {}, found {}", metadata.language, language));
186        }
187
188        debug!(
189            skill_name = %name,
190            language = %language,
191            "Skill loaded successfully"
192        );
193
194        Ok(Skill { metadata, code })
195    }
196
197    /// List all available skills.
198    pub async fn list_skills(&self) -> Result<Vec<SkillMetadata>> {
199        Ok(self
200            .list_skills_with_origin()
201            .await?
202            .into_iter()
203            .map(|entry| entry.metadata)
204            .collect())
205    }
206
207    /// Search skills by tag or keyword.
208    pub async fn search_skills(&self, query: &str) -> Result<Vec<SkillMetadata>> {
209        let skills = self.list_skills().await?;
210        let query_lower = query.to_lowercase();
211
212        Ok(skills
213            .into_iter()
214            .filter(|skill| {
215                skill.name.to_lowercase().contains(&query_lower)
216                    || skill.description.to_lowercase().contains(&query_lower)
217                    || skill.tags.iter().any(|tag| tag.to_lowercase().contains(&query_lower))
218            })
219            .collect())
220    }
221
222    /// Delete a skill.
223    pub async fn delete_skill(&self, name: &str) -> Result<()> {
224        let skill_dir = self.skills_dir.join(name);
225        let legacy_skill_dir = self.legacy_skills_dir.join(name);
226        if tokio::fs::try_exists(&skill_dir).await.unwrap_or(false) {
227            tokio::fs::remove_dir_all(&skill_dir).await.context(ERR_DELETE_SKILL)?;
228        } else if tokio::fs::try_exists(&legacy_skill_dir).await.unwrap_or(false) {
229            tokio::fs::remove_dir_all(&legacy_skill_dir).await.context(ERR_DELETE_SKILL)?;
230        } else {
231            return Err(anyhow!("skill '{name}' not found"));
232        }
233
234        info!(skill_name = %name, "Skill deleted successfully");
235
236        // Regenerate index after deletion
237        let _ = self.generate_index().await;
238
239        Ok(())
240    }
241
242    /// Generate INDEX.md with all skill names and descriptions
243    ///
244    /// This implements dynamic context discovery: agents can read the index
245    /// to discover available skills, then load specific skills as needed.
246    /// This is more token-efficient than loading all skill definitions.
247    pub async fn generate_index(&self) -> Result<PathBuf> {
248        let skills = self.list_skills_with_origin().await?;
249
250        let mut content = String::new();
251        content.push_str("# Skills Index\n\n");
252        content.push_str("This file lists all available skills for dynamic discovery.\n");
253        content.push_str("Use `read_file` on individual skill directories for full documentation.\n\n");
254        if skills.iter().any(|entry| matches!(entry.origin, SkillOrigin::Legacy)) {
255            content.push_str(
256                "Legacy skills from `.vtcode/skills/` are included but deprecated. Move them to `.agents/skills/`.\n\n",
257            );
258        }
259
260        if skills.is_empty() {
261            content.push_str("*No skills available yet.*\n\n");
262            content.push_str("Create skills using the `save_skill` tool.\n");
263        } else {
264            content.push_str("## Available Skills\n\n");
265            content.push_str("| Name | Language | Description | Tags |\n");
266            content.push_str("|------|----------|-------------|------|\n");
267
268            for entry in &skills {
269                let skill = &entry.metadata;
270                let tags = if skill.tags.is_empty() {
271                    "-".to_string()
272                } else {
273                    skill.tags.join(", ")
274                };
275                let desc = skill.description.replace('|', "\\|");
276                let _ = writeln!(content, "| `{}` | {} | {} | {} |", skill.name, skill.language, desc, tags);
277            }
278
279            content.push_str("\n## Quick Reference\n\n");
280            for entry in &skills {
281                let skill = &entry.metadata;
282                let base_path = match entry.origin {
283                    SkillOrigin::Primary => ".agents/skills",
284                    SkillOrigin::Legacy => ".vtcode/skills",
285                };
286                let _ = writeln!(content, "### {}\n", skill.name);
287                let _ = writeln!(content, "{}\n", skill.description);
288                let _ = writeln!(
289                    content,
290                    "- **Language**: {}\n- **Path**: `{}/{}/SKILL.md`\n",
291                    skill.language, base_path, skill.name
292                );
293            }
294        }
295
296        content.push_str("\n---\n");
297        content.push_str("*Generated automatically. Do not edit manually.*\n");
298
299        // Ensure directory exists
300        ensure_dir_exists(&self.skills_dir).await.context(ERR_CREATE_SKILLS_DIR)?;
301
302        let index_path = self.skills_dir.join("INDEX.md");
303        write_file_with_context(&index_path, &content, "skills index")
304            .await
305            .with_context(|| format!("Failed to write skills index: {}", index_path.display()))?;
306
307        info!(
308            skills_count = skills.len(),
309            path = %index_path.display(),
310            "Generated skills INDEX.md"
311        );
312
313        Ok(index_path)
314    }
315
316    /// Get the path to the INDEX.md file
317    pub fn index_path(&self) -> PathBuf {
318        self.skills_dir.join("INDEX.md")
319    }
320
321    async fn list_skills_with_origin(&self) -> Result<Vec<SkillEntry>> {
322        let mut entries = Vec::new();
323        let mut seen = hashbrown::HashSet::new();
324
325        let primary = self.read_skills_from_dir(&self.skills_dir).await.context(ERR_READ_SKILLS_DIR)?;
326        for metadata in primary {
327            seen.insert(metadata.name.clone());
328            entries.push(SkillEntry { metadata, origin: SkillOrigin::Primary });
329        }
330
331        let legacy = self
332            .read_skills_from_dir(&self.legacy_skills_dir)
333            .await
334            .context(ERR_READ_SKILLS_DIR)?;
335        for metadata in legacy {
336            if seen.contains(&metadata.name) {
337                continue;
338            }
339            entries.push(SkillEntry { metadata, origin: SkillOrigin::Legacy });
340        }
341
342        Ok(entries)
343    }
344
345    async fn read_skills_from_dir(&self, dir: &Path) -> Result<Vec<SkillMetadata>> {
346        if !tokio::fs::try_exists(dir).await.unwrap_or(false) {
347            return Ok(Vec::new());
348        }
349
350        // Pre-allocate skills vector - typically 10-20 skills per directory
351        let mut skills = Vec::with_capacity(16);
352        let mut dir_entries = tokio::fs::read_dir(dir).await.context(ERR_READ_SKILLS_DIR)?;
353
354        while let Some(entry) = dir_entries.next_entry().await.context(ERR_READ_DIR_ENTRY)? {
355            let path = entry.path();
356            if path.is_dir() {
357                let metadata_path = path.join("skill.json");
358                if let Ok(metadata_json) = read_file_with_context(&metadata_path, "skill metadata").await
359                    && let Ok(metadata) = serde_json::from_str::<SkillMetadata>(&metadata_json)
360                {
361                    skills.push(metadata);
362                }
363            }
364        }
365
366        Ok(skills)
367    }
368
369    /// Check if a skill is compatible with given tool versions
370    pub async fn check_skill_compatibility(
371        &self,
372        name: &str,
373        tool_versions: hashbrown::HashMap<String, crate::exec::ToolVersion>,
374    ) -> Result<crate::exec::CompatibilityReport> {
375        let skill = self.load_skill(name).await?;
376        let checker = crate::exec::SkillCompatibilityChecker::new(
377            skill.metadata.name,
378            skill.metadata.tool_dependencies,
379            tool_versions,
380        );
381
382        checker.check_compatibility()
383    }
384
385    /// Generate Markdown documentation for a skill.
386    fn generate_markdown(skill: &Skill) -> String {
387        // Reserve an estimated capacity to avoid multiple reallocations.
388        let mut md = String::with_capacity(1024 + skill.code.len() + skill.metadata.description.len());
389
390        let _ = writeln!(md, "# {}\n", skill.metadata.name);
391        let _ = writeln!(md, "{}\n", skill.metadata.description);
392
393        if !skill.metadata.tags.is_empty() {
394            md.push_str("**Tags:** ");
395            md.push_str(&skill.metadata.tags.join(", "));
396            md.push_str("\n\n");
397        }
398
399        md.push_str("## Language\n\n");
400        let _ = writeln!(md, "`{}`\n", skill.metadata.language);
401
402        if !skill.metadata.inputs.is_empty() {
403            md.push_str("## Inputs\n\n");
404            for param in &skill.metadata.inputs {
405                let required = if param.required { "required" } else { "optional" };
406                let _ = writeln!(
407                    md,
408                    "- `{name}` ({type}, {required}): {desc}",
409                    name = param.name,
410                    r#type = param.r#type,
411                    desc = param.description
412                );
413            }
414            md.push('\n');
415        }
416
417        md.push_str("## Output\n\n");
418        let _ = writeln!(md, "{}\n", skill.metadata.output);
419
420        if !skill.metadata.examples.is_empty() {
421            md.push_str("## Examples\n\n");
422            for (i, example) in skill.metadata.examples.iter().enumerate() {
423                if i > 0 {
424                    md.push('\n');
425                }
426                md.push_str("```\n");
427                md.push_str(example);
428                md.push_str("\n```\n");
429            }
430        }
431
432        md.push('\n');
433        md.push_str("## Code\n\n");
434        md.push_str("```");
435        md.push_str(&skill.metadata.language);
436        md.push('\n');
437        md.push_str(&skill.code);
438        md.push_str("\n```\n");
439
440        md
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn test_skill_metadata_serialization() {
450        let metadata = SkillMetadata {
451            name: "filter_files".into(),
452            description: "Filter files by pattern".into(),
453            language: "python3".into(),
454            inputs: vec![ParameterDoc {
455                name: "pattern".into(),
456                r#type: "str".into(),
457                description: "File pattern to match".into(),
458                required: true,
459            }],
460            output: "List of matching filenames".into(),
461            examples: vec!["filter_files(pattern='*.rs')".into()],
462            tags: vec!["files".into(), "filtering".into()],
463            created_at: "2025-01-01T00:00:00Z".into(),
464            modified_at: "2025-01-01T00:00:00Z".into(),
465            tool_dependencies: vec![],
466        };
467
468        let json = serde_json::to_string(&metadata).expect("Skill metadata should serialize");
469        let deserialized: SkillMetadata =
470            serde_json::from_str(&json).expect("Serialized skill metadata should deserialize");
471        assert_eq!(deserialized.name, metadata.name);
472    }
473}