skills.rs

  1//! Agent Skills discovery and formatting.
  2//!
  3//! This module discovers user-defined skills from global and worktree locations
  4//! and formats them for display in the agent's system prompt.
  5
  6use crate::{SkillContext, SkillsPromptTemplate, Template, Templates};
  7use anyhow::{Result, anyhow};
  8use collections::HashMap;
  9use gpui::{App, AppContext, Context, Entity};
 10use serde::Deserialize;
 11use std::path::Path;
 12use std::path::PathBuf;
 13use std::sync::Arc;
 14
 15/// A minimal representation of a discovered skill for formatting.
 16#[derive(Clone, Debug)]
 17pub struct Skill {
 18    name: String,
 19    description: String,
 20    path: PathBuf,
 21}
 22
 23/// Metadata extracted from a skill's YAML frontmatter.
 24#[derive(Deserialize, Debug)]
 25struct SkillMetadata {
 26    name: String,
 27    description: String,
 28    #[allow(dead_code)]
 29    license: Option<String>,
 30    compatibility: Option<String>,
 31    #[serde(default)]
 32    #[allow(dead_code)]
 33    metadata: HashMap<String, String>,
 34    #[allow(dead_code)]
 35    allowed_tools: Option<String>,
 36}
 37
 38impl SkillMetadata {
 39    /// Validates that the skill metadata conforms to the Agent Skills specification.
 40    fn validate(&self, expected_dir_name: &str) -> Result<()> {
 41        if self.name != expected_dir_name {
 42            return Err(anyhow!(
 43                "skill name '{}' doesn't match directory name '{}'",
 44                self.name,
 45                expected_dir_name
 46            ));
 47        }
 48
 49        if self.name.is_empty() {
 50            return Err(anyhow!("skill name cannot be empty"));
 51        }
 52
 53        if self.name.len() > 64 {
 54            return Err(anyhow!("skill name cannot exceed 64 characters"));
 55        }
 56
 57        if !self
 58            .name
 59            .chars()
 60            .all(|c| c.is_lowercase() || c.is_numeric() || c == '-')
 61        {
 62            return Err(anyhow!(
 63                "skill name must be lowercase alphanumeric + hyphens only: {}",
 64                self.name
 65            ));
 66        }
 67
 68        if self.name.contains("--") {
 69            return Err(anyhow!(
 70                "skill name must not contain consecutive hyphens: {}",
 71                self.name
 72            ));
 73        }
 74
 75        if self.name.starts_with('-') || self.name.ends_with('-') {
 76            return Err(anyhow!(
 77                "skill name must not start or end with hyphen: {}",
 78                self.name
 79            ));
 80        }
 81
 82        if self.description.is_empty() {
 83            return Err(anyhow!("skill description cannot be empty"));
 84        }
 85
 86        if self.description.len() > 1024 {
 87            return Err(anyhow!("skill description cannot exceed 1024 characters"));
 88        }
 89
 90        if let Some(ref compatibility) = self.compatibility {
 91            if compatibility.len() > 500 {
 92                return Err(anyhow!(
 93                    "skill compatibility exceeds 500 characters: {}",
 94                    compatibility.len()
 95                ));
 96            }
 97        }
 98
 99        Ok(())
100    }
101}
102
103/// Parses YAML frontmatter from a markdown file.
104/// Returns the parsed metadata and the markdown body.
105fn parse_skill_file(content: &str, expected_dir_name: &str) -> Result<(SkillMetadata, String)> {
106    let content = content.trim_start();
107
108    if !content.starts_with("---") {
109        return Err(anyhow!("SKILL.md must start with YAML frontmatter (---)"));
110    }
111
112    let end_marker = content[3..].find("\n---");
113    let (yaml_part, body) = match end_marker {
114        Some(end) => {
115            let yaml_end = 3 + end;
116            let yaml = content[3..yaml_end].trim().to_string();
117            let body_start = yaml_end + 3;
118            let body = content[body_start..].trim_start().to_string();
119            (yaml, body)
120        }
121        None => return Err(anyhow!("YAML frontmatter not properly closed with ---")),
122    };
123
124    let metadata: SkillMetadata = yaml_serde::from_str(&yaml_part)
125        .map_err(|e| anyhow!("failed to parse YAML frontmatter: {}", e))?;
126
127    metadata.validate(expected_dir_name)?;
128
129    Ok((metadata, body))
130}
131
132/// Discovers all skills in the given directory.
133/// Returns a map of skill name to Skill.
134fn discover_skills_sync(skills_dir: &Path) -> HashMap<String, Arc<Skill>> {
135    let mut skills = HashMap::default();
136
137    if !skills_dir.exists() || !skills_dir.is_dir() {
138        return skills;
139    }
140
141    let entries = match std::fs::read_dir(skills_dir) {
142        Ok(entries) => entries,
143        Err(e) => {
144            log::warn!("failed to read skills directory: {}", e);
145            return skills;
146        }
147    };
148
149    for entry in entries.flatten() {
150        let path = entry.path();
151
152        if !path.is_dir() {
153            continue;
154        }
155
156        let skill_file = path.join("SKILL.md");
157        if !skill_file.exists() {
158            continue;
159        }
160
161        let dir_name = path
162            .file_name()
163            .and_then(|n| n.to_str())
164            .unwrap_or_default();
165
166        let content = match std::fs::read_to_string(&skill_file) {
167            Ok(content) => content,
168            Err(e) => {
169                log::warn!("failed to read {:?}: {}", skill_file, e);
170                continue;
171            }
172        };
173
174        let (metadata, _body) = match parse_skill_file(&content, dir_name) {
175            Ok(result) => result,
176            Err(e) => {
177                log::warn!("failed to parse {:?}: {}", skill_file, e);
178                continue;
179            }
180        };
181
182        let skill = Arc::new(Skill {
183            name: metadata.name,
184            description: metadata.description,
185            path,
186        });
187
188        skills.insert(skill.name.clone(), skill);
189    }
190
191    skills
192}
193
194/// Returns the canonicalized global skills directory path (~/.config/zed/skills).
195/// Result is cached after first call. If canonicalization fails, returns the original path.
196pub fn global_skills_dir() -> PathBuf {
197    paths::config_dir().join("skills")
198}
199
200/// Discovers skills from both global and worktree locations.
201/// Worktree skills take precedence over global skills with the same name.
202pub fn discover_all_skills_sync(worktree_roots: &[PathBuf]) -> HashMap<String, Arc<Skill>> {
203    let mut all_skills = discover_skills_sync(&global_skills_dir());
204
205    for worktree in worktree_roots {
206        let worktree_skills = discover_skills_sync(&worktree.join(".agents").join("skills"));
207        for (name, skill) in worktree_skills {
208            all_skills.insert(name, skill);
209        }
210    }
211
212    all_skills
213}
214
215/// Format skills for display in the system prompt using handlebars templating.
216pub fn format_skills_for_prompt(
217    skills: &HashMap<String, Arc<Skill>>,
218    templates: Arc<Templates>,
219) -> String {
220    let mut skill_list: Vec<_> = skills.values().collect();
221    skill_list.sort_by(|a, b| a.name.cmp(&b.name));
222
223    let skill_contexts: Vec<SkillContext> = skill_list
224        .into_iter()
225        .map(|skill| SkillContext {
226            name: skill.name.clone(),
227            description: if skill.description.len() > 1024 {
228                format!("{}...", &skill.description[..1021])
229            } else {
230                skill.description.clone()
231            },
232            path: skill.path.display().to_string(),
233        })
234        .collect();
235
236    let template = SkillsPromptTemplate {
237        has_skills: !skill_contexts.is_empty(),
238        skills: skill_contexts,
239    };
240
241    template.render(&templates).unwrap_or_default()
242}
243
244/// Context entity that holds formatted skills for the system prompt.
245/// Populates itself asynchronously on creation.
246pub struct SkillsContext {
247    formatted_skills: Option<String>,
248}
249
250impl SkillsContext {
251    /// Create a new SkillsContext and spawn background task to populate it.
252    pub fn new(
253        worktree_roots: Vec<PathBuf>,
254        templates: Arc<Templates>,
255        cx: &mut App,
256    ) -> Entity<Self> {
257        cx.new(|cx: &mut Context<Self>| {
258            // Spawn async task that will populate the skills
259            cx.spawn(async move |this, cx| {
260                let formatted = cx
261                    .background_spawn(async move {
262                        let skills = discover_all_skills_sync(&worktree_roots);
263                        format_skills_for_prompt(&skills, templates)
264                    })
265                    .await;
266
267                this.update(cx, |this, _cx| {
268                    this.formatted_skills = Some(formatted);
269                })
270                .ok();
271            })
272            .detach();
273
274            Self {
275                formatted_skills: None,
276            }
277        })
278    }
279
280    /// Create a SkillsContext with pre-populated skills (for loading from DB).
281    pub fn from_formatted(formatted_skills: String, cx: &mut App) -> Entity<Self> {
282        cx.new(|_cx| Self {
283            formatted_skills: Some(formatted_skills),
284        })
285    }
286
287    /// Get the formatted skills string.
288    /// Returns empty string if not yet loaded.
289    pub fn formatted(&self) -> &str {
290        self.formatted_skills.as_deref().unwrap_or("")
291    }
292
293    /// Check if skills have been loaded.
294    pub fn is_loaded(&self) -> bool {
295        self.formatted_skills.is_some()
296    }
297}
298
299/// Checks if a path is within a skills directory (global or worktree-specific).
300///
301/// Expands `~` to home directory, canonicalizes the path, and checks if it's within:
302/// - The global skills directory (~/.config/zed/skills)
303/// - Any worktree's .agents/skills directory
304///
305/// Returns Some(canonical_path) if the path is within a skills directory.
306/// Returns None if the path is not within any skills directory.
307/// Check if a canonicalized path is within any skills directory.
308/// This is the pure logic version that operates on already-canonicalized paths.
309pub fn is_skills_path_canonical(
310    canonical_input: &Path,
311    worktree_roots: &[PathBuf],
312) -> Option<PathBuf> {
313    let global_skills_root = global_skills_dir();
314    // Canonicalize the skills roots so that symlinks within the skills directory
315    // resolve to paths that still pass the `starts_with` check.
316    let canonical_global = std::fs::canonicalize(&global_skills_root).unwrap_or(global_skills_root);
317    if canonical_input.starts_with(&canonical_global) {
318        return Some(canonical_input.to_path_buf());
319    }
320
321    for worktree_root in worktree_roots {
322        let worktree_skills_path = worktree_root.join(".agents").join("skills");
323        let canonical_worktree =
324            std::fs::canonicalize(&worktree_skills_path).unwrap_or(worktree_skills_path);
325        if canonical_input.starts_with(&canonical_worktree) {
326            return Some(canonical_input.to_path_buf());
327        }
328    }
329
330    None
331}
332
333/// Check if a path is within any skills directory.
334/// Handles ~ expansion and canonicalization.
335pub fn is_skills_path(input_path: &str, worktree_roots: &[PathBuf]) -> Option<PathBuf> {
336    let path = if input_path.starts_with('~') {
337        let home = paths::home_dir().to_string_lossy().into_owned();
338        PathBuf::from(input_path.replacen('~', &home, 1))
339    } else {
340        PathBuf::from(input_path)
341    };
342
343    let canonical_input = std::fs::canonicalize(&path).ok()?;
344
345    is_skills_path_canonical(&canonical_input, worktree_roots)
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn test_validate_valid_metadata() {
354        let metadata = SkillMetadata {
355            name: "pdf-processing".to_string(),
356            description: "Extract text and tables from PDF files".to_string(),
357            license: None,
358            compatibility: None,
359            metadata: HashMap::default(),
360            allowed_tools: None,
361        };
362        assert!(metadata.validate("pdf-processing").is_ok());
363    }
364
365    #[test]
366    fn test_validate_name_too_long() {
367        let metadata = SkillMetadata {
368            name: "a".repeat(65),
369            description: "Valid description".to_string(),
370            license: None,
371            compatibility: None,
372            metadata: HashMap::default(),
373            allowed_tools: None,
374        };
375        assert!(metadata.validate("toolongname").is_err());
376    }
377
378    #[test]
379    fn test_validate_name_empty() {
380        let metadata = SkillMetadata {
381            name: String::new(),
382            description: "Valid description".to_string(),
383            license: None,
384            compatibility: None,
385            metadata: HashMap::default(),
386            allowed_tools: None,
387        };
388        assert!(metadata.validate("").is_err());
389    }
390
391    #[test]
392    fn test_validate_name_invalid_chars() {
393        let metadata = SkillMetadata {
394            name: "invalid@name".to_string(),
395            description: "Valid description".to_string(),
396            license: None,
397            compatibility: None,
398            metadata: HashMap::default(),
399            allowed_tools: None,
400        };
401        assert!(metadata.validate("invalid@name").is_err());
402    }
403
404    #[test]
405    fn test_validate_name_starts_with_hyphen() {
406        let metadata = SkillMetadata {
407            name: "-invalid".to_string(),
408            description: "Valid description".to_string(),
409            license: None,
410            compatibility: None,
411            metadata: HashMap::default(),
412            allowed_tools: None,
413        };
414        assert!(metadata.validate("-invalid").is_err());
415    }
416
417    #[test]
418    fn test_validate_name_ends_with_hyphen() {
419        let metadata = SkillMetadata {
420            name: "invalid-".to_string(),
421            description: "Valid description".to_string(),
422            license: None,
423            compatibility: None,
424            metadata: HashMap::default(),
425            allowed_tools: None,
426        };
427        assert!(metadata.validate("invalid-").is_err());
428    }
429
430    #[test]
431    fn test_validate_name_consecutive_hyphens() {
432        let metadata = SkillMetadata {
433            name: "in--valid".to_string(),
434            description: "Valid description".to_string(),
435            license: None,
436            compatibility: None,
437            metadata: HashMap::default(),
438            allowed_tools: None,
439        };
440        assert!(metadata.validate("in--valid").is_err()); // Consecutive hyphens are allowed
441    }
442
443    #[test]
444    fn test_validate_description_empty() {
445        let metadata = SkillMetadata {
446            name: "valid-name".to_string(),
447            description: String::new(),
448            license: None,
449            compatibility: None,
450            metadata: HashMap::default(),
451            allowed_tools: None,
452        };
453        assert!(metadata.validate("valid-name").is_err());
454    }
455
456    #[test]
457    fn test_validate_description_too_long() {
458        let metadata = SkillMetadata {
459            name: "valid-name".to_string(),
460            description: "a".repeat(1025),
461            license: None,
462            compatibility: None,
463            metadata: HashMap::default(),
464            allowed_tools: None,
465        };
466        assert!(metadata.validate("valid-name").is_err());
467    }
468
469    #[test]
470    fn test_validate_compatibility_too_long() {
471        let metadata = SkillMetadata {
472            name: "valid-name".to_string(),
473            description: "Valid description".to_string(),
474            license: None,
475            compatibility: Some("a".repeat(501)),
476            metadata: HashMap::default(),
477            allowed_tools: None,
478        };
479        assert!(metadata.validate("valid-name").is_err());
480    }
481
482    #[test]
483    fn test_validate_name_mismatch() {
484        let metadata = SkillMetadata {
485            name: "bar".to_string(),
486            description: "Valid description".to_string(),
487            license: None,
488            compatibility: None,
489            metadata: HashMap::default(),
490            allowed_tools: None,
491        };
492        assert!(metadata.validate("foo").is_err());
493    }
494
495    #[test]
496    fn test_parse_valid_frontmatter() {
497        let content = r#"---
498name: test-skill
499description: A test skill
500---
501# Skill Content
502
503This is the skill content."#;
504
505        let (metadata, body) = parse_skill_file(content, "test-skill").unwrap();
506        assert_eq!(metadata.name, "test-skill");
507        assert_eq!(metadata.description, "A test skill");
508        assert!(body.contains("Skill Content"));
509    }
510
511    #[test]
512    fn test_parse_no_frontmatter() {
513        let content = "# Just markdown content";
514        assert!(parse_skill_file(content, "test").is_err());
515    }
516
517    #[test]
518    fn test_parse_unclosed_frontmatter() {
519        let content = "---\nname: test\n# No closing";
520        assert!(parse_skill_file(content, "test").is_err());
521    }
522
523    #[test]
524    fn test_parse_invalid_yaml() {
525        let content = "---\ninvalid yaml\n---\ncontent";
526        assert!(parse_skill_file(content, "test").is_err());
527    }
528
529    #[test]
530    fn test_format_skills_sorts_alphabetically() {
531        let mut skills = HashMap::default();
532        skills.insert(
533            "z-skill".to_string(),
534            Arc::new(Skill {
535                name: "z-skill".to_string(),
536                description: "Z skill desc".to_string(),
537                path: PathBuf::from("/z"),
538            }),
539        );
540        skills.insert(
541            "a-skill".to_string(),
542            Arc::new(Skill {
543                name: "a-skill".to_string(),
544                description: "A skill desc".to_string(),
545                path: PathBuf::from("/a"),
546            }),
547        );
548        skills.insert(
549            "m-skill".to_string(),
550            Arc::new(Skill {
551                name: "m-skill".to_string(),
552                description: "M skill desc".to_string(),
553                path: PathBuf::from("/m"),
554            }),
555        );
556
557        let result = format_skills_for_prompt(&skills, Templates::new());
558
559        // Verify all skills are present
560        assert!(result.contains("a-skill"));
561        assert!(result.contains("m-skill"));
562        assert!(result.contains("z-skill"));
563
564        // Verify alphabetical order: a-skill should appear before m-skill, which should appear before z-skill
565        let a_pos = result.find("a-skill").unwrap();
566        let m_pos = result.find("m-skill").unwrap();
567        let z_pos = result.find("z-skill").unwrap();
568        assert!(a_pos < m_pos, "a-skill should appear before m-skill");
569        assert!(m_pos < z_pos, "m-skill should appear before z-skill");
570    }
571
572    #[test]
573    fn test_format_skills_truncates_long_description() {
574        let mut skills = HashMap::default();
575        let long_description = "a".repeat(1500);
576
577        skills.insert(
578            "long-desc-skill".to_string(),
579            Arc::new(Skill {
580                name: "long-desc-skill".to_string(),
581                description: long_description.clone(),
582                path: PathBuf::from("/long"),
583            }),
584        );
585
586        let result = format_skills_for_prompt(&skills, Templates::new());
587
588        // The description should be truncated with "..."
589        assert!(result.contains("..."));
590        // The full description should NOT be present
591        assert!(!result.contains(&long_description));
592        // The skill name should still be present
593        assert!(result.contains("long-desc-skill"));
594    }
595
596    #[test]
597    fn test_format_skills_preserves_short_description() {
598        let mut skills = HashMap::default();
599
600        skills.insert(
601            "short-desc-skill".to_string(),
602            Arc::new(Skill {
603                name: "short-desc-skill".to_string(),
604                description: "Short description".to_string(),
605                path: PathBuf::from("/short"),
606            }),
607        );
608
609        let result = format_skills_for_prompt(&skills, Templates::new());
610
611        // Short descriptions should NOT be truncated (no "..." appended)
612        assert!(!result.contains("Short description..."));
613        assert!(result.contains("Short description"));
614    }
615
616    #[test]
617    fn test_format_skills_includes_all_fields() {
618        let mut skills = HashMap::default();
619
620        skills.insert(
621            "test-skill".to_string(),
622            Arc::new(Skill {
623                name: "test-skill".to_string(),
624                description: "Test description".to_string(),
625                path: PathBuf::from("/path/to/skill"),
626            }),
627        );
628
629        let result = format_skills_for_prompt(&skills, Templates::new());
630
631        // All fields should appear in the output
632        assert!(result.contains("test-skill"));
633        assert!(result.contains("Test description"));
634        assert!(result.contains("/path/to/skill"));
635    }
636
637    #[test]
638    fn test_format_skills_exactly_1024_char_description() {
639        let mut skills = HashMap::default();
640        // Exactly 1024 characters should NOT be truncated
641        let exact_description = "b".repeat(1024);
642
643        skills.insert(
644            "exact-skill".to_string(),
645            Arc::new(Skill {
646                name: "exact-skill".to_string(),
647                description: exact_description,
648                path: PathBuf::from("/exact"),
649            }),
650        );
651
652        let result = format_skills_for_prompt(&skills, Templates::new());
653
654        // Should NOT contain "..." since it's exactly 1024 chars
655        assert!(!result.contains("..."));
656    }
657
658    #[test]
659    fn test_format_skills_empty() {
660        let skills = HashMap::default();
661        let result = format_skills_for_prompt(&skills, Templates::new());
662        // With no skills, template renders to empty string
663        assert!(result.is_empty());
664    }
665
666    #[test]
667    fn test_is_skills_path_canonical_global_directory() {
668        let worktree_roots: Vec<PathBuf> = vec![];
669
670        let path = PathBuf::from("/home/user/.config/zed/skills/test.md");
671        let result = is_skills_path_canonical(&path, &worktree_roots);
672
673        // This will return Some if the actual global_skills_dir() matches,
674        // but since we don't know the user's home directory in tests,
675        // this test may return None on systems with different paths.
676        // The key assertion is that it doesn't panic and returns consistent types.
677        let _ = result;
678    }
679
680    #[test]
681    fn test_is_skills_path_canonical_worktree_directory() {
682        let worktree_roots = vec![PathBuf::from("/home/user/projects/myproject")];
683
684        let path = PathBuf::from("/home/user/projects/myproject/.agents/skills/test.md");
685        let result = is_skills_path_canonical(&path, &worktree_roots);
686
687        assert!(result.is_some());
688        assert_eq!(result.unwrap(), path);
689    }
690
691    #[test]
692    fn test_is_skills_path_canonical_worktree_subdirectory() {
693        let worktree_roots = vec![PathBuf::from("/home/user/projects/myproject")];
694
695        let path =
696            PathBuf::from("/home/user/projects/myproject/.agents/skills/nested/deep/skill.md");
697        let result = is_skills_path_canonical(&path, &worktree_roots);
698
699        assert!(result.is_some());
700        assert_eq!(result.unwrap(), path);
701    }
702
703    #[test]
704    fn test_is_skills_path_canonical_not_in_skills() {
705        let worktree_roots = vec![PathBuf::from("/home/user/project")];
706
707        let path = PathBuf::from("/etc/passwd");
708        let result = is_skills_path_canonical(&path, &worktree_roots);
709
710        assert!(result.is_none());
711    }
712
713    #[test]
714    fn test_is_skills_path_canonical_sibling_of_skills() {
715        let worktree_roots = vec![PathBuf::from("/home/user/project")];
716
717        let path = PathBuf::from("/home/user/project/.agents/config.toml");
718        let result = is_skills_path_canonical(&path, &worktree_roots);
719
720        assert!(result.is_none());
721    }
722
723    #[test]
724    fn test_is_skills_path_canonical_different_worktree() {
725        let worktree_roots = vec![PathBuf::from("/home/user/projectA")];
726
727        let path = PathBuf::from("/home/user/projectB/.agents/skills/test.md");
728        let result = is_skills_path_canonical(&path, &worktree_roots);
729
730        assert!(result.is_none());
731    }
732
733    #[test]
734    fn test_is_skills_path_canonical_multiple_worktrees() {
735        let worktree_roots = vec![
736            PathBuf::from("/home/user/projectA"),
737            PathBuf::from("/home/user/projectB"),
738        ];
739
740        // Path in first worktree
741        let path_a = PathBuf::from("/home/user/projectA/.agents/skills/skill.md");
742        let result_a = is_skills_path_canonical(&path_a, &worktree_roots);
743        assert!(result_a.is_some());
744        assert_eq!(result_a.unwrap(), path_a);
745
746        // Path in second worktree
747        let path_b = PathBuf::from("/home/user/projectB/.agents/skills/skill.md");
748        let result_b = is_skills_path_canonical(&path_b, &worktree_roots);
749        assert!(result_b.is_some());
750        assert_eq!(result_b.unwrap(), path_b);
751    }
752
753    #[test]
754    fn test_parse_with_extra_fields() {
755        let content = r#"---
756name: test-skill
757description: A test skill
758license: MIT
759compatibility: 1.0
760metadata:
761  author: Test
762  version: 1.0
763allowed_tools: bash
764---
765# Skill Content"#;
766
767        let (metadata, body) = parse_skill_file(content, "test-skill").unwrap();
768        assert_eq!(metadata.name, "test-skill");
769        assert_eq!(metadata.license, Some("MIT".to_string()));
770        assert_eq!(metadata.compatibility, Some("1.0".to_string()));
771        assert!(!body.is_empty());
772    }
773
774    #[test]
775    fn test_validate_unicode_name() {
776        let metadata = SkillMetadata {
777            name: "测试-skill".to_string(), // Chinese characters
778            description: "Valid description".to_string(),
779            license: None,
780            compatibility: None,
781            metadata: HashMap::default(),
782            allowed_tools: None,
783        };
784        // Unicode characters outside allowed set should fail
785        assert!(metadata.validate("测试-skill").is_err());
786    }
787}