stynx-code 3.4.0

stynx-code — interactive AI coding assistant
use std::path::Path;

pub struct Skill {
    pub name: String,
    pub description: String,
    pub argument_hint: Option<String>,
    pub when_to_use: Option<String>,
    pub allowed_tools: Vec<String>,
    pub user_invocable: bool,
    pub prompt_template: String,
}

impl Skill {
    pub fn expand(&self, args: &str) -> String {
        let result = if args.is_empty() {
            self.prompt_template.replace("$ARGUMENTS", "").trim().to_string()
        } else {
            self.prompt_template.replace("$ARGUMENTS", args)
        };

        result.replace("${ARGUMENTS}", if args.is_empty() { "" } else { args })
    }
}

pub fn load_skills(cwd: &str) -> Vec<Skill> {
    let home = home_dir();
    let mut skills = Vec::new();

    let skills_dirs = [
        format!("{home}/.claude/skills"),
        format!("{cwd}/.claude/skills"),
    ];
    for dir in &skills_dirs {
        load_skills_from_dir(dir, &mut skills, true);
    }

    let commands_dirs = [
        format!("{home}/.claude/commands"),
        format!("{cwd}/.claude/commands"),
    ];
    for dir in &commands_dirs {
        load_skills_from_dir(dir, &mut skills, false);
    }

    skills
}

fn home_dir() -> String {
    stynx_code_config::home_dir()
        .map(|p| p.to_string_lossy().to_string())
        .unwrap_or_else(|| ".".to_string())
}

fn load_skills_from_dir(dir: &str, skills: &mut Vec<Skill>, skills_dir_only: bool) {
    let path = Path::new(dir);
    if !path.is_dir() {
        return;
    }
    let entries = match std::fs::read_dir(path) {
        Ok(e) => e,
        Err(_) => return,
    };
    for entry in entries.flatten() {
        let p = entry.path();

        if p.is_dir() {
            let skill_file = p.join("SKILL.md");
            let skill_file_lower = p.join("skill.md");
            let actual = if skill_file.is_file() {
                Some(skill_file)
            } else if skill_file_lower.is_file() {
                Some(skill_file_lower)
            } else {
                None
            };
            if let Some(file_path) = actual {
                let dir_name = p.file_name()
                    .and_then(|n| n.to_str())
                    .unwrap_or_default()
                    .to_string();
                if let Some(skill) = parse_skill_file(&file_path, Some(&dir_name))
                    && !skills.iter().any(|s| s.name == skill.name) {
                        skills.push(skill);
                    }
            }
            continue;
        }

        if !skills_dir_only
            && p.extension().and_then(|e| e.to_str()) == Some("md")
            && let Some(skill) = parse_skill_file(&p, None)
                && !skills.iter().any(|s| s.name == skill.name) {
                    skills.push(skill);
                }
    }
}

fn parse_skill_file(path: &Path, dir_name: Option<&str>) -> Option<Skill> {
    let content = std::fs::read_to_string(path).ok()?;
    let (frontmatter, body) = split_frontmatter(&content)?;

    let name = extract_field(&frontmatter, "name")
        .or_else(|| dir_name.map(|s| s.to_string()))
        .or_else(|| {
            path.file_stem()
                .and_then(|s| s.to_str())
                .map(|s| s.to_string())
        })?;

    let description = extract_field(&frontmatter, "description").unwrap_or_default();

    let argument_hint = extract_field(&frontmatter, "argument-hint")
        .or_else(|| extract_field(&frontmatter, "argument_hint"));

    let when_to_use = extract_field(&frontmatter, "when_to_use")
        .or_else(|| extract_field(&frontmatter, "when-to-use"));

    let allowed_tools = extract_field(&frontmatter, "allowed-tools")
        .or_else(|| extract_field(&frontmatter, "allowed_tools"))
        .map(|s| {
            s.split(',')
                .map(|t| t.trim().to_string())
                .filter(|t| !t.is_empty())
                .collect()
        })
        .unwrap_or_default();

    let user_invocable = extract_field(&frontmatter, "user-invocable")
        .or_else(|| extract_field(&frontmatter, "user_invocable"))
        .map(|v| !matches!(v.to_lowercase().as_str(), "false" | "no" | "0"))
        .unwrap_or(true);

    Some(Skill {
        name,
        description,
        argument_hint,
        when_to_use,
        allowed_tools,
        user_invocable,
        prompt_template: body.trim().to_string(),
    })
}

fn split_frontmatter(content: &str) -> Option<(String, String)> {
    let content = content.trim_start();
    if !content.starts_with("---") {
        return Some((String::new(), content.to_string()));
    }
    let rest = &content[3..];
    let end = rest.find("\n---")?;
    let frontmatter = rest[..end].to_string();
    let body = rest[end + 4..].to_string();
    Some((frontmatter, body))
}

fn extract_field(frontmatter: &str, key: &str) -> Option<String> {
    for line in frontmatter.lines() {
        if let Some(rest) = line.strip_prefix(&format!("{key}:")) {
            let val = rest.trim().trim_matches('"').trim_matches('\'').to_string();
            if !val.is_empty() {
                return Some(val);
            }
        }
    }
    None
}