rho-coding-agent 0.7.0

A lightweight agent harness inspired by Pi
use std::{
    collections::HashSet,
    path::{Path, PathBuf},
};

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Skill {
    pub name: String,
    pub description: String,
    pub path: PathBuf,
    pub contents: String,
}

pub fn discover(cwd: &Path) -> Vec<Skill> {
    let home = std::env::var_os("HOME").map(PathBuf::from);
    discover_with_home(cwd, home.as_deref())
}

pub fn discover_with_home(cwd: &Path, home: Option<&Path>) -> Vec<Skill> {
    let mut roots = Vec::new();
    if let Some(home) = home {
        roots.push(home.join(".rho").join("skills"));
        roots.push(home.join(".agents").join("skills"));
    }
    roots.extend(
        crate::workspace::project_ancestor_dirs(cwd)
            .into_iter()
            .rev()
            .map(|path| path.join(".agents").join("skills")),
    );

    let mut seen = HashSet::new();
    roots
        .into_iter()
        .flat_map(|root| skill_paths(&root))
        .filter_map(|path| read_skill(&path).ok())
        .filter(|skill| seen.insert(skill.name.clone()))
        .collect()
}

fn skill_paths(root: &Path) -> Vec<PathBuf> {
    let Ok(entries) = std::fs::read_dir(root) else {
        return Vec::new();
    };

    let mut paths: Vec<_> = entries
        .filter_map(Result::ok)
        .filter_map(|entry| {
            let path = entry.path();
            if path.is_dir() {
                Some(path.join("SKILL.md"))
            } else {
                None
            }
        })
        .collect();
    paths.sort();
    paths
}

fn read_skill(path: &Path) -> anyhow::Result<Skill> {
    let contents = std::fs::read_to_string(path)?;
    let frontmatter = parse_frontmatter(&contents)?;
    let name = frontmatter
        .iter()
        .find(|(key, _)| key == "name")
        .map(|(_, value)| value.to_string())
        .ok_or_else(|| anyhow::anyhow!("missing required name"))?;
    let description = frontmatter
        .iter()
        .find(|(key, _)| key == "description")
        .map(|(_, value)| value.to_string())
        .ok_or_else(|| anyhow::anyhow!("missing required description"))?;

    validate_name(&name)?;
    validate_description(&description)?;
    let directory_name = path
        .parent()
        .and_then(Path::file_name)
        .and_then(|name| name.to_str())
        .ok_or_else(|| anyhow::anyhow!("missing skill directory name"))?;
    if name != directory_name {
        anyhow::bail!("skill name must match directory name");
    }

    Ok(Skill {
        name,
        description,
        path: path.to_path_buf(),
        contents,
    })
}

fn parse_frontmatter(contents: &str) -> anyhow::Result<Vec<(String, String)>> {
    let lines: Vec<_> = contents.lines().collect();
    if lines.first().copied() != Some("---") {
        anyhow::bail!("SKILL.md must start with YAML frontmatter");
    }

    let mut fields = Vec::new();
    let mut index = 1;
    while index < lines.len() {
        let line = lines[index];
        if line == "---" {
            return Ok(fields);
        }
        index += 1;
        if line.starts_with(' ') || line.starts_with('\t') || line.trim().is_empty() {
            continue;
        }
        let Some((key, value)) = line.split_once(':') else {
            continue;
        };
        let key = key.trim();
        let value = value.trim();
        if !matches!(key, "name" | "description" | "license" | "compatibility") {
            continue;
        }

        let value = if let Some(block_style) = yaml_block_style(value) {
            let mut block_lines = Vec::new();
            while index < lines.len() {
                let block_line = lines[index];
                if block_line == "---" {
                    break;
                }
                if !block_line.starts_with(' ') && !block_line.starts_with('\t') {
                    break;
                }
                block_lines.push(block_line.trim());
                index += 1;
            }
            if block_style == '>' {
                block_lines.join(" ").trim().to_string()
            } else {
                block_lines.join("\n").trim().to_string()
            }
        } else {
            unquote_yaml_scalar(value)
        };
        fields.push((key.to_string(), value));
    }

    anyhow::bail!("unterminated YAML frontmatter")
}

fn yaml_block_style(value: &str) -> Option<char> {
    match value {
        "|" | "|-" | "|+" => Some('|'),
        ">" | ">-" | ">+" => Some('>'),
        _ => None,
    }
}

fn unquote_yaml_scalar(value: &str) -> String {
    let trimmed = value.trim();
    if trimmed.len() >= 2
        && ((trimmed.starts_with('"') && trimmed.ends_with('"'))
            || (trimmed.starts_with('\'') && trimmed.ends_with('\'')))
    {
        trimmed[1..trimmed.len() - 1].to_string()
    } else {
        trimmed.to_string()
    }
}

fn validate_name(name: &str) -> anyhow::Result<()> {
    if name.is_empty() || name.len() > 64 {
        anyhow::bail!("skill name must be 1-64 characters");
    }
    let bytes = name.as_bytes();
    if bytes.first() == Some(&b'-') || bytes.last() == Some(&b'-') || name.contains("--") {
        anyhow::bail!("skill name must use single hyphen separators");
    }
    if !bytes
        .iter()
        .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
    {
        anyhow::bail!("skill name must be lowercase alphanumeric with hyphen separators");
    }
    Ok(())
}

fn validate_description(description: &str) -> anyhow::Result<()> {
    if description.is_empty() || description.len() > 1024 {
        anyhow::bail!("skill description must be 1-1024 characters");
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use tempfile::TempDir;

    use super::*;

    #[test]
    fn discovers_valid_skills_in_order() {
        let home = TempDir::new().unwrap();
        let project = TempDir::new().unwrap();
        write_skill(
            home.path(),
            ".rho/skills/rho-skill",
            "rho-skill",
            "rho desc",
        );
        write_skill(
            home.path(),
            ".agents/skills/agent-skill",
            "agent-skill",
            "agent desc",
        );
        write_skill(
            project.path(),
            ".agents/skills/project-skill",
            "project-skill",
            "project desc",
        );

        let skills = discover_with_home(project.path(), Some(home.path()));

        let names: Vec<_> = skills.iter().map(|skill| skill.name.as_str()).collect();
        assert_eq!(names, ["rho-skill", "agent-skill", "project-skill"]);
    }

    #[test]
    fn discovers_project_skills_from_ancestor_directories() {
        let home = TempDir::new().unwrap();
        let project = TempDir::new().unwrap();
        let child = project.path().join("src/nested");
        std::fs::create_dir_all(&child).unwrap();
        std::fs::create_dir(project.path().join(".git")).unwrap();
        write_skill(
            project.path(),
            ".agents/skills/project-skill",
            "project-skill",
            "project desc",
        );

        let skills = discover_with_home(&child, Some(home.path()));

        assert_eq!(skills.len(), 1);
        assert_eq!(skills[0].name, "project-skill");
    }

    #[test]
    fn prefers_nearest_project_skill_when_names_duplicate() {
        let home = TempDir::new().unwrap();
        let project = TempDir::new().unwrap();
        let child = project.path().join("src/nested");
        std::fs::create_dir_all(&child).unwrap();
        std::fs::create_dir(project.path().join(".git")).unwrap();
        write_skill(
            project.path(),
            ".agents/skills/dup-skill",
            "dup-skill",
            "parent desc",
        );
        write_skill(
            &child,
            ".agents/skills/dup-skill",
            "dup-skill",
            "child desc",
        );

        let skills = discover_with_home(&child, Some(home.path()));

        assert_eq!(skills.len(), 1);
        assert_eq!(skills[0].description, "child desc");
    }

    #[test]
    fn rejects_missing_frontmatter() {
        let root = TempDir::new().unwrap();
        let skill_dir = root.path().join(".rho/skills/bad-skill");
        std::fs::create_dir_all(&skill_dir).unwrap();
        std::fs::write(skill_dir.join("SKILL.md"), "# bad").unwrap();

        let skills = discover_with_home(root.path(), Some(root.path()));

        assert!(skills.is_empty());
    }

    #[test]
    fn rejects_name_that_does_not_match_directory() {
        let root = TempDir::new().unwrap();
        write_skill(root.path(), ".rho/skills/dir-name", "other-name", "desc");

        let skills = discover_with_home(root.path(), Some(root.path()));

        assert!(skills.is_empty());
    }

    #[test]
    fn rejects_invalid_name_format() {
        let root = TempDir::new().unwrap();
        write_skill(root.path(), ".rho/skills/bad--skill", "bad--skill", "desc");

        let skills = discover_with_home(root.path(), Some(root.path()));

        assert!(skills.is_empty());
    }

    #[test]
    fn rejects_empty_description() {
        let root = TempDir::new().unwrap();
        write_skill(root.path(), ".rho/skills/bad-skill", "bad-skill", "");

        let skills = discover_with_home(root.path(), Some(root.path()));

        assert!(skills.is_empty());
    }

    #[test]
    fn parses_block_scalar_description() {
        let root = TempDir::new().unwrap();
        let skill_dir = root.path().join(".rho/skills/block-skill");
        std::fs::create_dir_all(&skill_dir).unwrap();
        std::fs::write(
            skill_dir.join("SKILL.md"),
            "---\nname: block-skill\ndescription: >\n  first line\n  second line\n---\n# block\n",
        )
        .unwrap();

        let skills = discover_with_home(root.path(), Some(root.path()));

        assert_eq!(skills[0].description, "first line second line");
    }

    #[test]
    fn parses_block_scalar_chomping_description() {
        let root = TempDir::new().unwrap();
        let skill_dir = root.path().join(".rho/skills/chomp-skill");
        std::fs::create_dir_all(&skill_dir).unwrap();
        std::fs::write(
            skill_dir.join("SKILL.md"),
            "---\nname: chomp-skill\ndescription: |-\n  first line\n  second line\n---\n# block\n",
        )
        .unwrap();

        let skills = discover_with_home(root.path(), Some(root.path()));

        assert_eq!(skills[0].description, "first line\nsecond line");
    }

    #[test]
    fn skips_duplicate_skill_names_after_first_match() {
        let home = TempDir::new().unwrap();
        let project = TempDir::new().unwrap();
        write_skill(
            home.path(),
            ".rho/skills/dup-skill",
            "dup-skill",
            "first desc",
        );
        write_skill(
            home.path(),
            ".agents/skills/dup-skill",
            "dup-skill",
            "second desc",
        );

        let skills = discover_with_home(project.path(), Some(home.path()));

        assert_eq!(skills.len(), 1);
        assert_eq!(skills[0].description, "first desc");
    }

    fn write_skill(root: &Path, relative_dir: &str, name: &str, description: &str) {
        let skill_dir = root.join(relative_dir);
        std::fs::create_dir_all(&skill_dir).unwrap();
        std::fs::write(
            skill_dir.join("SKILL.md"),
            format!("---\nname: {name}\ndescription: {description}\n---\n# {name}\n"),
        )
        .unwrap();
    }
}