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}