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