dot-ai 0.6.1

A minimal AI agent that lives in your terminal
Documentation
use anyhow::{Context, Result};
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};

use crate::tools::Tool;

#[derive(Debug, Clone)]
pub struct SkillInfo {
    pub name: String,
    pub description: String,
    pub path: PathBuf,
}

pub struct SkillRegistry {
    skills: Vec<SkillInfo>,
}

impl SkillRegistry {
    pub fn discover() -> Self {
        let mut skills = Vec::new();
        let mut seen_names = std::collections::HashSet::new();

        for base in Self::search_paths() {
            if !base.exists() {
                continue;
            }
            let entries = match fs::read_dir(&base) {
                Ok(e) => e,
                Err(_) => continue,
            };
            for entry in entries.flatten() {
                let skill_dir = entry.path();
                if !skill_dir.is_dir() {
                    continue;
                }
                let skill_file = skill_dir.join("SKILL.md");
                if skill_file.exists()
                    && let Some(info) = Self::parse_skill(&skill_file)
                    && seen_names.insert(info.name.clone())
                {
                    skills.push(info);
                }
            }
        }

        tracing::info!("Discovered {} skills", skills.len());
        SkillRegistry { skills }
    }

    fn search_paths() -> Vec<PathBuf> {
        let mut paths = Vec::new();
        let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
        let config_dir = crate::config::Config::config_dir();
        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));

        paths.push(config_dir.join("skills"));
        paths.push(home.join(".agents").join("skills"));
        paths.push(home.join(".claude").join("skills"));

        paths.push(cwd.join(".dot").join("skills"));
        paths.push(cwd.join(".agents").join("skills"));
        paths.push(cwd.join(".claude").join("skills"));

        paths
    }

    fn parse_skill(path: &Path) -> Option<SkillInfo> {
        let content = fs::read_to_string(path).ok()?;
        let name = path.parent()?.file_name()?.to_string_lossy().to_string();

        let description = if let Some(stripped) = content.strip_prefix("---") {
            if let Some(end) = stripped.find("---") {
                let frontmatter = &stripped[..end];
                Self::extract_field(frontmatter, "description")
                    .unwrap_or_else(|| Self::first_meaningful_line(&content, 3 + end + 3))
            } else {
                Self::first_meaningful_line(&content, 0)
            }
        } else {
            Self::first_meaningful_line(&content, 0)
        };

        Some(SkillInfo {
            name,
            description,
            path: path.to_path_buf(),
        })
    }

    fn extract_field(frontmatter: &str, field: &str) -> Option<String> {
        let prefix = format!("{}:", field);
        for line in frontmatter.lines() {
            let trimmed = line.trim();
            if let Some(value) = trimmed.strip_prefix(&prefix) {
                let value = value.trim().trim_matches('"').trim_matches('\'');
                if !value.is_empty() {
                    return Some(value.to_string());
                }
            }
        }
        None
    }

    fn first_meaningful_line(content: &str, skip: usize) -> String {
        content
            .get(skip..)
            .unwrap_or("")
            .lines()
            .find(|l| {
                let t = l.trim();
                !t.is_empty() && !t.starts_with('#') && !t.starts_with("---")
            })
            .unwrap_or("No description")
            .trim()
            .chars()
            .take(120)
            .collect()
    }

    pub fn skills(&self) -> &[SkillInfo] {
        &self.skills
    }

    pub fn is_empty(&self) -> bool {
        self.skills.is_empty()
    }

    pub fn into_tool(self) -> Option<SkillTool> {
        if self.skills.is_empty() {
            return None;
        }
        Some(SkillTool {
            skills: self.skills,
        })
    }
}

pub struct SkillTool {
    skills: Vec<SkillInfo>,
}

impl Tool for SkillTool {
    fn name(&self) -> &str {
        "skill"
    }

    fn description(&self) -> &str {
        "Load a skill by name for specialized domain guidance. Use this when the task matches an available skill."
    }

    fn input_schema(&self) -> Value {
        let skill_names: Vec<&str> = self.skills.iter().map(|s| s.name.as_str()).collect();
        let desc_list: Vec<String> = self
            .skills
            .iter()
            .map(|s| format!("{}: {}", s.name, s.description))
            .collect();

        serde_json::json!({
            "type": "object",
            "properties": {
                "name": {
                    "type": "string",
                    "description": format!("Skill to load. Available: {}", desc_list.join("; ")),
                    "enum": skill_names
                }
            },
            "required": ["name"]
        })
    }

    fn execute(&self, input: Value) -> Result<String> {
        let name = input["name"]
            .as_str()
            .context("Missing required parameter 'name'")?;

        let info = self
            .skills
            .iter()
            .find(|s| s.name == name)
            .with_context(|| {
                format!(
                    "Unknown skill '{}'. Available: {}",
                    name,
                    self.skills
                        .iter()
                        .map(|s| s.name.as_str())
                        .collect::<Vec<_>>()
                        .join(", ")
                )
            })?;

        fs::read_to_string(&info.path).with_context(|| format!("Failed to read skill '{}'", name))
    }
}