1#![allow(
2 clippy::let_underscore_must_use,
3 reason = "Intentional compatibility, platform, or test-only suppression."
4)]
5use 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#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct SkillMetadata {
29 pub name: String,
31 pub description: String,
33 pub language: String,
35 pub inputs: Vec<ParameterDoc>,
37 pub output: String,
39 pub examples: Vec<String>,
41 pub tags: Vec<String>,
43 pub created_at: String,
45 pub modified_at: String,
47 #[serde(default)]
49 pub tool_dependencies: Vec<ToolDependency>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct ParameterDoc {
55 pub name: String,
57 pub r#type: String,
59 pub description: String,
61 pub required: bool,
63}
64
65#[derive(Debug, Clone)]
67pub struct Skill {
68 pub metadata: SkillMetadata,
70 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#[derive(Clone)]
88pub struct SkillManager {
89 skills_dir: PathBuf,
90 legacy_skills_dir: PathBuf,
91}
92
93impl SkillManager {
94 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 pub async fn save_skill(&self, skill: Skill) -> Result<()> {
108 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 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 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 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 let _ = self.generate_index().await;
148
149 Ok(())
150 }
151
152 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 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 let code = read_file_with_context(&code_path, "skill code")
173 .await
174 .context(ERR_READ_SKILL_CODE)?;
175
176 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 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 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 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 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 let _ = self.generate_index().await;
238
239 Ok(())
240 }
241
242 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_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 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 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 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 fn generate_markdown(skill: &Skill) -> String {
387 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}