1use anyhow::{Context, Result, anyhow};
7use std::fs;
8use std::io::Write;
9use std::path::{Path, PathBuf};
10use tracing::{debug, info};
11
12pub const EXAMPLE_SCRIPT: &str = r#"#!/usr/bin/env python3
14"""
15Example script for {skill_name}
16
17This demonstrates how to include executable scripts in a skill.
18Scripts provide deterministic, token-efficient operations.
19"""
20
21import sys
22
23def main():
24 print("Example script for {skill_name}")
25 print("Replace with actual functionality")
26
27if __name__ == "__main__":
28 main()
29"#;
30
31pub const EXAMPLE_REFERENCE: &str = r#"# {skill_title} API Reference
33
34This is an example reference document that VT Code can read as needed.
35
36## Overview
37
38Reference docs are ideal for:
39- Comprehensive API documentation
40- Detailed workflow guides
41- Complex multi-step processes
42- Content that's only needed for specific use cases
43
44## Usage
45
46Include specific API endpoints, schemas, or detailed instructions here.
47"#;
48
49pub type SkillFrontmatter = crate::skills::manifest::SkillYaml;
51
52pub struct SkillAuthor {
54 workspace_root: PathBuf,
55}
56
57impl SkillAuthor {
58 pub fn new(workspace_root: PathBuf) -> Self {
59 Self { workspace_root }
60 }
61
62 pub fn create_skill(&self, skill_name: &str, output_dir: Option<PathBuf>) -> Result<PathBuf> {
65 self.validate_skill_name(skill_name)?;
67
68 let skills_dir =
70 output_dir.unwrap_or_else(|| self.workspace_root.join(".agents").join("skills"));
71 let skill_dir = skills_dir.join(skill_name);
72
73 if skill_dir.exists() {
75 return Err(anyhow!(
76 "Skill directory already exists: {}",
77 skill_dir.display()
78 ));
79 }
80
81 fs::create_dir_all(&skill_dir).with_context(|| {
83 format!(
84 "failed to create skill directory at {}",
85 skill_dir.display()
86 )
87 })?;
88 info!("Created skill directory: {}", skill_dir.display());
89
90 let skill_content = crate::skills::manifest::generate_skill_template(
92 skill_name,
93 "Describe the workflow, routing triggers, and expected artifact for this skill.",
94 );
95
96 let skill_md_path = skill_dir.join("SKILL.md");
97 fs::write(&skill_md_path, skill_content)
98 .with_context(|| format!("failed to write {}", skill_md_path.display()))?;
99 info!("Created SKILL.md");
100
101 let skill_title = skill_name
103 .split('-')
104 .filter(|word| !word.is_empty())
105 .map(|word| {
106 let mut chars = word.chars();
107 match chars.next() {
108 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
109 None => String::new(),
110 }
111 })
112 .collect::<Vec<_>>()
113 .join(" ");
114 self.create_scripts_dir(&skill_dir, skill_name)?;
115 self.create_references_dir(&skill_dir, &skill_title)?;
116 self.create_assets_dir(&skill_dir)?;
117
118 Ok(skill_dir)
119 }
120
121 pub fn validate_skill(&self, skill_dir: &Path) -> Result<ValidationReport> {
123 let mut report = ValidationReport::new(skill_dir.to_path_buf());
124
125 let skill_md = skill_dir.join("SKILL.md");
127 if !skill_md.exists() {
128 report.errors.push("SKILL.md not found".to_string());
129 return Ok(report);
130 }
131
132 let content = fs::read_to_string(&skill_md)
134 .with_context(|| format!("failed to read {}", skill_md.display()))?;
135
136 if !content.starts_with("---") {
138 report.errors.push("No YAML frontmatter found".to_string());
139 return Ok(report);
140 }
141
142 let parts: Vec<&str> = content.splitn(3, "---").collect();
143 if parts.len() < 3 {
144 report.errors.push("Invalid frontmatter format".to_string());
145 return Ok(report);
146 }
147
148 let frontmatter: SkillFrontmatter = match serde_saphyr::from_str(parts[1].trim()) {
150 Ok(frontmatter) => frontmatter,
151 Err(error) => {
152 report
153 .errors
154 .push(format!("Invalid YAML frontmatter: {}", error));
155 return Ok(report);
156 }
157 };
158
159 let raw_frontmatter: serde_json::Value =
161 serde_saphyr::from_str(parts[1].trim()).map_err(|e| anyhow!("Invalid YAML: {}", e))?;
162 if let serde_json::Value::Object(map) = raw_frontmatter {
163 for key in map.keys() {
164 if !crate::skills::manifest::SUPPORTED_FRONTMATTER_KEYS.contains(&key.as_str()) {
165 report.errors.push(format!(
166 "Unexpected property '{}' in frontmatter. Allowed: {}",
167 key,
168 crate::skills::manifest::SUPPORTED_FRONTMATTER_KEYS.join(", ")
169 ));
170 }
171 }
172 }
173
174 let name = frontmatter.name.trim();
176 if name.is_empty() {
177 report
178 .errors
179 .push("Skill name must not be empty".to_string());
180 } else if !self.is_valid_skill_name(name) {
181 let mut reasons = Vec::new();
182 if name.chars().any(|c| c.is_ascii_uppercase()) {
183 reasons.push("must be lowercase");
184 }
185 if name.contains('_') {
186 reasons.push("no underscores allowed");
187 }
188 if name.starts_with('-') || name.ends_with('-') {
189 reasons.push("cannot start or end with hyphen");
190 }
191 if name.contains("--") {
192 reasons.push("no consecutive hyphens");
193 }
194 if name.len() > 64 {
195 reasons.push("max 64 characters");
196 }
197 if name.contains("anthropic") || name.contains("claude") || name.contains("vtcode") {
198 reasons.push("reserved words not allowed");
199 }
200 report.errors.push(format!(
201 "Invalid skill name '{}': {}",
202 name,
203 reasons.join(", ")
204 ));
205 }
206
207 let description = frontmatter.description.trim();
209 if description.is_empty() {
210 report.errors.push("Description is required".to_string());
211 } else {
212 if description.contains("[TODO") {
213 report
214 .warnings
215 .push("Description contains TODO placeholder".to_string());
216 }
217 if description.contains('<') || description.contains('>') {
218 report
219 .errors
220 .push("Description cannot contain angle brackets (< or >)".to_string());
221 }
222 if description.len() > 1024 {
223 report.errors.push(format!(
224 "Description is too long ({} characters). Maximum is 1024 characters.",
225 description.len()
226 ));
227 }
228 }
229
230 let body = parts[2].trim();
232 if body.is_empty() {
233 report.warnings.push("SKILL.md body is empty".to_string());
234 }
235
236 if body.len() > 50000 {
237 report.warnings.push(
238 "SKILL.md body is very long (>50k chars). Consider splitting into reference files."
239 .to_string(),
240 );
241 }
242
243 self.validate_structure(skill_dir, &mut report)?;
245
246 report.valid = report.errors.is_empty();
247 Ok(report)
248 }
249
250 pub fn package_skill(&self, skill_dir: &Path, output_dir: Option<PathBuf>) -> Result<PathBuf> {
252 let report = self.validate_skill(skill_dir)?;
254 if !report.valid {
255 return Err(anyhow!("Skill validation failed:\n{}", report.format()));
256 }
257
258 let skill_name = skill_dir
260 .file_name()
261 .and_then(|n| n.to_str())
262 .ok_or_else(|| anyhow!("Invalid skill directory name"))?;
263
264 let output_dir = output_dir.unwrap_or_else(|| self.workspace_root.clone());
265 let output_file = output_dir.join(format!("{}.skill", skill_name));
266
267 use zip::ZipWriter;
269
270 let file = fs::File::create(&output_file).with_context(|| {
271 format!("failed to create output file at {}", output_file.display())
272 })?;
273 let mut zip = ZipWriter::new(file);
274
275 add_directory_to_zip(&mut zip, skill_dir, skill_dir)?;
277
278 zip.finish()?;
279 info!("Packaged skill to: {}", output_file.display());
280
281 Ok(output_file)
282 }
283
284 fn validate_skill_name(&self, name: &str) -> Result<()> {
287 if !self.is_valid_skill_name(name) {
288 return Err(anyhow!(
289 "Invalid skill name '{}'. Must be lowercase alphanumeric with hyphens only",
290 name
291 ));
292 }
293 Ok(())
294 }
295
296 fn is_valid_skill_name(&self, name: &str) -> bool {
297 !name.is_empty()
298 && name.len() <= 64
299 && name
300 .chars()
301 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
302 && !name.starts_with('-')
303 && !name.ends_with('-')
304 && !name.contains("--")
305 && !name.contains("anthropic")
306 && !name.contains("claude")
307 && !name.contains("vtcode")
308 }
309
310 fn create_scripts_dir(&self, skill_dir: &Path, skill_name: &str) -> Result<()> {
311 let scripts_dir = skill_dir.join("scripts");
312 fs::create_dir(&scripts_dir).with_context(|| {
313 format!("failed to create scripts dir at {}", scripts_dir.display())
314 })?;
315
316 let example_script = scripts_dir.join("example.py");
317 let content = EXAMPLE_SCRIPT.replace("{skill_name}", skill_name);
318 fs::write(&example_script, content)
319 .with_context(|| format!("failed to write {}", example_script.display()))?;
320
321 #[cfg(unix)]
322 {
323 use std::os::unix::fs::PermissionsExt;
324 let mut perms = fs::metadata(&example_script)
325 .with_context(|| format!("failed to stat {}", example_script.display()))?
326 .permissions();
327 perms.set_mode(0o755);
328 fs::set_permissions(&example_script, perms).with_context(|| {
329 format!("failed to set permissions on {}", example_script.display())
330 })?;
331 }
332
333 info!("Created scripts/example.py");
334 Ok(())
335 }
336
337 fn create_references_dir(&self, skill_dir: &Path, skill_title: &str) -> Result<()> {
338 let references_dir = skill_dir.join("references");
339 fs::create_dir(&references_dir).with_context(|| {
340 format!(
341 "failed to create references dir at {}",
342 references_dir.display()
343 )
344 })?;
345
346 let reference_file = references_dir.join("api_reference.md");
347 let content = EXAMPLE_REFERENCE.replace("{skill_title}", skill_title);
348 fs::write(&reference_file, content)
349 .with_context(|| format!("failed to write {}", reference_file.display()))?;
350
351 info!("Created references/api_reference.md");
352 Ok(())
353 }
354
355 fn create_assets_dir(&self, skill_dir: &Path) -> Result<()> {
356 let assets_dir = skill_dir.join("assets");
357 fs::create_dir(&assets_dir)
358 .with_context(|| format!("failed to create assets dir at {}", assets_dir.display()))?;
359
360 let placeholder = assets_dir.join(".gitkeep");
361 fs::write(
362 placeholder,
363 "# Place template files, images, icons, etc. here\n",
364 )?;
365
366 info!("Created assets/ directory");
367 Ok(())
368 }
369
370 fn validate_structure(&self, skill_dir: &Path, report: &mut ValidationReport) -> Result<()> {
371 if skill_dir.join("README.md").exists() {
373 report
374 .warnings
375 .push("README.md found - not needed for skills (use SKILL.md only)".to_string());
376 }
377
378 if skill_dir.join("INSTALLATION_GUIDE.md").exists() {
379 report.warnings.push(
380 "INSTALLATION_GUIDE.md found - installation info should be in SKILL.md".to_string(),
381 );
382 }
383
384 let skill_md_content = fs::read_to_string(skill_dir.join("SKILL.md"))?;
386 if skill_md_content.contains('\\') {
387 report
388 .warnings
389 .push("SKILL.md contains backslashes - use forward slashes for paths".to_string());
390 }
391
392 Ok(())
393 }
394}
395
396fn add_directory_to_zip<W: Write + std::io::Seek>(
397 zip: &mut zip::ZipWriter<W>,
398 dir: &Path,
399 base: &Path,
400) -> Result<()> {
401 use std::io::Read;
402 use zip::write::SimpleFileOptions;
403
404 for entry in fs::read_dir(dir)? {
405 let entry = entry?;
406 let path = entry.path();
407 let name = path.strip_prefix(base)?;
408
409 if path.is_file() {
410 debug!("Adding file: {}", name.display());
411 let options =
412 SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
413 zip.start_file(name.to_string_lossy().to_string(), options)?;
414 let mut file = fs::File::open(&path)?;
415 let mut buffer = Vec::new();
416 file.read_to_end(&mut buffer)?;
417 zip.write_all(&buffer)?;
418 } else if path.is_dir() {
419 add_directory_to_zip(zip, &path, base)?;
420 }
421 }
422
423 Ok(())
424}
425
426#[derive(Debug, Clone)]
428pub struct ValidationReport {
429 pub skill_dir: PathBuf,
430 pub valid: bool,
431 pub errors: Vec<String>,
432 pub warnings: Vec<String>,
433}
434
435impl ValidationReport {
436 pub fn new(skill_dir: PathBuf) -> Self {
437 Self {
438 skill_dir,
439 valid: false,
440 errors: Vec::new(),
441 warnings: Vec::new(),
442 }
443 }
444
445 pub fn format(&self) -> String {
446 let mut output = format!("Validation Report for: {}\n", self.skill_dir.display());
447 output.push_str(&format!(
448 "Status: {}\n\n",
449 if self.valid {
450 "✓ VALID"
451 } else {
452 "✗ INVALID"
453 }
454 ));
455
456 if !self.errors.is_empty() {
457 output.push_str("Errors:\n");
458 for error in &self.errors {
459 output.push_str(&format!(" ✗ {}\n", error));
460 }
461 output.push('\n');
462 }
463
464 if !self.warnings.is_empty() {
465 output.push_str("Warnings:\n");
466 for warning in &self.warnings {
467 output.push_str(&format!(" ⚠ {}\n", warning));
468 }
469 }
470
471 output
472 }
473}
474
475pub fn render_skills_lean(skills: &[crate::skills::types::Skill]) -> Option<String> {
479 if skills.is_empty() {
480 return None;
481 }
482
483 let mut lines = Vec::new();
484 lines.push("## Skills".to_string());
485 lines.push("These skills are discovered at startup; each entry shows name, description, scope, and file path. Content is not inlined to keep context lean.".to_string());
486
487 let mut sorted_skills = skills.iter().collect::<Vec<_>>();
488 sorted_skills.sort_by(|left, right| left.name().cmp(right.name()));
489
490 for skill in sorted_skills {
491 let skill_md_path = skill.path.join("SKILL.md");
492 let path_str = skill_md_path.to_string_lossy().replace('\\', "/");
493 let scope = match skill.scope {
494 crate::skills::types::SkillScope::User => "user",
495 crate::skills::types::SkillScope::Repo => "repo",
496 crate::skills::types::SkillScope::System => "system",
497 crate::skills::types::SkillScope::Admin => "admin",
498 };
499 lines.push(format!(
500 "- {}: {} (file: {}, scope: {})",
501 skill.name(),
502 skill.description(),
503 path_str,
504 scope
505 ));
506 }
507
508 lines.push(r###"- Discovery: Available skills are listed above (name + description + file path). Skill bodies live on disk at the listed paths.
509- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description, use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
510- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.
511- How to use a skill (progressive disclosure):
512 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.
513 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request.
514 3) If `scripts/` exist, prefer running them instead of retyping code.
515 4) If `assets/` or templates exist, reuse them.
516- Routing: Treat YAML `description` as the primary routing signal. If the user explicitly says `Use the <skill> skill`, treat that as deterministic routing.
517- Context hygiene: Keep context small - summarize long sections, only load extra files when needed, avoid deeply nested references."###.to_string());
518
519 Some(lines.join("\n"))
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525 use crate::skills::types::Skill;
526 use tempfile::TempDir;
527
528 #[test]
529 fn test_validate_skill_name() -> Result<()> {
530 let tmp = TempDir::new().map_err(|e| {
531 eprintln!("Failed to create TempDir: {e}");
533 e
534 })?;
535 let author = SkillAuthor::new(tmp.path().to_path_buf());
536
537 assert!(author.is_valid_skill_name("my-skill"));
539 assert!(author.is_valid_skill_name("pdf-analyzer"));
540 assert!(author.is_valid_skill_name("skill-123"));
541 assert!(author.is_valid_skill_name("a"));
542 assert!(author.is_valid_skill_name("skill-v2-beta"));
543
544 assert!(!author.is_valid_skill_name("My-Skill"));
546 assert!(!author.is_valid_skill_name("PDF-Analyzer"));
547
548 assert!(!author.is_valid_skill_name("my_skill"));
550 assert!(!author.is_valid_skill_name("pdf_analyzer"));
551
552 assert!(!author.is_valid_skill_name("-my-skill"));
554 assert!(!author.is_valid_skill_name("my-skill-"));
555 assert!(!author.is_valid_skill_name("-"));
556
557 assert!(!author.is_valid_skill_name("my--skill"));
559 assert!(!author.is_valid_skill_name("skill---v2"));
560
561 assert!(!author.is_valid_skill_name("anthropic-skill"));
563 assert!(!author.is_valid_skill_name("claude-helper"));
564 assert!(!author.is_valid_skill_name("vtcode-plugin"));
565 assert!(!author.is_valid_skill_name("anthropic"));
566 assert!(!author.is_valid_skill_name("claude"));
567 assert!(!author.is_valid_skill_name("vtcode"));
568
569 assert!(!author.is_valid_skill_name(""));
571 assert!(!author.is_valid_skill_name(&"a".repeat(65)));
572
573 Ok(())
574 }
575
576 #[tokio::test]
577 async fn test_create_skill() -> Result<()> {
578 let tmp = TempDir::new().map_err(|e| {
579 eprintln!("Failed to create TempDir: {e}");
581 e
582 })?;
583 let author = SkillAuthor::new(tmp.path().to_path_buf());
584
585 let skill_dir = author
586 .create_skill("test-skill", Some(tmp.path().to_path_buf()))
587 .map_err(|e| {
588 eprintln!("Failed to write skill file: {e}");
589 e
590 })?;
591
592 assert!(skill_dir.exists());
593 assert!(skill_dir.join("SKILL.md").exists());
594 assert!(skill_dir.join("scripts").exists());
595 assert!(skill_dir.join("references").exists());
596 assert!(skill_dir.join("assets").exists());
597
598 let skill_md = fs::read_to_string(skill_dir.join("SKILL.md")).map_err(|e| {
600 eprintln!("Failed to read SKILL.md: {e}");
601 e
602 })?;
603 assert!(skill_md.starts_with("---"));
604 assert!(skill_md.contains("name: test-skill"));
605 assert!(skill_md.contains("# Test Skill"));
606 assert!(skill_md.contains("Output/Artifact:"));
607
608 Ok(())
609 }
610
611 #[test]
612 fn test_validate_skill_rejects_non_spec_frontmatter_fields() {
613 let tmp = TempDir::new().unwrap();
614 let author = SkillAuthor::new(tmp.path().to_path_buf());
615 let skill_dir = tmp.path().join("test-skill");
616 fs::create_dir(&skill_dir).unwrap();
617 fs::write(
618 skill_dir.join("SKILL.md"),
619 r#"---
620name: test-skill
621description: A test skill with unsupported fields
622version: "1.0.0"
623---
624
625# Test Skill
626
627## Workflow
628Use bundled resources when needed.
629"#,
630 )
631 .unwrap();
632
633 let report = author.validate_skill(&skill_dir).unwrap();
634
635 assert!(!report.valid, "{}", report.format());
636 assert!(
637 report
638 .errors
639 .iter()
640 .any(|error| error.contains("Invalid YAML frontmatter")),
641 "{}",
642 report.format()
643 );
644 }
645
646 #[test]
647 fn test_validation_report_formatting() {
648 let tmp = TempDir::new().unwrap();
649 let mut report = ValidationReport::new(tmp.path().to_path_buf());
650
651 report.errors.push("Test error".to_string());
652 report.warnings.push("Test warning".to_string());
653
654 let formatted = report.format();
655 assert!(formatted.contains("✗ INVALID"));
656 assert!(formatted.contains("✗ Test error"));
657 assert!(formatted.contains("⚠ Test warning"));
658
659 report.errors.clear();
661 report.valid = true;
662 let formatted = report.format();
663 assert!(formatted.contains("✓ VALID"));
664 }
665
666 #[test]
667 fn test_duplicate_skill_creation() {
668 let tmp = TempDir::new().unwrap();
669 let author = SkillAuthor::new(tmp.path().to_path_buf());
670
671 author
673 .create_skill("test-skill", Some(tmp.path().to_path_buf()))
674 .unwrap();
675
676 let result = author.create_skill("test-skill", Some(tmp.path().to_path_buf()));
678 assert!(result.is_err());
679 assert!(result.unwrap_err().to_string().contains("already exists"));
680 }
681
682 #[tokio::test]
683 async fn test_render_skills_lean() {
684 let tmp = TempDir::new().unwrap();
685 let author = SkillAuthor::new(tmp.path().to_path_buf());
686
687 let skill1_dir = author
689 .create_skill("pdf-analyzer", Some(tmp.path().to_path_buf()))
690 .unwrap();
691 let skill2_dir = author
692 .create_skill("spreadsheet-generator", Some(tmp.path().to_path_buf()))
693 .unwrap();
694
695 let skill1_md = skill1_dir.join("SKILL.md");
697 let content1 = fs::read_to_string(&skill1_md).unwrap().replace(
698 "description: Describe the workflow, routing triggers, and expected artifact for this skill.",
699 "description: Extract text and tables from PDFs",
700 );
701 fs::write(skill1_md, content1).unwrap();
702
703 let skill2_md = skill2_dir.join("SKILL.md");
704 let content2 = fs::read_to_string(&skill2_md).unwrap().replace(
705 "description: Describe the workflow, routing triggers, and expected artifact for this skill.",
706 "description: Create Excel spreadsheets with charts",
707 );
708 fs::write(skill2_md, content2).unwrap();
709
710 use crate::skills::manifest::parse_skill_file;
712 let (manifest1, body1) = parse_skill_file(&skill1_dir).unwrap();
713 let (manifest2, body2) = parse_skill_file(&skill2_dir).unwrap();
714
715 let skill1 = Skill::new(manifest1, skill1_dir.clone(), body1).unwrap();
716 let skill2 = Skill::new(manifest2, skill2_dir.clone(), body2).unwrap();
717
718 let rendered = render_skills_lean(&[skill1, skill2]).unwrap();
720
721 assert!(rendered.contains("## Skills"));
723 assert!(rendered.contains("pdf-analyzer: Extract text and tables from PDFs"));
724 assert!(rendered.contains("spreadsheet-generator: Create Excel spreadsheets with charts"));
725 assert!(rendered.contains("(file:"));
726 assert!(rendered.contains("SKILL.md, scope:"));
727 assert!(rendered.contains("scope:"));
728
729 assert!(rendered.contains("Trigger rules:"));
731 assert!(rendered.contains("$SkillName"));
732 assert!(rendered.contains("progressive disclosure"));
733 assert!(rendered.contains("Routing:"));
734 assert!(rendered.contains("Use the <skill> skill"));
735 assert!(rendered.contains("Context hygiene"));
736
737 assert!(rendered.len() < 2500, "Lean rendering should be compact");
741 }
742
743 #[test]
744 fn test_render_skills_lean_empty() {
745 let rendered = render_skills_lean(&[]);
746 assert!(rendered.is_none());
747 }
748}