//! 本机 Claude 技能扫描(skills / commands / plugins 三类) use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; use std::sync::OnceLock; use serde::Serialize; /// 技能元信息(前端 `/` 联想 + 后端注入用) #[derive(Debug, Clone, Serialize)] pub struct SkillInfo { pub name: String, pub description: String, pub argument_hint: Option, /// skill | command | plugin pub source: String, /// SKILL.md 绝对路径(注入时读全文) pub path: String, } /// ~/.claude 目录(跨平台:USERPROFILE / HOME) fn claude_home() -> Option { std::env::var_os("USERPROFILE") .or_else(|| std::env::var_os("HOME")) .map(PathBuf::from) .map(|h| h.join(".claude")) } /// 剥离 YAML 标量值两侧的引号(`"..."` / `'...'`),简易 frontmatter 解析用 fn unquote(s: &str) -> &str { s.strip_prefix('"') .and_then(|x| x.strip_suffix('"')) .or_else(|| s.strip_prefix('\'').and_then(|x| x.strip_suffix('\''))) .unwrap_or(s) } /// 解析 markdown frontmatter 的 name / description / argument-hint / user_invocable /// (简易,按行匹配,容错缩进与 CRLF;仅扫描 frontmatter 区段) fn parse_frontmatter(md: &str) -> Option<(String, String, Option, bool)> { let mut lines = md.lines(); if lines.next()?.trim() != "---" { return None; } let mut name = None; let mut desc = None; let mut hint = None; let mut invocable = true; for line in lines { if line.trim() == "---" { break; } let l = line.trim_start(); if let Some(v) = l.strip_prefix("name:") { name = Some(unquote(v.trim()).to_string()); } else if let Some(v) = l.strip_prefix("description:") { desc = Some(unquote(v.trim()).to_string()); } else if let Some(v) = l.strip_prefix("argument-hint:") { hint = Some(unquote(v.trim()).to_string()); } else if let Some(v) = l.strip_prefix("user_invocable:") { invocable = v.trim() != "false"; } } name.map(|n| (n, desc.unwrap_or_default(), hint, invocable)) } /// 解析单个 SKILL.md / command md 为 SkillInfo(排除 user_invocable: false) fn parse_skill_file(path: &Path, source: &str) -> Option { let md = fs::read_to_string(path).ok()?; let (name, description, argument_hint, invocable) = parse_frontmatter(&md).unwrap_or_else(|| { // 无 frontmatter(部分 commands):用文件名兜底,默认可调用 let stem = path .file_stem() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_default(); (stem, String::new(), None, true) }); if !invocable { return None; } Some(SkillInfo { name, description, argument_hint, source: source.to_string(), path: path.to_string_lossy().to_string(), }) } /// 递归收集目录下所有 SKILL.md(用于 plugins/marketplaces 多层嵌套) fn collect_skill_files(dir: &Path, out: &mut Vec) { if let Ok(entries) = fs::read_dir(dir) { for entry in entries.flatten() { let p = entry.path(); if p.is_dir() { // 跳过依赖/版本目录,避免递归爆炸 let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(""); if name == "node_modules" || name == ".git" { continue; } collect_skill_files(&p, out); } else if p.file_name().and_then(|n| n.to_str()) == Some("SKILL.md") { out.push(p); } } } } /// 扫描三类来源,按 name 去重(skills 优先 > commands > plugins) fn scan_skills() -> Vec { let home = match claude_home() { Some(h) => h, None => return Vec::new(), }; let mut skills = Vec::new(); let mut seen: HashSet = HashSet::new(); // 1. ~/.claude/skills/*/SKILL.md if let Ok(entries) = fs::read_dir(home.join("skills")) { for entry in entries.flatten() { if let Some(info) = parse_skill_file(&entry.path().join("SKILL.md"), "skill") { if seen.insert(info.name.clone()) { skills.push(info); } } } } // 2. ~/.claude/commands/*.md if let Ok(entries) = fs::read_dir(home.join("commands")) { for entry in entries.flatten() { let p = entry.path(); if p.extension().and_then(|e| e.to_str()) == Some("md") { if let Some(info) = parse_skill_file(&p, "command") { if seen.insert(info.name.clone()) { skills.push(info); } } } } } // 3. ~/.claude/plugins/marketplaces/**/skills/*/SKILL.md(递归;cache 不在此路径下) let mut files = Vec::new(); collect_skill_files(&home.join("plugins").join("marketplaces"), &mut files); for f in files { if let Some(info) = parse_skill_file(&f, "plugin") { if seen.insert(info.name.clone()) { skills.push(info); } } } skills } /// 技能扫描结果缓存(进程内;新增/改动技能需重启生效) pub(crate) fn skills_cached() -> &'static Vec { static SKILLS: OnceLock> = OnceLock::new(); SKILLS.get_or_init(scan_skills) } /// 按 name 读取技能全文(注入 system prompt);扫描走缓存,仅读单个 SKILL.md pub(crate) fn read_skill_content(name: &str) -> Option { skills_cached() .iter() .find(|s| s.name == name) .and_then(|s| fs::read_to_string(&s.path).ok()) }