rtango 0.5.0

Package manager for AI agent skills, agents, and system instruction files
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use crate::agent::detect::{self, DetectedAgent, DetectedSource, Detector, SourceKind};
use crate::agent::frontmatter::{self, FrontMatter, FrontMatterMapper, split_frontmatter};
use crate::agent::parse::{self, AgentsParser, SkillsParser};
use crate::agent::permission::Permission;
use crate::agent::write::{self, AgentsWriter, FrontMatterWriter, SkillsWriter};
use crate::agent::{Agent, AgentSet, Skill, SkillSet};
use crate::spec::AgentName;

const NAME: &str = "pi";
const DIR: &str = ".pi";

pub struct PiParser;

impl PiParser {
    /// Parse pi agent files from an arbitrary directory.
    ///
    /// pi accepts either `<name>.agent.md` or `<name>.md` (the standard
    /// `parse_standard_agents` only handles `.agent.md`). Used by the
    /// engine for `AgentSet` rules whose `source` points outside pi's
    /// native folder.
    pub fn parse_agents_in(dir: &Path, mapper: &dyn FrontMatterMapper) -> anyhow::Result<AgentSet> {
        let mut agents = Vec::new();
        if !dir.is_dir() {
            return Ok(agents);
        }
        for entry in fs::read_dir(dir)? {
            let entry = entry?;
            let path = entry.path();
            if !path.is_file() {
                continue;
            }
            let file_name = entry.file_name().to_string_lossy().into_owned();
            let Some(name) = file_name
                .strip_suffix(".agent.md")
                .or_else(|| file_name.strip_suffix(".md"))
            else {
                continue;
            };
            let content = fs::read_to_string(&path)?;
            let (yaml, body) = split_frontmatter(&content);
            let front_matter = match yaml {
                Some(y) => mapper.parse_frontmatter(y)?,
                None => FrontMatter::default(),
            };
            agents.push(Agent {
                name: name.to_owned(),
                file: path,
                front_matter,
                body: body.to_string(),
            });
        }
        agents.sort_by_key(|a| a.name.clone());
        Ok(agents)
    }
}

impl SkillsParser for PiParser {
    fn name(&self) -> AgentName {
        AgentName::new(NAME)
    }
    fn parse_skills(&self, root: &Path) -> anyhow::Result<SkillSet> {
        parse::parse_standard_skills(&root.join(format!("{DIR}/skills")), self)
    }
}

impl AgentsParser for PiParser {
    fn name(&self) -> AgentName {
        AgentName::new(NAME)
    }
    fn parse_agents(&self, root: &Path) -> anyhow::Result<AgentSet> {
        let dir = root.join(format!("{DIR}/agents"));
        let mut agents = Vec::new();
        if !dir.is_dir() {
            return Ok(agents);
        }
        for entry in fs::read_dir(dir)? {
            let entry = entry?;
            let path = entry.path();
            if !path.is_file() {
                continue;
            }
            let file_name = entry.file_name().to_string_lossy().into_owned();
            let Some(name) = file_name
                .strip_suffix(".agent.md")
                .or_else(|| file_name.strip_suffix(".md"))
            else {
                continue;
            };
            let content = fs::read_to_string(&path)?;
            let (yaml, body) = split_frontmatter(&content);
            let front_matter = match yaml {
                Some(y) => self.parse_frontmatter(y)?,
                None => FrontMatter::default(),
            };
            agents.push(Agent {
                name: name.to_owned(),
                file: path,
                front_matter,
                body: body.to_string(),
            });
        }
        agents.sort_by_key(|a| a.name.clone());
        Ok(agents)
    }
}

impl FrontMatterMapper for PiParser {
    fn parse_permission(&self, token: &str) -> Permission {
        Permission::Other(token.to_string())
    }

    fn parse_frontmatter(&self, yaml: &str) -> anyhow::Result<FrontMatter> {
        frontmatter::parse_standard_frontmatter(yaml, self)
    }
}

impl FrontMatterWriter for PiParser {
    fn format_permission(&self, _perm: &Permission) -> Option<String> {
        None
    }

    fn format_frontmatter(&self, fm: &FrontMatter) -> String {
        write::format_standard_frontmatter(fm, self)
    }
}

impl SkillsWriter for PiParser {
    fn name(&self) -> AgentName {
        AgentName::new(NAME)
    }
    fn write_skill(&self, root: &Path, skill: &Skill) -> anyhow::Result<PathBuf> {
        write::write_standard_skill(&root.join(format!("{DIR}/skills")), skill, self)
    }
}

impl AgentsWriter for PiParser {
    fn name(&self) -> AgentName {
        AgentName::new(NAME)
    }
    fn write_agent(&self, root: &Path, agent: &Agent) -> anyhow::Result<PathBuf> {
        let dir = root.join(format!("{DIR}/agents"));
        fs::create_dir_all(&dir)?;
        let file = dir.join(format!("{}.md", agent.name));
        let yaml = self.format_frontmatter(&agent.front_matter);
        let content = if yaml.is_empty() {
            agent.body.clone()
        } else {
            frontmatter::join_frontmatter(&yaml, &agent.body)
        };
        fs::write(&file, &content)?;
        Ok(file)
    }
}

impl Detector for PiParser {
    fn name(&self) -> AgentName {
        AgentName::new(NAME)
    }

    fn detect(&self, root: &Path) -> Option<DetectedAgent> {
        let skills_dir = root.join(format!("{DIR}/skills"));
        let agents_dir = root.join(format!("{DIR}/agents"));

        let has_skills = detect::dir_has_standard_skills(&skills_dir);
        let has_agents = agents_dir.is_dir()
            && fs::read_dir(&agents_dir)
                .ok()
                .map(|entries| {
                    entries.filter_map(Result::ok).any(|entry| {
                        let path = entry.path();
                        path.is_file()
                            && path
                                .file_name()
                                .and_then(|name| name.to_str())
                                .is_some_and(|name| name.ends_with(".md") && name != "README.md")
                    })
                })
                .unwrap_or(false);

        if !has_skills && !has_agents {
            return None;
        }

        let mut sources = Vec::new();
        if has_skills {
            sources.push(DetectedSource {
                id: format!("{NAME}-skills"),
                path: PathBuf::from(format!("{DIR}/skills/")),
                kind: SourceKind::SkillSet,
            });
        }
        if has_agents {
            sources.push(DetectedSource {
                id: format!("{NAME}-agents"),
                path: PathBuf::from(format!("{DIR}/agents/")),
                kind: SourceKind::AgentSet,
            });
        }

        Some(DetectedAgent {
            name: Detector::name(self),
            sources,
        })
    }
}