aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/skills/walker.rs
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/walker.rs
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/walker.rs')
-rw-r--r--crates/atuin-ai/src/skills/walker.rs178
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());
+ }
+}