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/walker.rs | |
| 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/walker.rs')
| -rw-r--r-- | crates/atuin-ai/src/skills/walker.rs | 178 |
1 files changed, 178 insertions, 0 deletions
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()); + } +} |
