dirge-agent 0.7.2

Minimalistic coding agent written in Rust, optimized for memory footprint and performance
use std::collections::HashMap;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone)]
pub struct Skill {
    pub name: String,
    pub description: String,
    pub content: String,
    #[allow(dead_code)]
    pub location: PathBuf,
}

pub fn discover_skills(cwd: &Path) -> Vec<Skill> {
    let mut map: HashMap<String, Skill> = HashMap::new();

    let global_dirs = dirs::home_dir().into_iter().flat_map(|home| {
        [
            home.join(".claude").join("skills"),
            home.join(".opencode").join("skills"),
            home.join(".dirge").join("skills"),
        ]
    });

    // `find_project_ancestor_dirs` returns cwd first, then parents
    // (inner → outer). The map insert below is last-write-wins, so
    // iterating in that natural order makes OUTER repos overwrite
    // INNER — the opposite of opencode's "more specific wins". For
    // a monorepo where both `monorepo/.dirge/skills/foo` and
    // `monorepo/svc/.dirge/skills/foo` exist, the svc-level skill
    // is the one a developer working in svc would expect. Reverse
    // so OUTER repos are visited first (and overwritten by INNER).
    // Audit H13.
    let mut project_ancestors = find_project_ancestor_dirs(cwd);
    project_ancestors.reverse();
    let project_dirs = project_ancestors.into_iter().flat_map(|ancestor| {
        [
            ancestor.join(".claude").join("skills"),
            ancestor.join(".opencode").join("skills"),
            ancestor.join(".dirge").join("skills"),
        ]
    });

    for dir in global_dirs.chain(project_dirs) {
        if let Ok(entries) = std::fs::read_dir(&dir) {
            for entry in entries.flatten() {
                let path = entry.path();
                // UI-5: refuse to load skills whose directory or
                // SKILL.md is a symlink. Symlinks would let a
                // repo plant `.dirge/skills/innocent -> /etc/...`
                // and silently load whatever the link target
                // contains. `std::fs::metadata` follows links;
                // `symlink_metadata` does not.
                let lmeta = match std::fs::symlink_metadata(&path) {
                    Ok(m) => m,
                    Err(_) => continue,
                };
                if lmeta.file_type().is_symlink() {
                    eprintln!("warning: skipping symlinked skill dir {:?}", path);
                    continue;
                }
                if !lmeta.is_dir() {
                    continue;
                }
                let skill_md = path.join("SKILL.md");
                let skill_lmeta = match std::fs::symlink_metadata(&skill_md) {
                    Ok(m) => m,
                    Err(_) => continue,
                };
                if skill_lmeta.file_type().is_symlink() {
                    eprintln!("warning: skipping symlinked SKILL.md at {:?}", skill_md);
                    continue;
                }
                if !skill_lmeta.is_file() {
                    continue;
                }
                // Cap skill content at 1 MB. A skill is meant to be a
                // short markdown instructions file; multi-MB skills
                // would blow up LLM context. If users have legitimate
                // need for larger skills, they should compress and
                // bump this cap deliberately.
                const SKILL_MAX_BYTES: u64 = 1024 * 1024;
                if let Ok(meta) = std::fs::metadata(&skill_md)
                    && meta.len() > SKILL_MAX_BYTES
                {
                    eprintln!(
                        "warning: skipping skill {:?} ({} bytes > 1 MB cap)",
                        skill_md,
                        meta.len(),
                    );
                    continue;
                }
                if let Ok(content) = std::fs::read_to_string(&skill_md)
                    && let Some(skill) = parse_skill(&content, &path)
                {
                    // README contract: "Project skills override
                    // global skills by name." Globals are iterated
                    // first (line 34), so use `insert` (last-write-
                    // wins) — `or_insert` kept the global value
                    // and silently dropped the project override.
                    if !skill.name.is_empty() {
                        map.insert(skill.name.clone(), skill);
                    }
                }
            }
        }
    }

    let mut skills: Vec<Skill> = map.into_values().collect();
    skills.sort_by(|a, b| a.name.cmp(&b.name));
    skills
}

pub fn find_project_ancestor_dirs(cwd: &Path) -> Vec<PathBuf> {
    let mut dirs = Vec::new();
    let mut current = cwd.to_path_buf();
    dirs.push(current.clone());
    loop {
        if current.join(".git").is_dir() && !dirs.contains(&current) {
            dirs.push(current.clone());
        }
        if !current.pop() {
            break;
        }
    }
    dirs
}

fn parse_skill(content: &str, dir_path: &Path) -> Option<Skill> {
    let dir_name = dir_path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("unknown");

    let (frontmatter, body) = split_frontmatter(content);
    let body = body.trim();
    if body.is_empty() {
        return None;
    }

    let (name, description) = if frontmatter.is_empty() {
        (dir_name.to_string(), String::new())
    } else {
        parse_frontmatter(&frontmatter, dir_name)
    };

    // A frontmatter `name:` with an empty value would parse to "" and
    // then any subsequent `skill <empty>` call would silently match
    // the first such entry. Fall back to the directory name in that
    // case so every skill has a usable handle.
    let name = if name.trim().is_empty() {
        dir_name.to_string()
    } else {
        name
    };

    Some(Skill {
        name,
        description,
        content: body.to_string(),
        location: dir_path.to_path_buf(),
    })
}

pub(crate) fn split_frontmatter(content: &str) -> (String, String) {
    let content = if let Some(c) = content.strip_prefix("---\n") {
        c
    } else if let Some(c) = content.strip_prefix("---\r\n") {
        c
    } else {
        return (String::new(), content.to_string());
    };

    if let Some(pos) = content.find("\r\n---") {
        let frontmatter = &content[..pos];
        let body = &content[pos + 5..];
        (frontmatter.to_string(), body.to_string())
    } else if let Some(pos) = content.find("\n---") {
        let frontmatter = &content[..pos];
        let body = &content[pos + 4..];
        (frontmatter.to_string(), body.to_string())
    } else {
        (String::new(), content.to_string())
    }
}

pub(crate) fn parse_frontmatter(frontmatter: &str, default_name: &str) -> (String, String) {
    let mut name = default_name.to_string();
    let mut description = String::new();

    for line in frontmatter.lines() {
        let line = line.trim();
        if let Some(value) = line.strip_prefix("name:") {
            name = value.trim().to_string();
        } else if let Some(value) = line.strip_prefix("description:") {
            description = value.trim().to_string();
        }
    }

    (name, description)
}

pub fn find_skill<'a>(name: &str, skills: &'a [Skill]) -> Option<&'a Skill> {
    skills.iter().find(|s| s.name == name)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_split_frontmatter() {
        let (fm, body) = split_frontmatter("---\nname: test\ndescription: desc\n---\nbody here");
        assert_eq!(fm, "name: test\ndescription: desc");
        assert_eq!(body.trim(), "body here");
    }

    #[test]
    fn test_split_frontmatter_no_fm() {
        let (fm, body) = split_frontmatter("just body");
        assert!(fm.is_empty());
        assert_eq!(body, "just body");
    }

    #[test]
    fn test_split_frontmatter_crlf() {
        let (fm, body) = split_frontmatter("---\r\nname: test\r\n---\r\nbody");
        assert_eq!(fm, "name: test");
        assert_eq!(body.trim(), "body");
    }

    #[test]
    fn test_parse_frontmatter() {
        let (name, desc) = parse_frontmatter("name: my-skill\ndescription: Does stuff", "default");
        assert_eq!(name, "my-skill");
        assert_eq!(desc, "Does stuff");
    }

    #[test]
    fn test_parse_frontmatter_falls_back_to_default_name() {
        let (name, desc) = parse_frontmatter("description: Does stuff", "dir-name");
        assert_eq!(name, "dir-name");
        assert_eq!(desc, "Does stuff");
    }

    #[test]
    fn test_parse_skill_rejects_empty_body() {
        let skill = parse_skill("---\nname: test\n---\n   \n", Path::new("/tmp/test-skill"));
        assert!(skill.is_none());
    }

    #[test]
    fn test_find_skill() {
        let skills = vec![Skill {
            name: "test".into(),
            description: "desc".into(),
            content: "body".into(),
            location: PathBuf::from("/tmp"),
        }];
        assert!(find_skill("test", &skills).is_some());
        assert!(find_skill("missing", &skills).is_none());
    }
}