//! 本机 Claude 技能扫描(skills / commands / plugins 三类) //! //! 核心设计6(/ skill 修复): //! - `strip_frontmatter` + `read_skill_content_stripped`:注入正文剥首个 `---...---` 块 //! (原 `read_skill_content` 保留兼容,返全文) //! - `OnceLock` → `RwLock>` + 快路径(读锁命中 clone)/ 慢路径(重扫) //! + `invalidate_skills` 写锁置 None,支持进程内 `ai_reload_skills` 热重载 //! - `scan_skills` 返 `ScanResult{skills, conflicts}`:同名收集所有 path 入 conflicts, //! 保留首份(优先级 skills>commands>plugins);`SkillInfo.duplicates` 仅冲突时填。 use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; use std::sync::{RwLock, RwLockReadGuard}; 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, /// 同名冲突时的其它来源路径(仅冲突时填 Some;首份为 None)。 /// /// 注入始终取首份(优先级 skills>commands>plugins),前端据非空 duplicates 提示用户 /// "存在同名技能 N 份,已用 {source} 来源"。无冲突为 None,向后兼容旧前端(字段缺省)。 #[serde(skip_serializing_if = "Option::is_none")] pub duplicates: Option>, } /// 扫描结果:去重后的 skills 列表 + 同名冲突明细(前端提示用)。 /// /// `conflicts`: `(name, 所有同名的 path 列表)` —— 注入取 skills[0](即 ScanResult.skills 中 /// 首份,优先级 skills>commands>plugins),冲突列表仅做提示,不影响注入。 #[derive(Debug, Clone, Serialize)] pub struct ScanResult { pub skills: Vec, pub conflicts: Vec<(String, Vec)>, } /// ~/.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")) } /// 剥离 markdown 首个 `---...---` frontmatter 块,返回正文。 /// /// 状态机:用行索引推进;首个 `---`(trim 后)进入 in_fm,遇到下个 `---` 退出,余为正文。 /// 无 frontmatter(首行非 ---)直接返原文;frontmatter 未闭合(只有起始 --- 无收尾) /// 按容错返空串(避免把整篇当 frontmatter 误剥,此分支极罕见 —— SKILL.md 都闭合)。 /// /// 注入用:原 `read_skill_content` 返全文(含 frontmatter)会让 LLM 看到 YAML 头噪声, /// 改用 `read_skill_content_stripped` 后注入正文干净。 pub(crate) fn strip_frontmatter(md: &str) -> String { let mut lines = md.lines().enumerate().peekable(); // 空串 / 首行非 --- → 无 frontmatter,返原文 let first = match lines.next() { Some((_, l)) => l, None => return String::new(), }; if first.trim() != "---" { // 无 frontmatter:返原文(首行 + 余下,用 \n 拼回) let mut out = first.to_string(); for (_, l) in lines { out.push('\n'); out.push_str(l); } return out; } // in_fm:跳过直到下个 --- let mut body_start: Option = None; for (i, l) in lines { if l.trim() == "---" { body_start = Some(i + 1); break; } } match body_start { Some(start) => { // 正文 = 原文第 start 行(0-based)起所有行 md.lines().skip(start).collect::>().join("\n") } None => String::new(), // frontmatter 未闭合 } } /// 剥离 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(), duplicates: None, }) } /// 递归收集目录下所有 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)。 /// /// 返 `ScanResult`:`skills` 为去重后首份(优先级保留),`conflicts` 收集所有同名 path /// (仅当同名 > 1 时入列,前端提示用)。 fn scan_skills() -> ScanResult { let home = match claude_home() { Some(h) => h, None => { return ScanResult { skills: Vec::new(), conflicts: Vec::new(), } } }; // 三类来源按优先级顺序收集(skills > commands > plugins) let mut all: Vec> = Vec::with_capacity(3); // 1. ~/.claude/skills/*/SKILL.md let mut batch_skill = Vec::new(); 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") { batch_skill.push(info); } } } all.push(batch_skill); // 2. ~/.claude/commands/*.md let mut batch_cmd = Vec::new(); 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") { batch_cmd.push(info); } } } } all.push(batch_cmd); // 3. ~/.claude/plugins/marketplaces/**/skills/*/SKILL.md(递归;cache 不在此路径下) let mut files = Vec::new(); collect_skill_files(&home.join("plugins").join("marketplaces"), &mut files); let mut batch_plugin = Vec::new(); for f in files { if let Some(info) = parse_skill_file(&f, "plugin") { batch_plugin.push(info); } } all.push(batch_plugin); // 按 name 去重(首份优先级保留)+ 收集冲突 // name -> (首份 index in skills, 所有 path) let mut seen: HashSet = HashSet::new(); let mut skills: Vec = Vec::new(); // name -> Vec(按来源顺序,用于冲突判定 + duplicates 回填) let mut name_paths: std::collections::HashMap> = std::collections::HashMap::new(); for batch in &all { for info in batch { name_paths .entry(info.name.clone()) .or_default() .push(info.path.clone()); if seen.insert(info.name.clone()) { skills.push(info.clone()); } } } // 回填 duplicates + 构造 conflicts let mut conflicts: Vec<(String, Vec)> = Vec::new(); for skill in skills.iter_mut() { if let Some(paths) = name_paths.get(&skill.name) { if paths.len() > 1 { // 首份 path 是 skill.path 本身,duplicates 填其余(按收集顺序) let dups: Vec = paths .iter() .filter(|p| **p != skill.path) .cloned() .collect(); if !dups.is_empty() { skill.duplicates = Some(dups); } conflicts.push((skill.name.clone(), paths.clone())); } } } ScanResult { skills, conflicts } } /// 进程内技能缓存(RwLock + Option 懒初始化)。 /// /// 设计(核心设计6): /// - 快路径:`skills_cached()` 读锁命中 → clone 返回(扫盘零开销) /// - 慢路径:读锁 None → 释放后 `scan_skills()` 填充(双检锁,避免持写锁扫盘阻塞读) /// - `invalidate_skills()` 写锁置 None,下次 `skills_cached()` 触发重扫 /// - `ai_reload_skills` IPC:invalidate + 重扫,进程内热重载(不重启生效) static SKILLS: RwLock>> = RwLock::new(None); /// 读锁快路径 guard(供 `read_skill_content_stripped` 持引用迭代读缓存)。 /// /// 生命周期 `'static`:SKILLS 是 static 项,对其借用可标注 `'static`(Rust 对 static 的保证), /// 使函数能返回 guard 跨作用域传递。guard Drop 时释放读锁。 type SkillsGuard = RwLockReadGuard<'static, Option>>; /// 取读锁快照(懒初始化:None 时先释放锁扫盘填回,再读锁取引用)。 /// /// 返 `RwLockReadGuard>>`,调用方解 `*guard` 得 `&Vec`。 /// 懒初始化走双检锁:先读锁查 Some(快),None 时释放 → 扫盘 → 写锁填回 → 读锁重取。 /// /// P1-260617-3:`scan_skills` 同步递归 `fs::read_dir` + `read_to_string`(plugins/marketplaces /// 多层嵌套,Windows 文件多时同步阻塞 tokio runtime)。本函数改 async,慢路径扫盘包 /// `spawn_blocking` 隔离(对齐 commands/project.rs detect_stack 模式)。快路径(读锁命中)仍同步无 fs。 /// /// 锁中毒(P1-260617-3 加固):读写锁 expect 中毒会 panic,技能加载热路径 panic 不可接受。 /// 中毒 → tracing::error! 记录 + 返 None(调用方 `skills_cached` 得空 Vec / `read_skill_content_stripped` /// 返 None),不再 panic。中毒通常因持锁 panicking 线程(早期改 *g 时 unwrap)残留,缓存本身可重建, /// 返空后下次 `invalidate_skills` 或进程重启自愈。 async fn skills_lock_async() -> Option { // 快路径:读锁命中(无 fs,纯内存) { // 锁中毒不 panic:PoisonError 携 guard 仍可恢复数据,但缓存一致性不保 → 记录后返 None 降级空。 let g = match RwLock::read(&SKILLS) { Ok(g) => g, Err(_) => { tracing::error!("SKILLS 读锁中毒,返空技能列表"); return None; } }; if g.is_some() { return Some(g); } } // 慢路径:扫盘(spawn_blocking 隔离同步 fs 递归,防阻塞 tokio runtime) let scanned = tokio::task::spawn_blocking(scan_skills) .await .map(|res| res.skills) .unwrap_or_default(); { let mut g = match RwLock::write(&SKILLS) { Ok(g) => g, Err(_) => { tracing::error!("SKILLS 写锁中毒,返空技能列表"); return None; } }; // 另一线程可能已填,二次检查(双检锁) if g.is_none() { *g = Some(scanned); } } // 再取读锁返回(此时必 Some) let g = match RwLock::read(&SKILLS) { Ok(g) => g, Err(_) => { tracing::error!("SKILLS 读锁(慢路径后)中毒,返空技能列表"); return None; } }; debug_assert!(g.is_some(), "skills_lock 慢路径后必 Some"); Some(g) } /// 技能扫描结果缓存(进程内;命中即 clone,不重复扫盘)。 /// /// 替代原 `OnceLock::get_or_init` 路径:返 owned `Vec`(clone), /// 因 RwLock 不能返 `&'static`。调用方(config.rs:30 / read_skill_content_stripped)已同步适配。 /// /// P1-260617-3:改 async,慢路径(首次/重扫)走 `skills_lock_async` → spawn_blocking /// 隔离同步 fs 防阻塞 tokio runtime(Tauri 单线程 runtime)。快路径(读锁命中)无 fs。 pub(crate) async fn skills_cached() -> Vec { let g = skills_lock_async().await; // 锁中毒 → None → unwrap_or_default() 得空 Vec(对齐 P1-260617-3 中毒降级)。 g.and_then(|g| g.clone()).unwrap_or_default() } /// 置缓存为 None,下次 `skills_cached()` 触发重扫。 /// /// `ai_reload_skills` IPC 调用:写锁置 None → 紧接 `skills_cached()` 重扫, /// 实现"改技能不重启即生效"。 pub(crate) fn invalidate_skills() { // 锁中毒:本就是想置 None 清缓存重建,但中毒时持锁线程已 panic,此处无法恢复一致状态。 // 记录错误并提前返回(下次加载由 skills_lock_async 中毒降级返空,进程重启自愈)。 let mut g = match RwLock::write(&SKILLS) { Ok(g) => g, Err(_) => { tracing::error!("SKILLS 写锁中毒(invalidate_skills),跳过置 None"); return; } }; *g = None; } /// 按 name 读技能正文(剥首个 `---...---` frontmatter 块)。 /// /// 核心设计6:注入用正文,避免 YAML 头噪声污染 system prompt。 /// 缓存未命中返 None;文件读失败返 None。 /// /// P1-260617-3:改 async,缓存懒初始化(可能触发扫盘)走 spawn_blocking 防阻塞 runtime。 /// /// 注:guard(std::sync::RwLockReadGuard 非 Send)必须在 spawn_blocking 的 .await 前 drop, /// 否则非 Send 跨 await 点致 future 不 Send(MentionResolver 要求 Send)。guard 用 { } 限作用域。 pub(crate) async fn read_skill_content_stripped(name: String) -> Option { // 用 skills_cached(扫盘填充 + clone)替代 skills_lock_async(只读):防御 SKILLS 为 None // (首次/invalidate 后)时 ? 早返 None 致技能不注入。skills_cached 触发 get_or_init 扫盘确保填充。 let path: Option = { let skills = skills_cached().await; skills.iter().find(|s| s.name == name).map(|s| s.path.clone()) }; let path = match path { Some(p) => p, None => { tracing::warn!(skill = %name, "[read_skill_content_stripped] 缓存中未找到该技能(name 不匹配/未扫到/user_invocable:false)"); return None; } }; // 单文件读(spawn_blocking 防 Windows fs 慢),细化诊断各失败步便于定位真断点。 // path move 进 spawn_blocking,先 clone 留诊断用(tracing 借用 move 后值会 E0382)。 let path_for_log = path.clone(); let md = match tokio::task::spawn_blocking(move || fs::read_to_string(&path)).await { Ok(Ok(content)) => content, Ok(Err(e)) => { tracing::warn!(skill = %name, path = %path_for_log, error = %e, "[read_skill_content_stripped] 读 SKILL.md 失败(路径/权限/编码)"); return None; } Err(e) => { tracing::warn!(skill = %name, error = %e, "[read_skill_content_stripped] spawn_blocking join 失败"); return None; } }; Some(strip_frontmatter(&md)) } #[cfg(test)] mod tests { use super::*; #[test] fn strip_frontmatter_normal() { let md = "---\nname: foo\ndescription: bar\n---\n# Body\ncontent"; assert_eq!(strip_frontmatter(md), "# Body\ncontent"); } #[test] fn strip_frontmatter_no_fm() { let md = "# Title\nbody line"; assert_eq!(strip_frontmatter(md), "# Title\nbody line"); } #[test] fn strip_frontmatter_empty() { assert_eq!(strip_frontmatter(""), ""); } #[test] fn strip_frontmatter_only_fm() { // 只有 frontmatter 无正文 let md = "---\nname: foo\n---\n"; assert_eq!(strip_frontmatter(md), ""); } #[test] fn strip_frontmatter_crlf() { let md = "---\r\nname: foo\r\n---\r\nbody\r\n"; let out = strip_frontmatter(md); assert!(out.contains("body"), "CRLF 正文应保留: {:?}", out); } #[test] fn scan_result_dedup_and_conflicts() { // 仅测去重逻辑(不依赖 ~/.claude 存在);通过 parse_skill_file 间接覆盖 // scan_skills 本身依赖文件系统,此处不集成测 let _ = ScanResult { skills: vec![], conflicts: vec![], }; } }