aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/skills
diff options
context:
space:
mode:
authorMichelle Tilley <michelle@michelletilley.net>2026-04-23 13:43:01 -0700
committerGitHub <noreply@github.com>2026-04-23 13:43:01 -0700
commit461ef4c43589c6ca68176c180fd04f2755c9f036 (patch)
treec646ea272d6016533c4941592f9a22baa2a54488 /crates/atuin-ai/src/skills
parentfeat: Send user-defined context with `TERMINAL.md` (#3443) (diff)
downloadatuin-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.rs233
-rw-r--r--crates/atuin-ai/src/skills/mod.rs468
-rw-r--r--crates/atuin-ai/src/skills/walker.rs178
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());
+ }
+}