Skip to main content

vtcode_core/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};
11
12/// Example Python script template
13pub 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
31/// Example reference document template
32pub 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
49/// YAML frontmatter for SKILL.md authoring validation.
50pub type SkillFrontmatter = crate::skills::manifest::SkillYaml;
51
52/// Skill authoring operations
53pub 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    /// Create a new skill from template
63    ///
64    pub fn create_skill(&self, skill_name: &str, output_dir: Option<PathBuf>) -> Result<PathBuf> {
65        // Validate skill name
66        self.validate_skill_name(skill_name)?;
67
68        // Determine output directory
69        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        // Check if exists
74        if skill_dir.exists() {
75            return Err(anyhow!(
76                "Skill directory already exists: {}",
77                skill_dir.display()
78            ));
79        }
80
81        // Create skill directory
82        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        // Create SKILL.md
91        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        // Create resource directories with examples
102        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    /// Validate a skill following the Agent Skills specification
122    pub fn validate_skill(&self, skill_dir: &Path) -> Result<ValidationReport> {
123        let mut report = ValidationReport::new(skill_dir.to_path_buf());
124
125        // Check SKILL.md exists
126        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        // Read and parse content
133        let content = fs::read_to_string(&skill_md)
134            .with_context(|| format!("failed to read {}", skill_md.display()))?;
135
136        // Extract frontmatter
137        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        // Parse YAML
149        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        // Validate frontmatter properties (only allowed: skill metadata fields)
160        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        // Validate name
175        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        // Validate description
208        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        // Check body content
231        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        // Validate directory structure
244        self.validate_structure(skill_dir, &mut report)?;
245
246        report.valid = report.errors.is_empty();
247        Ok(report)
248    }
249
250    /// Package a skill into .skill file (zip format)
251    pub fn package_skill(&self, skill_dir: &Path, output_dir: Option<PathBuf>) -> Result<PathBuf> {
252        // Validate first
253        let report = self.validate_skill(skill_dir)?;
254        if !report.valid {
255            return Err(anyhow!("Skill validation failed:\n{}", report.format()));
256        }
257
258        // Determine output path
259        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        // Create zip file
268        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 all files from skill_dir
276        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    // Helper methods
285
286    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        // Check for common mistakes
372        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        // Warn about Windows-style paths
385        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/// Validation report for skills
427#[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
475/// Render skills section for system prompt (Codex-style lean format)
476///
477/// Only includes name + description + file path. Body stays on disk for progressive disclosure.
478pub 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            // Convert TempDir error into a test failure with context
532            eprintln!("Failed to create TempDir: {e}");
533            e
534        })?;
535        let author = SkillAuthor::new(tmp.path().to_path_buf());
536
537        // Valid names
538        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        // Invalid: uppercase
545        assert!(!author.is_valid_skill_name("My-Skill"));
546        assert!(!author.is_valid_skill_name("PDF-Analyzer"));
547
548        // Invalid: underscore
549        assert!(!author.is_valid_skill_name("my_skill"));
550        assert!(!author.is_valid_skill_name("pdf_analyzer"));
551
552        // Invalid: leading/trailing hyphens
553        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        // Invalid: consecutive hyphens
558        assert!(!author.is_valid_skill_name("my--skill"));
559        assert!(!author.is_valid_skill_name("skill---v2"));
560
561        // Invalid: reserved words
562        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        // Invalid: empty or too long
570        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            // Provide context on TempDir failure
580            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        // Verify SKILL.md has correct structure
599        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        // Valid report
660        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        // Create first skill
672        author
673            .create_skill("test-skill", Some(tmp.path().to_path_buf()))
674            .unwrap();
675
676        // Try to create duplicate - should fail
677        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        // Create test skills
688        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        // Update descriptions to be valid
696        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        // Load skills
711        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        // Render lean format
719        let rendered = render_skills_lean(&[skill1, skill2]).unwrap();
720
721        // Verify structure
722        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        // Verify usage rules present
730        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        // Verify token efficiency - should be much smaller than full content
738        // Lean format: ~200 tokens per skill + 400 for rules (with package manager prefs) = ~800 total
739        // Full format would be 5K+ tokens per skill
740        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}