1use std::collections::{HashMap, HashSet};
2use std::fmt::Write as _;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use serde::Deserialize;
7
8use crate::agent::config::WorkspaceTrustConfig;
9use crate::agent::truncation::safe_head;
10use crate::agent::trust_resolver::{resolve_workspace_trust, WorkspaceTrustPolicy};
11
12pub const PROJECT_GUIDANCE_FILES: &[&str] = &[
13 "AGENTS.md",
14 "agents.md",
15 "CLAUDE.md",
16 ".claude.md",
17 "CLAUDE.local.md",
18 "HEMATITE.md",
19 "HEMATITE.local.md",
20 ".hematite/rules.md",
21 ".hematite/rules.local.md",
22 "SKILLS.md",
23 "SKILL.md",
24 ".hematite/instructions.md",
25];
26
27pub const AGENT_SKILL_DIRS: &[&str] = &[".agents/skills", ".hematite/skills"];
28
29#[derive(Debug, Clone)]
30pub struct InstructionFile {
31 pub path: PathBuf,
32 pub content: String,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum SkillScope {
37 User,
38 Project,
39}
40
41impl SkillScope {
42 pub fn label(self) -> &'static str {
43 match self {
44 SkillScope::User => "user",
45 SkillScope::Project => "project",
46 }
47 }
48}
49
50#[derive(Debug, Clone)]
51pub struct AgentSkill {
52 pub name: String,
53 pub description: String,
54 pub compatibility: Option<String>,
55 pub triggers: Vec<String>,
58 pub skill_md_path: PathBuf,
59 pub scope: SkillScope,
60 pub body: String,
61}
62
63#[derive(Debug, Clone)]
64pub struct SkillDiscovery {
65 pub skills: Vec<AgentSkill>,
66 pub project_skills_loaded: bool,
67 pub project_skills_note: Option<String>,
68}
69
70#[derive(Debug, Default, Deserialize)]
71struct SkillFrontmatter {
72 name: Option<String>,
73 description: Option<String>,
74 compatibility: Option<String>,
75 triggers: Option<String>,
77}
78
79pub fn resolve_guidance_path(dir: &Path, candidate_name: &str) -> PathBuf {
80 candidate_name
81 .split('/')
82 .fold(dir.to_path_buf(), |acc, part| acc.join(part))
83}
84
85pub fn guidance_section_title(candidate_name: &str) -> &'static str {
86 match candidate_name {
87 "SKILLS.md" | "SKILL.md" => "PROJECT GUIDANCE",
88 _ => "PROJECT RULES",
89 }
90}
91
92pub fn guidance_status_label(candidate_name: &str) -> &'static str {
93 match candidate_name {
94 "SKILLS.md" | "SKILL.md" => "(workspace guidance)",
95 _ if candidate_name.contains(".local") || candidate_name.ends_with(".local.md") => {
96 "(local override)"
97 }
98 _ => "(shared asset)",
99 }
100}
101
102pub fn discover_agent_skills(
103 workspace_root: &Path,
104 trust_config: &WorkspaceTrustConfig,
105) -> SkillDiscovery {
106 let mut discovered: Vec<AgentSkill> = Vec::new();
107
108 if let Some(home) = dirs::home_dir() {
109 let user_roots = AGENT_SKILL_DIRS
110 .iter()
111 .map(|relative| resolve_guidance_path(&home, relative))
112 .collect::<Vec<_>>();
113 load_skills_from_roots(&mut discovered, &user_roots, SkillScope::User);
114 }
115
116 let trust = resolve_workspace_trust(workspace_root, trust_config);
117 let (project_skills_loaded, project_skills_note) = match trust.policy {
118 WorkspaceTrustPolicy::Trusted => {
119 let project_roots = AGENT_SKILL_DIRS
120 .iter()
121 .map(|relative| resolve_guidance_path(workspace_root, relative))
122 .collect::<Vec<_>>();
123 load_skills_from_roots(&mut discovered, &project_roots, SkillScope::Project);
124 (true, None)
125 }
126 WorkspaceTrustPolicy::RequireApproval => (
127 false,
128 Some(format!(
129 "Project skill directories were skipped because `{}` is not trust-allowlisted.",
130 trust.workspace_display
131 )),
132 ),
133 WorkspaceTrustPolicy::Denied => (
134 false,
135 Some(format!(
136 "Project skill directories were skipped because `{}` is denied by trust policy.",
137 trust.workspace_display
138 )),
139 ),
140 };
141
142 SkillDiscovery {
143 skills: dedupe_skills(discovered),
144 project_skills_loaded,
145 project_skills_note,
146 }
147}
148
149pub fn render_skill_catalog(discovery: &SkillDiscovery, max_chars: usize) -> Option<String> {
150 if discovery.skills.is_empty() && discovery.project_skills_note.is_none() {
151 return None;
152 }
153
154 let mut output = Vec::with_capacity(discovery.skills.len() + 2);
155 output.push("# Agent Skills Catalog".to_string());
156 output.push(
157 "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(),
158 );
159 if let Some(note) = &discovery.project_skills_note {
160 output.push(format!("- {}", note));
161 }
162
163 let mut remaining = max_chars;
164 for skill in &discovery.skills {
165 if remaining < 150 {
166 output.push("\n... [further skills omitted due to context limit]".to_string());
167 break;
168 }
169 let mut line = format!(
170 "- {} [{}] — {} | SKILL.md: {}",
171 skill.name,
172 skill.scope.label(),
173 skill.description,
174 skill.skill_md_path.display()
175 );
176 if !skill.triggers.is_empty() {
177 let _ = write!(line, " | auto-activates: {}", skill.triggers.join(", "));
178 }
179 if let Some(compatibility) = &skill.compatibility {
180 let _ = write!(line, " | compatibility: {}", compatibility);
181 }
182 remaining = remaining.saturating_sub(line.len());
183 output.push(line);
184 }
185
186 Some(output.join("\n"))
187}
188
189pub fn activate_matching_skills<'a>(
193 discovery: &'a SkillDiscovery,
194 query: &str,
195) -> Vec<&'a AgentSkill> {
196 let q = query.to_lowercase();
197 let workspace_root = crate::tools::file_ops::workspace_root();
198 let ws_exts = workspace_stack_extensions(&workspace_root);
199 let query_paths = extract_query_paths(query);
200
201 let mut matched = Vec::with_capacity(discovery.skills.len());
202 for skill in &discovery.skills {
203 let name_lower = skill.name.to_lowercase();
205 if q.contains(&name_lower) {
206 matched.push(skill);
207 continue;
208 }
209
210 let parts: Vec<&str> = name_lower
213 .split(['-', '_', ' '])
214 .filter(|p| p.len() > 3)
215 .collect();
216 if parts.len() >= 2 && parts.iter().all(|p| q.contains(*p)) {
217 matched.push(skill);
218 continue;
219 }
220
221 if !skill.triggers.is_empty() {
223 let trigger_hit = skill
224 .triggers
225 .iter()
226 .any(|pattern| query_paths.iter().any(|path| glob_matches(pattern, path)));
227 if trigger_hit {
228 matched.push(skill);
229 continue;
230 }
231
232 let ws_hit = skill
234 .triggers
235 .iter()
236 .any(|pattern| ws_exts.iter().any(|ext| glob_matches(pattern, ext)));
237 if ws_hit {
238 matched.push(skill);
239 }
240 }
241 }
242 matched
243}
244
245fn glob_matches(pattern: &str, name: &str) -> bool {
247 if let Some(ext_pattern) = pattern.strip_prefix("*.") {
248 name.ends_with(&format!(".{}", ext_pattern)) || name == ext_pattern
250 } else if let Some(prefix) = pattern.strip_suffix('*') {
251 name.starts_with(prefix)
252 } else if pattern.contains('*') {
253 let (pre, suf) = pattern.split_once('*').unwrap();
255 name.starts_with(pre) && name.ends_with(suf)
256 } else {
257 name == pattern
259 }
260}
261
262fn workspace_stack_extensions(root: &std::path::Path) -> Vec<String> {
265 let mut exts: Vec<String> = Vec::with_capacity(8);
266 let markers: &[(&str, &[&str])] = &[
267 ("Cargo.toml", &["x.rs"]),
268 ("go.mod", &["x.go"]),
269 ("CMakeLists.txt", &["x.cpp", "x.c", "x.h"]),
270 ("package.json", &["x.ts", "x.js", "x.tsx", "x.jsx"]),
271 ("tsconfig.json", &["x.ts", "x.tsx"]),
272 ("pyproject.toml", &["x.py"]),
273 ("setup.py", &["x.py"]),
274 ("requirements.txt", &["x.py"]),
275 ("Gemfile", &["x.rb"]),
276 ("pom.xml", &["x.java"]),
277 ("build.gradle", &["x.java", "x.kt"]),
278 ("composer.json", &["x.php"]),
279 ];
280 for (marker, file_exts) in markers {
281 if root.join(marker).exists() {
282 exts.extend(file_exts.iter().map(|s| s.to_string()));
283 }
284 }
285 exts
286}
287
288fn extract_query_paths(query: &str) -> Vec<String> {
291 let known_exts = [
292 "rs", "py", "ts", "js", "tsx", "jsx", "go", "cpp", "c", "h", "java", "kt", "rb", "php",
293 "swift", "cs", "md", "toml", "yaml", "yml", "json", "html", "css", "scss", "sh", "pdf",
294 "txt",
295 ];
296 let mut paths = Vec::new();
297 for token in query.split_whitespace() {
298 let token = token.trim_matches(|c: char| {
299 !c.is_alphanumeric() && c != '.' && c != '/' && c != '_' && c != '-' && c != '@'
300 });
301 let effective = token.strip_prefix('@').unwrap_or(token);
302 if let Some(ext) = effective.rsplit('.').next() {
303 if known_exts.contains(&ext.to_lowercase().as_str()) {
304 paths.push(effective.to_string());
305 }
306 }
307 }
308 paths
309}
310
311pub fn render_active_skill_bodies(
314 discovery: &SkillDiscovery,
315 query: &str,
316 max_chars: usize,
317) -> Option<String> {
318 let matches = activate_matching_skills(discovery, query);
319 if matches.is_empty() {
320 return None;
321 }
322 let mut sections: Vec<String> = vec!["# Active Skill Instructions".to_string()];
323 let mut remaining = max_chars;
324 for skill in matches {
325 if remaining < 200 {
326 sections.push("... [further skill bodies omitted — context limit]".to_string());
327 break;
328 }
329 let body = skill.body.trim();
330 if body.is_empty() {
331 continue;
332 }
333 let section = format!("## Skill: {}\n{}", skill.name, body);
334 let entry = if section.len() > remaining {
335 format!(
336 "{}\n... [skill body truncated]",
337 §ion[..remaining.saturating_sub(30)]
338 )
339 } else {
340 section
341 };
342 remaining = remaining.saturating_sub(entry.len());
343 sections.push(entry);
344 }
345 if sections.len() <= 1 {
346 return None;
347 }
348 Some(sections.join("\n\n"))
349}
350
351pub fn render_skills_report(discovery: &SkillDiscovery) -> String {
352 let mut report = String::from("## Agent Skills\n\n");
353 let _ = write!(
354 report,
355 "Project skill directories: {}\n\n",
356 if discovery.project_skills_loaded {
357 "loaded"
358 } else {
359 "skipped"
360 }
361 );
362 if let Some(note) = &discovery.project_skills_note {
363 report.push_str(note);
364 report.push_str("\n\n");
365 }
366 if discovery.skills.is_empty() {
367 report.push_str("No Agent Skills were discovered.\n\n");
368 report.push_str("Scanned locations:\n");
369 report.push_str("- `<project>/.agents/skills/`\n");
370 report.push_str("- `<project>/.hematite/skills/`\n");
371 report.push_str("- `~/.agents/skills/`\n");
372 report.push_str("- `~/.hematite/skills/`\n");
373 report.push_str(
374 "\nAgent Skills are directory-based and require a `SKILL.md` file at the skill root.",
375 );
376 return report;
377 }
378
379 report.push_str("Discovered skills:\n");
380 for skill in &discovery.skills {
381 let _ = write!(
382 report,
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 let _ = writeln!(report, " auto-activates: {}", skill.triggers.join(", "));
391 }
392 if let Some(compatibility) = &skill.compatibility {
393 let _ = writeln!(report, " compatibility: {}", compatibility);
394 }
395 }
396 report
397}
398
399pub fn discover_instruction_files(cwd: &Path) -> Vec<InstructionFile> {
401 let mut directories = Vec::new();
402 let mut cursor = Some(cwd);
403 while let Some(dir) = cursor {
404 directories.push(dir.to_path_buf());
405 cursor = dir.parent();
406 }
407 directories.reverse();
408
409 let mut files = Vec::new();
410 let mut seen_hashes = HashSet::new();
411
412 for dir in directories {
413 for candidate_name in PROJECT_GUIDANCE_FILES {
414 let candidate_path = resolve_guidance_path(&dir, candidate_name);
415
416 if let Ok(content) = fs::read_to_string(&candidate_path) {
417 let trimmed = content.trim();
418 if !trimmed.is_empty() {
419 let hash = stable_hash(trimmed);
421 if seen_hashes.contains(&hash) {
422 continue;
423 }
424 seen_hashes.insert(hash);
425 files.push(InstructionFile {
426 path: candidate_path,
427 content: trimmed.to_string(),
428 });
429 }
430 }
431 }
432 }
433 files
434}
435
436fn stable_hash(s: &str) -> u64 {
437 use std::collections::hash_map::DefaultHasher;
438 use std::hash::{Hash, Hasher};
439 let mut hasher = DefaultHasher::new();
440 s.hash(&mut hasher);
441 hasher.finish()
442}
443
444pub fn render_instructions(files: &[InstructionFile], max_chars: usize) -> Option<String> {
446 if files.is_empty() {
447 return None;
448 }
449
450 let mut output = Vec::with_capacity(files.len() + 2);
451 output.push("# Project Instructions And Skills".to_string());
452 output.push(
453 "These guidance files were discovered in the directory tree for the current repository:"
454 .to_string(),
455 );
456
457 let mut remaining = max_chars;
458 for file in files {
459 if remaining < 100 {
460 output.push("\n... [further instructions omitted due to context limit]".to_string());
461 break;
462 }
463
464 let content = if file.content.len() > remaining {
465 format!(
466 "{}\n... [truncated]",
467 safe_head(&file.content, remaining.saturating_sub(20))
468 )
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::with_capacity(skills.len());
612 let mut indexes: HashMap<String, usize> = HashMap::with_capacity(skills.len());
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}