diff options
| author | Michelle Tilley <michelle@michelletilley.net> | 2026-04-23 13:43:01 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-23 13:43:01 -0700 |
| commit | 461ef4c43589c6ca68176c180fd04f2755c9f036 (patch) | |
| tree | c646ea272d6016533c4941592f9a22baa2a54488 /crates/atuin-ai/src/skills | |
| parent | feat: Send user-defined context with `TERMINAL.md` (#3443) (diff) | |
| download | atuin-461ef4c43589c6ca68176c180fd04f2755c9f036.zip | |
feat: Add skill discovery, loading, and invocation (#3444)
Adds a skills system that lets users define reusable LLM instructions as `SKILL.md` files with YAML frontmatter.
Diffstat (limited to 'crates/atuin-ai/src/skills')
| -rw-r--r-- | crates/atuin-ai/src/skills/frontmatter.rs | 233 | ||||
| -rw-r--r-- | crates/atuin-ai/src/skills/mod.rs | 468 | ||||
| -rw-r--r-- | crates/atuin-ai/src/skills/walker.rs | 178 |
3 files changed, 879 insertions, 0 deletions
diff --git a/crates/atuin-ai/src/skills/frontmatter.rs b/crates/atuin-ai/src/skills/frontmatter.rs new file mode 100644 index 00000000..759dffcc --- /dev/null +++ b/crates/atuin-ai/src/skills/frontmatter.rs @@ -0,0 +1,233 @@ +//! YAML frontmatter parsing for `SKILL.md` files. +//! +//! Extracts the YAML block between `---` delimiters and parses it with +//! `yaml-rust2`. Returns the parsed fields and the byte offset where the +//! body begins (after the closing `---`). + +use yaml_rust2::YamlLoader; + +/// Parsed frontmatter fields from a `SKILL.md` file. +#[derive(Debug, Default)] +pub(crate) struct Frontmatter { + pub name: Option<String>, + pub description: Option<String>, + pub disable_model_invocation: bool, +} + +/// Result of splitting a skill file into frontmatter + body. +#[derive(Debug)] +pub(crate) struct ParsedSkillFile { + pub frontmatter: Frontmatter, + /// Everything after the closing `---` delimiter. + pub body: String, +} + +/// Parse a `SKILL.md` file's content into frontmatter and body. +/// +/// If no frontmatter delimiters are found, all content is treated as body +/// with default frontmatter. +pub(crate) fn parse(content: &str) -> ParsedSkillFile { + let Some((yaml_str, body)) = split_frontmatter(content) else { + return ParsedSkillFile { + frontmatter: Frontmatter::default(), + body: content.to_string(), + }; + }; + + let frontmatter = match YamlLoader::load_from_str(yaml_str) { + Ok(docs) if !docs.is_empty() => extract_fields(&docs[0]), + Ok(_) => Frontmatter::default(), + Err(e) => { + tracing::warn!("Failed to parse skill frontmatter: {e}"); + Frontmatter::default() + } + }; + + ParsedSkillFile { frontmatter, body } +} + +/// Split content on `---` delimiters. Returns `(yaml_str, body)` or `None` +/// if frontmatter is not present. +fn split_frontmatter(content: &str) -> Option<(&str, String)> { + let trimmed = content.trim_start(); + + // Must start with `---` + if !trimmed.starts_with("---") { + return None; + } + + // Find the end of the opening delimiter line + let after_open = trimmed.get(3..)?.trim_start_matches(|c: char| c != '\n'); + let after_open = after_open.strip_prefix('\n').unwrap_or(after_open); + + // Find the closing `---` + let close_pos = after_open + .lines() + .enumerate() + .find(|(_, line)| line.trim() == "---") + .map(|(i, _)| { + after_open + .lines() + .take(i) + .map(|l| l.len() + 1) // +1 for newline + .sum::<usize>() + })?; + + let yaml_str = &after_open[..close_pos]; + let rest = &after_open[close_pos..]; + // Skip the closing `---` line + let body = rest + .strip_prefix("---") + .unwrap_or(rest) + .trim_start_matches(|c: char| c != '\n'); + let body = body.strip_prefix('\n').unwrap_or(body); + + Some((yaml_str, body.to_string())) +} + +fn extract_fields(doc: &yaml_rust2::Yaml) -> Frontmatter { + use yaml_rust2::Yaml; + + let name = match &doc["name"] { + Yaml::String(s) => Some(s.clone()), + _ => None, + }; + + let description = match &doc["description"] { + Yaml::String(s) => Some(s.trim().to_string()), + _ => None, + }; + + let disable_model_invocation = match &doc["disable-model-invocation"] { + Yaml::Boolean(b) => *b, + Yaml::String(s) => matches!(s.as_str(), "true" | "yes" | "1"), + _ => false, + }; + + Frontmatter { + name, + description, + disable_model_invocation, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn basic_frontmatter() { + let content = "\ +--- +name: my-skill +description: A test skill +disable-model-invocation: true +--- + +Body content here. +"; + let parsed = parse(content); + assert_eq!(parsed.frontmatter.name.as_deref(), Some("my-skill")); + assert_eq!( + parsed.frontmatter.description.as_deref(), + Some("A test skill") + ); + assert!(parsed.frontmatter.disable_model_invocation); + assert_eq!(parsed.body.trim(), "Body content here."); + } + + #[test] + fn multiline_folded_description() { + let content = "\ +--- +name: release +description: > + Orchestrate a multi-step release — version bumping, changelog + generation, PR creation, tagging, and publishing. +disable-model-invocation: true +--- + +# Release steps +"; + let parsed = parse(content); + assert_eq!(parsed.frontmatter.name.as_deref(), Some("release")); + let desc = parsed.frontmatter.description.unwrap(); + assert!(desc.contains("Orchestrate a multi-step release")); + assert!(desc.contains("publishing")); + assert!(parsed.frontmatter.disable_model_invocation); + assert!(parsed.body.contains("# Release steps")); + } + + #[test] + fn no_frontmatter() { + let content = "Just a body with no frontmatter."; + let parsed = parse(content); + assert!(parsed.frontmatter.name.is_none()); + assert!(parsed.frontmatter.description.is_none()); + assert!(!parsed.frontmatter.disable_model_invocation); + assert_eq!(parsed.body, content); + } + + #[test] + fn empty_frontmatter() { + let content = "\ +--- +--- + +Body after empty frontmatter. +"; + let parsed = parse(content); + assert!(parsed.frontmatter.name.is_none()); + assert!(parsed.frontmatter.description.is_none()); + assert_eq!(parsed.body.trim(), "Body after empty frontmatter."); + } + + #[test] + fn missing_fields_use_defaults() { + let content = "\ +--- +name: partial +--- + +Some body. +"; + let parsed = parse(content); + assert_eq!(parsed.frontmatter.name.as_deref(), Some("partial")); + assert!(parsed.frontmatter.description.is_none()); + assert!(!parsed.frontmatter.disable_model_invocation); + } + + #[test] + fn unknown_fields_ignored() { + let content = "\ +--- +name: my-skill +future-field: some value +another: 42 +--- + +Body. +"; + let parsed = parse(content); + assert_eq!(parsed.frontmatter.name.as_deref(), Some("my-skill")); + } + + #[test] + fn body_with_triple_dashes() { + let content = "\ +--- +name: test +--- + +Some body. + +--- + +More body after a horizontal rule. +"; + let parsed = parse(content); + assert_eq!(parsed.frontmatter.name.as_deref(), Some("test")); + assert!(parsed.body.contains("Some body.")); + assert!(parsed.body.contains("More body after a horizontal rule.")); + } +} diff --git a/crates/atuin-ai/src/skills/mod.rs b/crates/atuin-ai/src/skills/mod.rs new file mode 100644 index 00000000..36b3a2ae --- /dev/null +++ b/crates/atuin-ai/src/skills/mod.rs @@ -0,0 +1,468 @@ +//! AI skill discovery, metadata, and lazy loading. +//! +//! Skills are markdown files (`SKILL.md`) with YAML frontmatter that define +//! reusable instructions for the LLM. Only skill metadata (name + description) +//! is sent to the server; full content is loaded on demand via `load_skill`. + +mod frontmatter; +pub(crate) mod walker; + +use std::path::Path; + +use eyre::{Result, eyre}; + +use crate::user_context::interpolate; + +/// Per-skill description truncation limit (before budget calculation). +const MAX_DESCRIPTION_LEN: usize = 1024; + +/// Default total character budget for skill descriptions sent to the server. +const DEFAULT_DESCRIPTION_BUDGET: usize = 9992; + +/// JSON overhead per skill entry: `{"name":"","description":""},` ≈ 30 chars. +const PER_ENTRY_OVERHEAD: usize = 30; + +/// Metadata for a discovered skill. Produced at discovery time from +/// frontmatter only — the body is not read until `load()`. +#[derive(Debug, Clone)] +pub(crate) struct SkillDescriptor { + pub name: String, + pub description: String, + pub source_path: std::path::PathBuf, + pub disable_model_invocation: bool, +} + +/// A name + description pair ready to serialize into the request payload. +#[derive(Debug, Clone, serde::Serialize)] +pub(crate) struct SkillSummary { + pub name: String, + pub description: String, +} + +/// Holds discovered skills and provides lookup, budget packing, and loading. +#[derive(Debug, Clone)] +pub(crate) struct SkillRegistry { + skills: Vec<SkillDescriptor>, +} + +impl SkillRegistry { + /// Discover skills from project and global directories. + pub async fn discover(project_root: Option<&Path>) -> Self { + let global_dir = walker::global_skills_dir(); + let project_dir = project_root.map(walker::project_skills_dir); + + Self::discover_from_dirs(project_dir.as_deref(), &global_dir).await + } + + /// Discover skills from explicit directory paths. Useful for testing. + pub async fn discover_from_dirs( + project_skills_dir: Option<&Path>, + global_skills_dir: &Path, + ) -> Self { + let raw_files = walker::discover(project_skills_dir, global_skills_dir).await; + + let mut skills = Vec::new(); + let mut seen_names = std::collections::HashSet::new(); + + for raw in raw_files { + let parsed = frontmatter::parse(&raw.content); + let fm = parsed.frontmatter; + + let name = fm.name.unwrap_or_else(|| sanitize_name(&raw.dir_name)); + + // Deduplicate: first seen wins (project before global) + if !seen_names.insert(name.clone()) { + continue; + } + + let description = fm + .description + .or_else(|| first_paragraph(&parsed.body)) + .unwrap_or_default(); + + skills.push(SkillDescriptor { + name, + description, + source_path: raw.path, + disable_model_invocation: fm.disable_model_invocation, + }); + } + + Self { skills } + } + + /// Create an empty registry. + #[cfg(test)] + pub fn empty() -> Self { + Self { skills: Vec::new() } + } + + /// Look up a skill by name. + pub fn get(&self, name: &str) -> Option<&SkillDescriptor> { + self.skills.iter().find(|s| s.name == name) + } + + /// All discovered skills. + pub fn all(&self) -> &[SkillDescriptor] { + &self.skills + } + + /// Whether any non-disabled skills exist (determines capability advertisement). + #[cfg(test)] + pub fn has_server_visible_skills(&self) -> bool { + self.skills.iter().any(|s| !s.disable_model_invocation) + } + + /// Pack skill descriptions into the server payload under a character budget. + /// + /// Returns the summaries that fit plus an optional overflow message. + pub fn server_skills(&self) -> (Vec<SkillSummary>, Option<String>) { + self.server_skills_with_budget(DEFAULT_DESCRIPTION_BUDGET) + } + + pub fn server_skills_with_budget(&self, budget: usize) -> (Vec<SkillSummary>, Option<String>) { + let eligible: Vec<&SkillDescriptor> = self + .skills + .iter() + .filter(|s| !s.disable_model_invocation) + .collect(); + + let mut summaries = Vec::new(); + let mut used = 0; + let mut overflow_names = Vec::new(); + + for skill in &eligible { + let truncated_desc = truncate_description(&skill.description, MAX_DESCRIPTION_LEN); + let entry_size = skill.name.len() + truncated_desc.len() + PER_ENTRY_OVERHEAD; + + if used + entry_size > budget && !summaries.is_empty() { + overflow_names.push(skill.name.as_str()); + continue; + } + + used += entry_size; + summaries.push(SkillSummary { + name: skill.name.clone(), + description: truncated_desc, + }); + } + + let overflow = if overflow_names.is_empty() { + None + } else { + Some(format!( + "{} additional skill(s) not listed due to size limits: {}", + overflow_names.len(), + overflow_names.join(", ") + )) + }; + + (summaries, overflow) + } + + /// Load a skill's full body content, with argument substitution and + /// `!`` interpolation applied. + /// + /// `$ARGUMENTS` in the body is replaced with the provided arguments before + /// shell interpolation runs. If `$ARGUMENTS` does not appear in the body + /// and arguments were provided, they are appended as `ARGUMENTS: <value>`. + pub async fn load(&self, name: &str, shell: &str, arguments: Option<&str>) -> Result<String> { + let skill = self + .get(name) + .ok_or_else(|| eyre!("Unknown skill: {name}"))?; + + let content = tokio::fs::read_to_string(&skill.source_path).await?; + let parsed = frontmatter::parse(&content); + let body = parsed.body; + + if body.trim().is_empty() { + return Ok(format!("(Skill '{name}' has no body content)")); + } + + let body = substitute_arguments(&body, arguments); + + Ok(interpolate::interpolate(&body, shell).await) + } +} + +/// Replace `$ARGUMENTS` placeholders in skill body text. +/// +/// If `$ARGUMENTS` appears in the body, all occurrences are replaced with the +/// argument string (or empty string if none). If `$ARGUMENTS` does not appear +/// and arguments were provided, they are appended on a new line. +fn substitute_arguments(body: &str, arguments: Option<&str>) -> String { + let args = arguments.unwrap_or(""); + + if body.contains("$ARGUMENTS") { + return body.replace("$ARGUMENTS", args); + } + + if !args.is_empty() { + return format!("{body}\n\nARGUMENTS: {args}"); + } + + body.to_string() +} + +/// Sanitize a directory name into a valid skill name. +fn sanitize_name(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' { + c + } else { + '-' + } + }) + .collect::<String>() + .to_lowercase() +} + +/// Extract the first non-empty paragraph from markdown body text. +fn first_paragraph(body: &str) -> Option<String> { + let trimmed = body.trim(); + if trimmed.is_empty() { + return None; + } + + let para: String = trimmed + .lines() + .take_while(|line| !line.trim().is_empty()) + .collect::<Vec<_>>() + .join(" "); + + let para = para.trim().to_string(); + if para.is_empty() { None } else { Some(para) } +} + +/// Truncate a description to `max_len` characters, adding ellipsis if cut. +fn truncate_description(desc: &str, max_len: usize) -> String { + if desc.len() <= max_len { + return desc.to_string(); + } + let mut end = max_len.saturating_sub(3); + // Avoid splitting a multi-byte char + while !desc.is_char_boundary(end) && end > 0 { + end -= 1; + } + format!("{}...", &desc[..end]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_name_basic() { + assert_eq!(sanitize_name("My Skill"), "my-skill"); + assert_eq!(sanitize_name("deploy_prod"), "deploy-prod"); + assert_eq!(sanitize_name("code-review"), "code-review"); + } + + #[test] + fn first_paragraph_extraction() { + assert_eq!( + first_paragraph("Hello world\nSecond line\n\nNew paragraph"), + Some("Hello world Second line".to_string()) + ); + assert_eq!(first_paragraph(""), None); + assert_eq!(first_paragraph("\n\n"), None); + assert_eq!( + first_paragraph("Single line"), + Some("Single line".to_string()) + ); + } + + #[test] + fn truncate_description_short() { + assert_eq!(truncate_description("short", 100), "short"); + } + + #[test] + fn substitute_arguments_replaces_placeholder() { + let body = "Deploy $ARGUMENTS to production."; + assert_eq!( + substitute_arguments(body, Some("patch")), + "Deploy patch to production." + ); + } + + #[test] + fn substitute_arguments_multiple_occurrences() { + let body = "Run $ARGUMENTS then verify $ARGUMENTS worked."; + assert_eq!( + substitute_arguments(body, Some("migrate")), + "Run migrate then verify migrate worked." + ); + } + + #[test] + fn substitute_arguments_appends_when_no_placeholder() { + let body = "Do the thing."; + let result = substitute_arguments(body, Some("extra context")); + assert!(result.starts_with("Do the thing.")); + assert!(result.contains("ARGUMENTS: extra context")); + } + + #[test] + fn substitute_arguments_no_args_no_placeholder() { + let body = "Just a body."; + assert_eq!(substitute_arguments(body, None), "Just a body."); + } + + #[test] + fn substitute_arguments_no_args_clears_placeholder() { + let body = "Deploy $ARGUMENTS to production."; + assert_eq!(substitute_arguments(body, None), "Deploy to production."); + } + + #[test] + fn truncate_description_long() { + let long = "a".repeat(600); + let result = truncate_description(&long, 512); + assert!(result.len() <= 512); + assert!(result.ends_with("...")); + } + + #[test] + fn budget_packing() { + let registry = SkillRegistry { + skills: vec![ + SkillDescriptor { + name: "a".to_string(), + description: "Short desc".to_string(), + source_path: "a/SKILL.md".into(), + disable_model_invocation: false, + }, + SkillDescriptor { + name: "b".to_string(), + description: "Another desc".to_string(), + source_path: "b/SKILL.md".into(), + disable_model_invocation: false, + }, + ], + }; + + let (summaries, overflow) = registry.server_skills_with_budget(4096); + assert_eq!(summaries.len(), 2); + assert!(overflow.is_none()); + } + + #[test] + fn budget_overflow() { + let registry = SkillRegistry { + skills: vec![ + SkillDescriptor { + name: "first".to_string(), + description: "x".repeat(200), + source_path: "a/SKILL.md".into(), + disable_model_invocation: false, + }, + SkillDescriptor { + name: "second".to_string(), + description: "y".repeat(200), + source_path: "b/SKILL.md".into(), + disable_model_invocation: false, + }, + ], + }; + + // Budget only fits one + let (summaries, overflow) = registry.server_skills_with_budget(260); + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].name, "first"); + let overflow = overflow.unwrap(); + assert!(overflow.contains("second")); + assert!(overflow.contains("1 additional")); + } + + #[test] + fn disabled_skills_excluded_from_server() { + let registry = SkillRegistry { + skills: vec![ + SkillDescriptor { + name: "visible".to_string(), + description: "I show up".to_string(), + source_path: "a/SKILL.md".into(), + disable_model_invocation: false, + }, + SkillDescriptor { + name: "hidden".to_string(), + description: "I don't".to_string(), + source_path: "b/SKILL.md".into(), + disable_model_invocation: true, + }, + ], + }; + + let (summaries, _) = registry.server_skills(); + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].name, "visible"); + + // But all() includes both + assert_eq!(registry.all().len(), 2); + } + + #[test] + fn has_server_visible_skills() { + let empty = SkillRegistry::empty(); + assert!(!empty.has_server_visible_skills()); + + let all_disabled = SkillRegistry { + skills: vec![SkillDescriptor { + name: "hidden".to_string(), + description: String::new(), + source_path: "a/SKILL.md".into(), + disable_model_invocation: true, + }], + }; + assert!(!all_disabled.has_server_visible_skills()); + + let some_visible = SkillRegistry { + skills: vec![SkillDescriptor { + name: "visible".to_string(), + description: String::new(), + source_path: "a/SKILL.md".into(), + disable_model_invocation: false, + }], + }; + assert!(some_visible.has_server_visible_skills()); + } + + #[tokio::test] + async fn end_to_end_discover() { + let dir = tempfile::tempdir().unwrap(); + let skills_dir = dir.path().join("skills"); + + // Create a skill with frontmatter + let skill_dir = skills_dir.join("my-skill"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: my-skill\ndescription: A test skill\n---\n\nBody here.\n", + ) + .unwrap(); + + // Create a skill with multiline description + let skill_dir2 = skills_dir.join("release"); + std::fs::create_dir_all(&skill_dir2).unwrap(); + std::fs::write( + skill_dir2.join("SKILL.md"), + "---\nname: release\ndescription: >\n Multi-line\n description here.\n---\n\nRelease steps.\n", + ) + .unwrap(); + + let registry = SkillRegistry::discover_from_dirs( + Some(&skills_dir), + &std::path::PathBuf::from("/nonexistent"), + ) + .await; + assert_eq!(registry.all().len(), 2); + + let my_skill = registry.get("my-skill").unwrap(); + assert_eq!(my_skill.description, "A test skill"); + + let release = registry.get("release").unwrap(); + assert!(release.description.contains("Multi-line")); + } +} diff --git a/crates/atuin-ai/src/skills/walker.rs b/crates/atuin-ai/src/skills/walker.rs new file mode 100644 index 00000000..b93845f9 --- /dev/null +++ b/crates/atuin-ai/src/skills/walker.rs @@ -0,0 +1,178 @@ +//! Filesystem discovery for `SKILL.md` files. +//! +//! Recursively scans `.atuin/skills/` directories at the project and global +//! levels. Supports nested directories for organization (e.g. +//! `.atuin/skills/ops/deploy/SKILL.md`). + +use std::path::{Path, PathBuf}; + +const SKILL_FILENAME: &str = "SKILL.md"; + +/// A skill file found on disk, before body interpolation. +#[derive(Debug)] +pub(crate) struct RawSkillFile { + /// Full path to the SKILL.md file. + pub path: PathBuf, + /// The parent directory name, used as fallback skill name. + pub dir_name: String, + /// Whether this is a project-level skill (vs global). + #[allow(dead_code)] + pub is_project: bool, + /// Raw file content. + pub content: String, +} + +/// Discover all `SKILL.md` files across project and global skill directories. +/// +/// Project skills come first in the returned list (higher priority for +/// deduplication). +pub(crate) async fn discover( + project_skills_dir: Option<&Path>, + global_skills_dir: &Path, +) -> Vec<RawSkillFile> { + let mut files = Vec::new(); + + // Project skills first (higher priority) + if let Some(dir) = project_skills_dir.filter(|d| d.is_dir()) { + scan_dir(dir, true, &mut files).await; + } + + // Global skills second + if global_skills_dir.is_dir() { + scan_dir(global_skills_dir, false, &mut files).await; + } + + files +} + +/// The default global skills directory (`~/.config/atuin/skills/`). +pub(crate) fn global_skills_dir() -> PathBuf { + atuin_common::utils::config_dir().join("skills") +} + +/// Given a project working directory, return the project skills directory. +pub(crate) fn project_skills_dir(project_root: &Path) -> PathBuf { + project_root.join(".atuin").join("skills") +} + +/// Recursively scan a directory for `SKILL.md` files. +async fn scan_dir(dir: &Path, is_project: bool, out: &mut Vec<RawSkillFile>) { + let mut entries = match tokio::fs::read_dir(dir).await { + Ok(entries) => entries, + Err(e) => { + tracing::debug!("Could not read skills directory {}: {e}", dir.display()); + return; + } + }; + + let mut subdirs = Vec::new(); + + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + + if path.is_dir() { + // Check for SKILL.md directly in this directory + let skill_path = path.join(SKILL_FILENAME); + if skill_path.is_file() { + let dir_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + match tokio::fs::read_to_string(&skill_path).await { + Ok(content) => { + out.push(RawSkillFile { + path: skill_path, + dir_name, + is_project, + content, + }); + } + Err(e) => { + tracing::warn!("Failed to read skill file {}: {e}", skill_path.display()); + } + } + } + + // Collect subdirectories for recursive scanning + subdirs.push(path); + } + } + + for subdir in subdirs { + Box::pin(scan_dir(&subdir, is_project, out)).await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup_skill(dir: &Path, rel_path: &str, content: &str) { + let skill_dir = dir.join(rel_path); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write(skill_dir.join(SKILL_FILENAME), content).unwrap(); + } + + #[tokio::test] + async fn discovers_project_skills() { + let dir = tempfile::tempdir().unwrap(); + let skills_dir = dir.path().join("skills"); + setup_skill(&skills_dir, "deploy", "---\nname: deploy\n---\nDeploy."); + + let files = discover(Some(&skills_dir), Path::new("/nonexistent")).await; + assert_eq!(files.len(), 1); + assert_eq!(files[0].dir_name, "deploy"); + assert!(files[0].is_project); + } + + #[tokio::test] + async fn discovers_global_skills() { + let dir = tempfile::tempdir().unwrap(); + let skills_dir = dir.path().join("skills"); + setup_skill(&skills_dir, "review", "---\nname: review\n---\nReview."); + + let files = discover(None, &skills_dir).await; + assert_eq!(files.len(), 1); + assert_eq!(files[0].dir_name, "review"); + assert!(!files[0].is_project); + } + + #[tokio::test] + async fn discovers_nested_skills() { + let dir = tempfile::tempdir().unwrap(); + let skills_dir = dir.path().join("skills"); + setup_skill(&skills_dir, "ops/deploy", "---\nname: deploy\n---\n"); + setup_skill(&skills_dir, "ops/rollback", "---\nname: rollback\n---\n"); + + let files = discover(Some(&skills_dir), Path::new("/nonexistent")).await; + assert_eq!(files.len(), 2); + } + + #[tokio::test] + async fn project_comes_before_global() { + let project = tempfile::tempdir().unwrap(); + let global = tempfile::tempdir().unwrap(); + let project_skills = project.path().join("skills"); + let global_skills = global.path().join("skills"); + + setup_skill(&project_skills, "a-skill", "project"); + setup_skill(&global_skills, "b-skill", "global"); + + let files = discover(Some(&project_skills), &global_skills).await; + assert_eq!(files.len(), 2); + assert!(files[0].is_project); + assert!(!files[1].is_project); + } + + #[tokio::test] + async fn missing_directories_handled() { + let files = discover( + Some(Path::new("/does/not/exist")), + Path::new("/also/missing"), + ) + .await; + assert!(files.is_empty()); + } +} |
