lingshu-plugins 0.10.0

Shared plugin discovery, manifest parsing, skill loading, and runtime helpers
use std::path::{Path, PathBuf};

use lingshu_types::Platform;

use crate::error::PluginError;
use crate::skill::manifest::{SkillManifest, parse_skill_manifest};
use crate::skill::platform::skill_matches_platform;
use crate::skill::readiness::resolve_skill_readiness;
use crate::types::{SkillReadinessStatus, SkillSource};

const EXCLUDED_DIRS: &[&str] = &[
    ".git",
    ".github",
    ".quarantine",
    ".hub",
    "node_modules",
    "target",
    "__pycache__",
];

#[derive(Debug, Clone)]
pub struct LoadedSkill {
    pub path: PathBuf,
    pub source: SkillSource,
    pub manifest: SkillManifest,
    pub readiness: SkillReadinessStatus,
    pub platform_visible: bool,
}

pub fn scan_skills_dir(
    root: &Path,
    source: SkillSource,
    disabled: &[String],
    _platform: Platform,
) -> Result<Vec<LoadedSkill>, PluginError> {
    if !root.exists() {
        return Ok(Vec::new());
    }

    let mut out = Vec::new();
    for skill_file in candidate_skill_files(root, 0)? {
        let manifest = parse_skill_manifest(&skill_file)?;
        if disabled.iter().any(|candidate| candidate == &manifest.name) {
            continue;
        }
        let required_env = manifest
            .required_environment_variables
            .iter()
            .map(|entry| entry.name.clone())
            .chain(
                manifest
                    .collect_secrets
                    .iter()
                    .map(|entry| entry.env_var.clone()),
            )
            .collect::<Vec<_>>();
        out.push(LoadedSkill {
            path: skill_file,
            source,
            platform_visible: skill_matches_platform(&manifest.platforms),
            readiness: resolve_skill_readiness(&required_env),
            manifest,
        });
    }

    out.sort_by(|left, right| left.manifest.name.cmp(&right.manifest.name));
    Ok(out)
}

fn candidate_skill_files(root: &Path, depth: usize) -> Result<Vec<PathBuf>, PluginError> {
    if depth > 2 {
        return Ok(Vec::new());
    }

    let mut out = Vec::new();
    let root_skill = root.join("SKILL.md");
    if root_skill.is_file() {
        out.push(root_skill);
    }
    if depth >= 2 {
        return Ok(out);
    }

    for entry in std::fs::read_dir(root)? {
        let entry = entry?;
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }
        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
            continue;
        };
        if EXCLUDED_DIRS
            .iter()
            .any(|excluded| excluded.eq_ignore_ascii_case(name))
        {
            continue;
        }
        let file = path.join("SKILL.md");
        if file.is_file() {
            out.push(file);
        }
        out.extend(candidate_skill_files(&path, depth + 1)?);
    }
    Ok(out)
}

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

    #[test]
    fn scan_detects_root_and_depth_two_skills_but_not_depth_three() {
        let temp = tempfile::tempdir().expect("tempdir");
        std::fs::write(
            temp.path().join("SKILL.md"),
            skill_doc("root-skill", "Root"),
        )
        .expect("write root");
        std::fs::create_dir_all(temp.path().join("one/two/three")).expect("dirs");
        std::fs::write(
            temp.path().join("one/SKILL.md"),
            skill_doc("one-skill", "One"),
        )
        .expect("write one");
        std::fs::write(
            temp.path().join("one/two/SKILL.md"),
            skill_doc("two-skill", "Two"),
        )
        .expect("write two");
        std::fs::write(
            temp.path().join("one/two/three/SKILL.md"),
            skill_doc("three-skill", "Three"),
        )
        .expect("write three");

        let loaded = scan_skills_dir(temp.path(), SkillSource::User, &[], Platform::Cli)
            .expect("skills load");

        let names = loaded
            .into_iter()
            .map(|skill| skill.manifest.name)
            .collect::<Vec<_>>();
        assert!(names.contains(&"root-skill".to_string()));
        assert!(names.contains(&"one-skill".to_string()));
        assert!(names.contains(&"two-skill".to_string()));
        assert!(!names.contains(&"three-skill".to_string()));
    }

    fn skill_doc(name: &str, title: &str) -> String {
        format!("---\nname: {name}\ndescription: {title}\n---\n\n# {title}\n\nBody.\n")
    }
}