Skip to main content

vtcode_core/exec/
skill_manager.rs

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