1#![allow(clippy::let_underscore_must_use)]
2use 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#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SkillMetadata {
28 pub name: String,
30 pub description: String,
32 pub language: String,
34 pub inputs: Vec<ParameterDoc>,
36 pub output: String,
38 pub examples: Vec<String>,
40 pub tags: Vec<String>,
42 pub created_at: String,
44 pub modified_at: String,
46 #[serde(default)]
48 pub tool_dependencies: Vec<ToolDependency>,
49}
50
51#[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#[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#[derive(Clone)]
81pub struct SkillManager {
82 skills_dir: PathBuf,
83 legacy_skills_dir: PathBuf,
84}
85
86impl SkillManager {
87 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 pub async fn save_skill(&self, skill: Skill) -> Result<()> {
101 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 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 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 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 let _ = self.generate_index().await;
146
147 Ok(())
148 }
149
150 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 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 let code = read_file_with_context(&code_path, "skill code")
190 .await
191 .context(ERR_READ_SKILL_CODE)?;
192
193 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 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 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 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 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 let _ = self.generate_index().await;
270
271 Ok(())
272 }
273
274 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_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 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 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 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 fn generate_markdown(skill: &Skill) -> String {
442 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}