bot-forge 1.0.2

Rust CLI for installing agent skills and developer tools from configurable forms.
Documentation
//! Skill discovery and agent destination resolution.
//!
//! Installation and managed removal share this capability so repository layout parsing and
//! platform-specific agent paths do not belong to execution, generic paths, or item lifecycle.

use std::env;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};

use crate::constants::SKILL_FILE;
use crate::error::ForgeError;
use crate::fsutil::{read_dir, read_to_string};
use crate::model::Agent;
use crate::util::{home_dir, unquote};

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SkillCandidate {
    /// Canonical installation name from frontmatter or the directory fallback.
    pub(crate) name: String,
    /// Directory containing the candidate's `SKILL.md` and payload.
    pub(crate) path: PathBuf,
}

/// Discover skill roots at `root`, `root/skills`, and `root/.claude/skills`.
///
/// Candidates are sorted by name and path, then deduplicated by path. A malformed or absent
/// frontmatter name falls back to the directory name.
///
/// # Errors
///
/// Returns [`ForgeError`] when a candidate directory cannot be enumerated.
pub(crate) fn discover(root: &Path) -> Result<Vec<SkillCandidate>, ForgeError> {
    let mut candidates = Vec::new();
    if root.join(SKILL_FILE).is_file() {
        candidates.push(candidate(root));
    }

    for base in [root.join("skills"), root.join(".claude").join("skills")] {
        if base.is_dir() {
            for entry in read_dir(&base)? {
                let path = entry?;
                if path.join(SKILL_FILE).is_file() {
                    candidates.push(candidate(&path));
                }
            }
        }
    }

    candidates.sort_by(|left, right| left.name.cmp(&right.name).then(left.path.cmp(&right.path)));
    candidates.dedup_by(|left, right| left.path == right.path);
    Ok(candidates)
}

/// Resolve the existing skill directory for an agent, honoring its environment override.
///
/// A missing directory returns `None`; callers decide whether that is an error or a skipped target.
pub(crate) fn agent_dir(agent: Agent) -> Option<PathBuf> {
    let override_var = match agent {
        Agent::Claude => "BOT_FORGE_CLAUDE_DIR",
        Agent::OpenCode => "BOT_FORGE_OPENCODE_DIR",
    };
    if let Ok(path) = env::var(override_var) {
        let path = PathBuf::from(path);
        return path.is_dir().then_some(path);
    }
    let path = match agent {
        Agent::Claude => home_dir().join(".claude").join("skills"),
        Agent::OpenCode if cfg!(windows) => env::var("APPDATA")
            .map(PathBuf::from)
            .unwrap_or_else(|_| home_dir().join("AppData").join("Roaming"))
            .join("opencode")
            .join("skills"),
        Agent::OpenCode => home_dir().join(".config").join("opencode").join("skills"),
    };
    path.is_dir().then_some(path)
}

fn candidate(path: &Path) -> SkillCandidate {
    let fallback = path
        .file_name()
        .and_then(OsStr::to_str)
        .unwrap_or("unnamed")
        .to_string();
    let name = read_to_string(&path.join(SKILL_FILE))
        .ok()
        .and_then(|content| parse_frontmatter_name(&content))
        .unwrap_or(fallback);
    SkillCandidate {
        name,
        path: path.to_path_buf(),
    }
}

fn parse_frontmatter_name(content: &str) -> Option<String> {
    let mut lines = content.lines();
    if lines.next()?.trim() != "---" {
        return None;
    }
    for line in lines {
        let line = line.trim();
        if line == "---" {
            return None;
        }
        if let Some((key, value)) = line.split_once(':')
            && key.trim() == "name"
        {
            return Some(unquote(value.trim()).to_string());
        }
    }
    None
}