Skip to main content

vtcode_skills/
authoring.rs

1//! Skill Authoring Tool for VT Code
2//!
3//! Implements the Agent Skills specification for creating, validating,
4//! and packaging skills that extend VT Code's capabilities.
5
6use 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
13/// Example Python script template
14const 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
59/// Example reference document template
60const 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
77/// YAML frontmatter for SKILL.md authoring validation.
78pub type SkillFrontmatter = crate::manifest::SkillYaml;
79
80/// Skill authoring operations
81pub 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    /// Create a new skill from template
91    ///
92    pub fn create_skill(&self, skill_name: &str, output_dir: Option<PathBuf>) -> Result<PathBuf> {
93        // Validate skill name
94        self.validate_skill_name(skill_name)?;
95
96        // Determine output directory
97        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        // Check if exists
101        if skill_dir.exists() {
102            return Err(anyhow!("Skill directory already exists: {}", skill_dir.display()));
103        }
104
105        // Create skill directory
106        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        // Create SKILL.md
111        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        // Create resource directories with examples
122        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    /// Validate a skill following the Agent Skills specification
142    pub fn validate_skill(&self, skill_dir: &Path) -> Result<ValidationReport> {
143        let mut report = ValidationReport::new(skill_dir.to_path_buf());
144
145        // Check SKILL.md exists
146        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        // Read and parse content
153        let content =
154            fs::read_to_string(&skill_md).with_context(|| format!("failed to read {}", skill_md.display()))?;
155
156        // Extract frontmatter
157        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        // Parse YAML
169        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        // Validate frontmatter properties (only allowed: skill metadata fields)
178        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        // Validate name
193        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        // Validate description
222        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        // Check body content
243        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        // Validate directory structure
255        self.validate_structure(skill_dir, &mut report)?;
256
257        report.valid = report.errors.is_empty();
258        Ok(report)
259    }
260
261    /// Package a skill into .skill file (zip format)
262    pub fn package_skill(&self, skill_dir: &Path, output_dir: Option<PathBuf>) -> Result<PathBuf> {
263        // Validate first
264        let report = self.validate_skill(skill_dir)?;
265        if !report.valid {
266            return Err(anyhow!("Skill validation failed:\n{}", report.format()));
267        }
268
269        // Determine output path
270        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        // Create zip file
279        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 all files from skill_dir
286        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    // Helper methods
295
296    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        // Check for common mistakes
366        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        // Warn about Windows-style paths
379        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/// Validation report for skills
416#[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
457/// Render skills section for system prompt (Codex-style lean format)
458///
459/// Only includes name + description + file path. Body stays on disk for progressive disclosure.
460pub 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            // Convert TempDir error into a test failure with context
508            eprintln!("Failed to create TempDir: {e}");
509            e
510        })?;
511        let author = SkillAuthor::new(tmp.path().to_path_buf());
512
513        // Valid names
514        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        // Invalid: uppercase
521        assert!(!author.is_valid_skill_name("My-Skill"));
522        assert!(!author.is_valid_skill_name("PDF-Analyzer"));
523
524        // Invalid: underscore
525        assert!(!author.is_valid_skill_name("my_skill"));
526        assert!(!author.is_valid_skill_name("pdf_analyzer"));
527
528        // Invalid: leading/trailing hyphens
529        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        // Invalid: consecutive hyphens
534        assert!(!author.is_valid_skill_name("my--skill"));
535        assert!(!author.is_valid_skill_name("skill---v2"));
536
537        // Invalid: reserved words
538        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        // Invalid: empty or too long
546        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            // Provide context on TempDir failure
556            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        // Verify SKILL.md has correct structure
573        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        // Valid report
627        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        // Create first skill
639        author.create_skill("test-skill", Some(tmp.path().to_path_buf())).unwrap();
640
641        // Try to create duplicate - should fail
642        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        // Create test skills
653        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        // Update descriptions to be valid
659        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        // Load skills
674        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        // Render lean format
682        let rendered = render_skills_lean(&[skill1, skill2]).unwrap();
683
684        // Verify structure
685        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        // Verify usage rules present
693        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        // Verify token efficiency - should be much smaller than full content
701        // Lean format: ~200 tokens per skill + 400 for rules (with package manager prefs) = ~800 total
702        // Full format would be 5K+ tokens per skill
703        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}