aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/skills
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-06-10 22:01:45 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-06-10 22:01:45 +0200
commit5e31a81cd2207f053b8cd8ad84ebe2a2f691b29d (patch)
tree5d76811ab0d693c01fa472d41aa2ceaf3bd0b415 /crates/atuin-ai/src/skills
parentchore: Remove unneeded files (diff)
downloadatuin-5e31a81cd2207f053b8cd8ad84ebe2a2f691b29d.zip
chore: Remove some unused rust code
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, 0 insertions, 879 deletions
diff --git a/crates/atuin-ai/src/skills/frontmatter.rs b/crates/atuin-ai/src/skills/frontmatter.rs
deleted file mode 100644
index 759dffcc..00000000
--- a/crates/atuin-ai/src/skills/frontmatter.rs
+++ /dev/null
@@ -1,233 +0,0 @@
-//! 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
deleted file mode 100644
index 36b3a2ae..00000000
--- a/crates/atuin-ai/src/skills/mod.rs
+++ /dev/null
@@ -1,468 +0,0 @@
-//! 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
deleted file mode 100644
index b93845f9..00000000
--- a/crates/atuin-ai/src/skills/walker.rs
+++ /dev/null
@@ -1,178 +0,0 @@
-//! 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());
- }
-}