1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use serde::Deserialize;
6
7use crate::agent::config::WorkspaceTrustConfig;
8use crate::agent::trust_resolver::{resolve_workspace_trust, WorkspaceTrustPolicy};
9
10pub const PROJECT_GUIDANCE_FILES: &[&str] = &[
11 "AGENTS.md",
12 "agents.md",
13 "CLAUDE.md",
14 ".claude.md",
15 "CLAUDE.local.md",
16 "HEMATITE.md",
17 "HEMATITE.local.md",
18 ".hematite/rules.md",
19 ".hematite/rules.local.md",
20 "SKILLS.md",
21 "SKILL.md",
22 ".hematite/instructions.md",
23];
24
25pub const AGENT_SKILL_DIRS: &[&str] = &[".agents/skills", ".hematite/skills"];
26
27#[derive(Debug, Clone)]
28pub struct InstructionFile {
29 pub path: PathBuf,
30 pub content: String,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum SkillScope {
35 User,
36 Project,
37}
38
39impl SkillScope {
40 pub fn label(self) -> &'static str {
41 match self {
42 SkillScope::User => "user",
43 SkillScope::Project => "project",
44 }
45 }
46}
47
48#[derive(Debug, Clone)]
49pub struct AgentSkill {
50 pub name: String,
51 pub description: String,
52 pub compatibility: Option<String>,
53 pub triggers: Vec<String>,
56 pub skill_md_path: PathBuf,
57 pub scope: SkillScope,
58 pub body: String,
59}
60
61#[derive(Debug, Clone)]
62pub struct SkillDiscovery {
63 pub skills: Vec<AgentSkill>,
64 pub project_skills_loaded: bool,
65 pub project_skills_note: Option<String>,
66}
67
68#[derive(Debug, Default, Deserialize)]
69struct SkillFrontmatter {
70 name: Option<String>,
71 description: Option<String>,
72 compatibility: Option<String>,
73 triggers: Option<String>,
75}
76
77pub fn resolve_guidance_path(dir: &Path, candidate_name: &str) -> PathBuf {
78 candidate_name
79 .split('/')
80 .fold(dir.to_path_buf(), |acc, part| acc.join(part))
81}
82
83pub fn guidance_section_title(candidate_name: &str) -> &'static str {
84 match candidate_name {
85 "SKILLS.md" | "SKILL.md" => "PROJECT GUIDANCE",
86 _ => "PROJECT RULES",
87 }
88}
89
90pub fn guidance_status_label(candidate_name: &str) -> &'static str {
91 match candidate_name {
92 "SKILLS.md" | "SKILL.md" => "(workspace guidance)",
93 _ if candidate_name.contains(".local") || candidate_name.ends_with(".local.md") => {
94 "(local override)"
95 }
96 _ => "(shared asset)",
97 }
98}
99
100pub fn discover_agent_skills(
101 workspace_root: &Path,
102 trust_config: &WorkspaceTrustConfig,
103) -> SkillDiscovery {
104 let mut discovered: Vec<AgentSkill> = Vec::new();
105
106 if let Some(home) = dirs::home_dir() {
107 let user_roots = AGENT_SKILL_DIRS
108 .iter()
109 .map(|relative| resolve_guidance_path(&home, relative))
110 .collect::<Vec<_>>();
111 load_skills_from_roots(&mut discovered, &user_roots, SkillScope::User);
112 }
113
114 let trust = resolve_workspace_trust(workspace_root, trust_config);
115 let (project_skills_loaded, project_skills_note) = match trust.policy {
116 WorkspaceTrustPolicy::Trusted => {
117 let project_roots = AGENT_SKILL_DIRS
118 .iter()
119 .map(|relative| resolve_guidance_path(workspace_root, relative))
120 .collect::<Vec<_>>();
121 load_skills_from_roots(&mut discovered, &project_roots, SkillScope::Project);
122 (true, None)
123 }
124 WorkspaceTrustPolicy::RequireApproval => (
125 false,
126 Some(format!(
127 "Project skill directories were skipped because `{}` is not trust-allowlisted.",
128 trust.workspace_display
129 )),
130 ),
131 WorkspaceTrustPolicy::Denied => (
132 false,
133 Some(format!(
134 "Project skill directories were skipped because `{}` is denied by trust policy.",
135 trust.workspace_display
136 )),
137 ),
138 };
139
140 SkillDiscovery {
141 skills: dedupe_skills(discovered),
142 project_skills_loaded,
143 project_skills_note,
144 }
145}
146
147pub fn render_skill_catalog(discovery: &SkillDiscovery, max_chars: usize) -> Option<String> {
148 if discovery.skills.is_empty() && discovery.project_skills_note.is_none() {
149 return None;
150 }
151
152 let mut output = Vec::new();
153 output.push("# Agent Skills Catalog".to_string());
154 output.push(
155 "These skills use progressive disclosure. Read a skill's SKILL.md before following it; only load scripts, references, or assets when the skill calls for them.".to_string(),
156 );
157 if let Some(note) = &discovery.project_skills_note {
158 output.push(format!("- {}", note));
159 }
160
161 let mut remaining = max_chars;
162 for skill in &discovery.skills {
163 if remaining < 150 {
164 output.push("\n... [further skills omitted due to context limit]".to_string());
165 break;
166 }
167 let mut line = format!(
168 "- {} [{}] — {} | SKILL.md: {}",
169 skill.name,
170 skill.scope.label(),
171 skill.description,
172 skill.skill_md_path.display()
173 );
174 if !skill.triggers.is_empty() {
175 line.push_str(&format!(" | auto-activates: {}", skill.triggers.join(", ")));
176 }
177 if let Some(compatibility) = &skill.compatibility {
178 line.push_str(&format!(" | compatibility: {}", compatibility));
179 }
180 remaining = remaining.saturating_sub(line.len());
181 output.push(line);
182 }
183
184 Some(output.join("\n"))
185}
186
187pub fn activate_matching_skills<'a>(
191 discovery: &'a SkillDiscovery,
192 query: &str,
193) -> Vec<&'a AgentSkill> {
194 let q = query.to_lowercase();
195 let workspace_root = crate::tools::file_ops::workspace_root();
196 let ws_exts = workspace_stack_extensions(&workspace_root);
197 let query_paths = extract_query_paths(query);
198
199 let mut matched = Vec::new();
200 for skill in &discovery.skills {
201 let name_lower = skill.name.to_lowercase();
203 if q.contains(&name_lower) {
204 matched.push(skill);
205 continue;
206 }
207
208 let parts: Vec<&str> = name_lower
211 .split(['-', '_', ' '])
212 .filter(|p| p.len() > 3)
213 .collect();
214 if parts.len() >= 2 && parts.iter().all(|p| q.contains(*p)) {
215 matched.push(skill);
216 continue;
217 }
218
219 if !skill.triggers.is_empty() {
221 let trigger_hit = skill
222 .triggers
223 .iter()
224 .any(|pattern| query_paths.iter().any(|path| glob_matches(pattern, path)));
225 if trigger_hit {
226 matched.push(skill);
227 continue;
228 }
229
230 let ws_hit = skill
232 .triggers
233 .iter()
234 .any(|pattern| ws_exts.iter().any(|ext| glob_matches(pattern, ext)));
235 if ws_hit {
236 matched.push(skill);
237 }
238 }
239 }
240 matched
241}
242
243fn glob_matches(pattern: &str, name: &str) -> bool {
245 if let Some(ext_pattern) = pattern.strip_prefix("*.") {
246 name.ends_with(&format!(".{}", ext_pattern)) || name == ext_pattern
248 } else if let Some(prefix) = pattern.strip_suffix('*') {
249 name.starts_with(prefix)
250 } else if pattern.contains('*') {
251 let (pre, suf) = pattern.split_once('*').unwrap();
253 name.starts_with(pre) && name.ends_with(suf)
254 } else {
255 name == pattern
257 }
258}
259
260fn workspace_stack_extensions(root: &std::path::Path) -> Vec<String> {
263 let mut exts: Vec<String> = Vec::new();
264 let markers: &[(&str, &[&str])] = &[
265 ("Cargo.toml", &["x.rs"]),
266 ("go.mod", &["x.go"]),
267 ("CMakeLists.txt", &["x.cpp", "x.c", "x.h"]),
268 ("package.json", &["x.ts", "x.js", "x.tsx", "x.jsx"]),
269 ("tsconfig.json", &["x.ts", "x.tsx"]),
270 ("pyproject.toml", &["x.py"]),
271 ("setup.py", &["x.py"]),
272 ("requirements.txt", &["x.py"]),
273 ("Gemfile", &["x.rb"]),
274 ("pom.xml", &["x.java"]),
275 ("build.gradle", &["x.java", "x.kt"]),
276 ("composer.json", &["x.php"]),
277 ];
278 for (marker, file_exts) in markers {
279 if root.join(marker).exists() {
280 exts.extend(file_exts.iter().map(|s| s.to_string()));
281 }
282 }
283 exts
284}
285
286fn extract_query_paths(query: &str) -> Vec<String> {
289 let known_exts = [
290 "rs", "py", "ts", "js", "tsx", "jsx", "go", "cpp", "c", "h", "java", "kt", "rb", "php",
291 "swift", "cs", "md", "toml", "yaml", "yml", "json", "html", "css", "scss", "sh", "pdf",
292 "txt",
293 ];
294 let mut paths = Vec::new();
295 for token in query.split_whitespace() {
296 let token = token.trim_matches(|c: char| {
297 !c.is_alphanumeric() && c != '.' && c != '/' && c != '_' && c != '-' && c != '@'
298 });
299 let effective = if token.starts_with('@') {
300 &token[1..]
301 } else {
302 token
303 };
304 if let Some(ext) = effective.rsplit('.').next() {
305 if known_exts.contains(&ext.to_lowercase().as_str()) {
306 paths.push(effective.to_string());
307 }
308 }
309 }
310 paths
311}
312
313pub fn render_active_skill_bodies(
316 discovery: &SkillDiscovery,
317 query: &str,
318 max_chars: usize,
319) -> Option<String> {
320 let matches = activate_matching_skills(discovery, query);
321 if matches.is_empty() {
322 return None;
323 }
324 let mut sections: Vec<String> = vec!["# Active Skill Instructions".to_string()];
325 let mut remaining = max_chars;
326 for skill in matches {
327 if remaining < 200 {
328 sections.push("... [further skill bodies omitted — context limit]".to_string());
329 break;
330 }
331 let body = skill.body.trim();
332 if body.is_empty() {
333 continue;
334 }
335 let section = format!("## Skill: {}\n{}", skill.name, body);
336 let entry = if section.len() > remaining {
337 format!(
338 "{}\n... [skill body truncated]",
339 §ion[..remaining.saturating_sub(30)]
340 )
341 } else {
342 section
343 };
344 remaining = remaining.saturating_sub(entry.len());
345 sections.push(entry);
346 }
347 if sections.len() <= 1 {
348 return None;
349 }
350 Some(sections.join("\n\n"))
351}
352
353pub fn render_skills_report(discovery: &SkillDiscovery) -> String {
354 let mut report = String::from("## Agent Skills\n\n");
355 report.push_str(&format!(
356 "Project skill directories: {}\n\n",
357 if discovery.project_skills_loaded {
358 "loaded"
359 } else {
360 "skipped"
361 }
362 ));
363 if let Some(note) = &discovery.project_skills_note {
364 report.push_str(note);
365 report.push_str("\n\n");
366 }
367 if discovery.skills.is_empty() {
368 report.push_str("No Agent Skills were discovered.\n\n");
369 report.push_str("Scanned locations:\n");
370 report.push_str("- `<project>/.agents/skills/`\n");
371 report.push_str("- `<project>/.hematite/skills/`\n");
372 report.push_str("- `~/.agents/skills/`\n");
373 report.push_str("- `~/.hematite/skills/`\n");
374 report.push_str(
375 "\nAgent Skills are directory-based and require a `SKILL.md` file at the skill root.",
376 );
377 return report;
378 }
379
380 report.push_str("Discovered skills:\n");
381 for skill in &discovery.skills {
382 report.push_str(&format!(
383 "- `{}` [{}] — {}\n SKILL.md: {}\n",
384 skill.name,
385 skill.scope.label(),
386 skill.description,
387 skill.skill_md_path.display()
388 ));
389 if !skill.triggers.is_empty() {
390 report.push_str(&format!(
391 " auto-activates: {}\n",
392 skill.triggers.join(", ")
393 ));
394 }
395 if let Some(compatibility) = &skill.compatibility {
396 report.push_str(&format!(" compatibility: {}\n", compatibility));
397 }
398 }
399 report
400}
401
402pub fn discover_instruction_files(cwd: &Path) -> Vec<InstructionFile> {
404 let mut directories = Vec::new();
405 let mut cursor = Some(cwd);
406 while let Some(dir) = cursor {
407 directories.push(dir.to_path_buf());
408 cursor = dir.parent();
409 }
410 directories.reverse();
411
412 let mut files = Vec::new();
413 let mut seen_hashes = HashSet::new();
414
415 for dir in directories {
416 for candidate_name in PROJECT_GUIDANCE_FILES {
417 let candidate_path = resolve_guidance_path(&dir, candidate_name);
418
419 if let Ok(content) = fs::read_to_string(&candidate_path) {
420 let trimmed = content.trim();
421 if !trimmed.is_empty() {
422 let hash = stable_hash(trimmed);
424 if seen_hashes.contains(&hash) {
425 continue;
426 }
427 seen_hashes.insert(hash);
428 files.push(InstructionFile {
429 path: candidate_path,
430 content: trimmed.to_string(),
431 });
432 }
433 }
434 }
435 }
436 files
437}
438
439fn stable_hash(s: &str) -> u64 {
440 use std::collections::hash_map::DefaultHasher;
441 use std::hash::{Hash, Hasher};
442 let mut hasher = DefaultHasher::new();
443 s.hash(&mut hasher);
444 hasher.finish()
445}
446
447pub fn render_instructions(files: &[InstructionFile], max_chars: usize) -> Option<String> {
449 if files.is_empty() {
450 return None;
451 }
452
453 let mut output = Vec::new();
454 output.push("# Project Instructions And Skills".to_string());
455 output.push(
456 "These guidance files were discovered in the directory tree for the current repository:"
457 .to_string(),
458 );
459
460 let mut remaining = max_chars;
461 for file in files {
462 if remaining < 100 {
463 output.push("\n... [further instructions omitted due to context limit]".to_string());
464 break;
465 }
466
467 let content = if file.content.len() > remaining {
468 format!("{}\n... [truncated]", &file.content[..remaining - 20])
469 } else {
470 file.content.clone()
471 };
472
473 remaining = remaining.saturating_sub(content.len());
474 output.push(format!("\n## Source: {}\n{}", file.path.display(), content));
475 }
476
477 Some(output.join("\n"))
478}
479
480fn load_skills_from_roots(into: &mut Vec<AgentSkill>, roots: &[PathBuf], scope: SkillScope) {
481 for root in roots {
482 if !root.exists() || !root.is_dir() {
483 continue;
484 }
485 for skill_md in discover_skill_markdown_files(root) {
486 if let Some(skill) = parse_agent_skill(&skill_md, scope) {
487 into.push(skill);
488 }
489 }
490 }
491}
492
493fn discover_skill_markdown_files(root: &Path) -> Vec<PathBuf> {
494 let mut files = Vec::new();
495 for entry in walkdir::WalkDir::new(root)
496 .min_depth(2)
497 .max_depth(4)
498 .into_iter()
499 .filter_map(Result::ok)
500 {
501 if !entry.file_type().is_file() {
502 continue;
503 }
504 if entry.file_name() != "SKILL.md" {
505 continue;
506 }
507 files.push(entry.into_path());
508 }
509 files
510}
511
512fn parse_agent_skill(skill_md_path: &Path, scope: SkillScope) -> Option<AgentSkill> {
513 let content = fs::read_to_string(skill_md_path).ok()?;
514 let (frontmatter, body) = split_frontmatter(&content)?;
515 let parsed = parse_frontmatter(&frontmatter)?;
516 let name = parsed.name?.trim().to_string();
517 let description = parsed.description?.trim().to_string();
518 if name.is_empty() || description.is_empty() {
519 return None;
520 }
521 let triggers = parsed
522 .triggers
523 .map(|t| {
524 t.split(',')
525 .map(|p| p.trim().to_string())
526 .filter(|p| !p.is_empty())
527 .collect()
528 })
529 .unwrap_or_default();
530 Some(AgentSkill {
531 name,
532 description,
533 compatibility: parsed
534 .compatibility
535 .map(|value| value.trim().to_string())
536 .filter(|value| !value.is_empty()),
537 triggers,
538 skill_md_path: skill_md_path.to_path_buf(),
539 scope,
540 body: body.trim().to_string(),
541 })
542}
543
544fn split_frontmatter(content: &str) -> Option<(String, String)> {
545 let mut lines = content.lines();
546 if lines.next()?.trim() != "---" {
547 return None;
548 }
549 let mut frontmatter = Vec::new();
550 let mut body = Vec::new();
551 let mut in_frontmatter = true;
552 for line in lines {
553 if in_frontmatter && line.trim() == "---" {
554 in_frontmatter = false;
555 continue;
556 }
557 if in_frontmatter {
558 frontmatter.push(line);
559 } else {
560 body.push(line);
561 }
562 }
563 if in_frontmatter {
564 return None;
565 }
566 Some((frontmatter.join("\n"), body.join("\n")))
567}
568
569fn parse_frontmatter(frontmatter: &str) -> Option<SkillFrontmatter> {
570 serde_yaml::from_str::<SkillFrontmatter>(frontmatter)
571 .ok()
572 .or_else(|| parse_frontmatter_fallback(frontmatter))
573}
574
575fn parse_frontmatter_fallback(frontmatter: &str) -> Option<SkillFrontmatter> {
576 let mut parsed = SkillFrontmatter::default();
577 for line in frontmatter.lines() {
578 let trimmed = line.trim();
579 if trimmed.is_empty() || trimmed.starts_with('#') {
580 continue;
581 }
582 let Some((key, value)) = trimmed.split_once(':') else {
583 continue;
584 };
585 let value = value.trim();
586 let value = strip_matching_quotes(value);
587 match key.trim() {
588 "name" => parsed.name = Some(value.to_string()),
589 "description" => parsed.description = Some(value.to_string()),
590 "compatibility" => parsed.compatibility = Some(value.to_string()),
591 "triggers" => parsed.triggers = Some(value.to_string()),
592 _ => {}
593 }
594 }
595 (parsed.name.is_some() || parsed.description.is_some()).then_some(parsed)
596}
597
598fn strip_matching_quotes(value: &str) -> &str {
599 if value.len() >= 2 {
600 let bytes = value.as_bytes();
601 let first = bytes[0] as char;
602 let last = bytes[value.len() - 1] as char;
603 if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
604 return &value[1..value.len() - 1];
605 }
606 }
607 value
608}
609
610fn dedupe_skills(skills: Vec<AgentSkill>) -> Vec<AgentSkill> {
611 let mut deduped = Vec::new();
612 let mut indexes: HashMap<String, usize> = HashMap::new();
613 for skill in skills {
614 if let Some(index) = indexes.get(&skill.name).copied() {
615 deduped[index] = skill;
616 } else {
617 indexes.insert(skill.name.clone(), deduped.len());
618 deduped.push(skill);
619 }
620 }
621 deduped.sort_by(|left, right| left.name.cmp(&right.name));
622 deduped
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628 use std::path::PathBuf;
629
630 #[test]
631 fn fallback_frontmatter_handles_unquoted_colons() {
632 let parsed = parse_frontmatter(
633 "name: pdf-processing\ndescription: Use when: PDFs, forms, or extraction are involved\ncompatibility: Requires Python 3.11+: tested locally",
634 )
635 .unwrap();
636
637 assert_eq!(parsed.name.as_deref(), Some("pdf-processing"));
638 assert_eq!(
639 parsed.description.as_deref(),
640 Some("Use when: PDFs, forms, or extraction are involved")
641 );
642 assert_eq!(
643 parsed.compatibility.as_deref(),
644 Some("Requires Python 3.11+: tested locally")
645 );
646 }
647
648 #[test]
649 fn project_skill_overrides_user_skill_on_name_collision() {
650 let temp = tempfile::tempdir().unwrap();
651 let user_root = temp.path().join("user");
652 let project_root = temp.path().join("project");
653
654 fs::create_dir_all(user_root.join(".agents/skills/review")).unwrap();
655 fs::create_dir_all(project_root.join(".agents/skills/review")).unwrap();
656
657 fs::write(
658 user_root.join(".agents/skills/review/SKILL.md"),
659 "---\nname: review\ndescription: User skill.\n---\n",
660 )
661 .unwrap();
662 fs::write(
663 project_root.join(".agents/skills/review/SKILL.md"),
664 "---\nname: review\ndescription: Project skill.\n---\n",
665 )
666 .unwrap();
667
668 let mut discovered = Vec::new();
669 load_skills_from_roots(
670 &mut discovered,
671 &[user_root.join(".agents/skills")],
672 SkillScope::User,
673 );
674 load_skills_from_roots(
675 &mut discovered,
676 &[project_root.join(".agents/skills")],
677 SkillScope::Project,
678 );
679
680 let deduped = dedupe_skills(discovered);
681 assert_eq!(deduped.len(), 1);
682 assert_eq!(deduped[0].description, "Project skill.");
683 assert_eq!(deduped[0].scope, SkillScope::Project);
684 }
685
686 #[test]
687 fn trusted_workspace_discovers_project_skill_dirs() {
688 let temp = tempfile::tempdir().unwrap();
689 let workspace = temp.path().join("workspace");
690 let user_home = temp.path().join("home");
691
692 fs::create_dir_all(workspace.join(".agents/skills/code-review")).unwrap();
693 fs::create_dir_all(user_home.join(".agents/skills/global-review")).unwrap();
694 fs::write(
695 workspace.join(".agents/skills/code-review/SKILL.md"),
696 "---\nname: code-review\ndescription: Review diffs.\n---\n",
697 )
698 .unwrap();
699 fs::write(
700 user_home.join(".agents/skills/global-review/SKILL.md"),
701 "---\nname: global-review\ndescription: Global review skill.\n---\n",
702 )
703 .unwrap();
704
705 let mut discovered = Vec::new();
706 load_skills_from_roots(
707 &mut discovered,
708 &[user_home.join(".agents/skills")],
709 SkillScope::User,
710 );
711 load_skills_from_roots(
712 &mut discovered,
713 &[workspace.join(".agents/skills")],
714 SkillScope::Project,
715 );
716 let deduped = dedupe_skills(discovered);
717
718 let names = deduped
719 .into_iter()
720 .map(|skill| skill.name)
721 .collect::<Vec<_>>();
722 assert_eq!(
723 names,
724 vec!["code-review".to_string(), "global-review".to_string()]
725 );
726 }
727
728 #[test]
729 fn activate_matching_skills_finds_by_name() {
730 let discovery = SkillDiscovery {
731 skills: vec![
732 AgentSkill {
733 name: "pdf-processing".to_string(),
734 description: "Use when PDFs are involved.".to_string(),
735 compatibility: None,
736 triggers: vec![],
737 skill_md_path: PathBuf::from("/tmp/pdf-processing/SKILL.md"),
738 scope: SkillScope::User,
739 body: "Step 1: extract text.".to_string(),
740 },
741 AgentSkill {
742 name: "code-review".to_string(),
743 description: "Review diffs.".to_string(),
744 compatibility: None,
745 triggers: vec![],
746 skill_md_path: PathBuf::from("/tmp/code-review/SKILL.md"),
747 scope: SkillScope::Project,
748 body: "Review all changed files.".to_string(),
749 },
750 ],
751 project_skills_loaded: true,
752 project_skills_note: None,
753 };
754
755 let m = activate_matching_skills(&discovery, "please use the pdf-processing skill");
757 assert_eq!(m.len(), 1);
758 assert_eq!(m[0].name, "pdf-processing");
759
760 let m2 = activate_matching_skills(&discovery, "can you do a code review of this PR?");
762 assert_eq!(m2.len(), 1);
763 assert_eq!(m2[0].name, "code-review");
764
765 let m3 = activate_matching_skills(&discovery, "what is the weather today?");
767 assert!(m3.is_empty());
768 }
769
770 #[test]
771 fn activate_matching_skills_triggers_on_file_extension() {
772 let discovery = SkillDiscovery {
773 skills: vec![AgentSkill {
774 name: "python-style".to_string(),
775 description: "Python style guide.".to_string(),
776 compatibility: None,
777 triggers: vec!["*.py".to_string()],
778 skill_md_path: PathBuf::from("/tmp/python-style/SKILL.md"),
779 scope: SkillScope::User,
780 body: "Use ruff for linting.".to_string(),
781 }],
782 project_skills_loaded: true,
783 project_skills_note: None,
784 };
785
786 let m = activate_matching_skills(&discovery, "fix the type hints in src/parser.py");
788 assert_eq!(m.len(), 1, "should activate via *.py trigger");
789
790 let m2 = activate_matching_skills(&discovery, "refactor @src/utils.py");
792 assert_eq!(m2.len(), 1, "should activate via @mention .py path");
793
794 let m3 = activate_matching_skills(&discovery, "how does the network stack work?");
796 assert!(m3.is_empty());
797 }
798
799 #[test]
800 fn glob_matches_patterns() {
801 assert!(glob_matches("*.rs", "main.rs"));
802 assert!(glob_matches("*.rs", "src/lib.rs"));
803 assert!(!glob_matches("*.rs", "main.py"));
804 assert!(glob_matches("Cargo.toml", "Cargo.toml"));
805 assert!(!glob_matches("Cargo.toml", "cargo.toml"));
806 assert!(glob_matches("test*", "test_utils.rs"));
807 assert!(!glob_matches("test*", "unit_test.rs"));
808 assert!(glob_matches("*.py", "x.py")); }
810
811 #[test]
812 fn triggers_parsed_from_frontmatter() {
813 let temp = tempfile::tempdir().unwrap();
814 fs::create_dir_all(temp.path().join("py-skill")).unwrap();
815 fs::write(
816 temp.path().join("py-skill/SKILL.md"),
817 "---\nname: py-skill\ndescription: Python helper.\ntriggers: \"*.py, *.pyx\"\n---\n\nDo python things.\n",
818 )
819 .unwrap();
820
821 let skill =
822 parse_agent_skill(&temp.path().join("py-skill/SKILL.md"), SkillScope::User).unwrap();
823 assert_eq!(skill.triggers, vec!["*.py", "*.pyx"]);
824 assert!(skill.body.contains("Do python things."));
825 }
826
827 #[test]
828 fn render_active_skill_bodies_injects_body() {
829 let discovery = SkillDiscovery {
830 skills: vec![AgentSkill {
831 name: "pdf-processing".to_string(),
832 description: "Use when PDFs are involved.".to_string(),
833 compatibility: None,
834 triggers: vec![],
835 skill_md_path: PathBuf::from("/tmp/pdf-processing/SKILL.md"),
836 scope: SkillScope::User,
837 body: "## Instructions\nRun pdftotext first.".to_string(),
838 }],
839 project_skills_loaded: true,
840 project_skills_note: None,
841 };
842
843 let rendered =
844 render_active_skill_bodies(&discovery, "process this pdf-processing task", 8_000);
845 assert!(rendered.is_some());
846 let text = rendered.unwrap();
847 assert!(text.contains("Active Skill Instructions"));
848 assert!(text.contains("Skill: pdf-processing"));
849 assert!(text.contains("pdftotext"));
850
851 let none = render_active_skill_bodies(&discovery, "unrelated query about network", 8_000);
853 assert!(none.is_none());
854 }
855
856 #[test]
857 fn skill_body_captured_from_skill_md() {
858 let temp = tempfile::tempdir().unwrap();
859 fs::create_dir_all(temp.path().join("my-skill")).unwrap();
860 fs::write(
861 temp.path().join("my-skill/SKILL.md"),
862 "---\nname: my-skill\ndescription: A test skill.\n---\n\n## How to use\nDo the thing.\n",
863 )
864 .unwrap();
865
866 let skill =
867 parse_agent_skill(&temp.path().join("my-skill/SKILL.md"), SkillScope::User).unwrap();
868 assert_eq!(skill.name, "my-skill");
869 assert!(skill.body.contains("Do the thing."));
870 }
871
872 #[test]
873 fn guidance_catalog_renders_skill_paths() {
874 let discovery = SkillDiscovery {
875 skills: vec![AgentSkill {
876 name: "code-review".to_string(),
877 description: "Review diffs.".to_string(),
878 compatibility: Some("Requires git".to_string()),
879 triggers: vec![],
880 skill_md_path: PathBuf::from("/tmp/code-review/SKILL.md"),
881 scope: SkillScope::Project,
882 body: String::new(),
883 }],
884 project_skills_loaded: true,
885 project_skills_note: None,
886 };
887
888 let rendered = render_skill_catalog(&discovery, 2_000).unwrap();
889 assert!(rendered.contains("code-review"));
890 assert!(rendered.contains("/tmp/code-review/SKILL.md"));
891 assert!(rendered.contains("Requires git"));
892 }
893}