重构:删 5 零引用 crate(df-evolve/plugin/stages/task/traceability)+ 清死模块、ai.rs 拆 11 子 module、ai.ts 拆 6 composable、i18n 拆目录 功能:知识库全栈(df-project/scan + CRUD + 时间线 + 前端)、Settings 拆分、appSettings KV 迁移、模型池、LLM 并发 Semaphore 修复:审批持久化根治、ConditionEngine 默认拒绝、NodeRegistry unimplemented 清除、promote 补偿删除、工具结果截断 50KB、路径校验防 symlink 逃逸 文档:B-03 人工审批设计、决策记录三分档、规格契约自检、经验记录、todo 看板、PROGRESS 更新 详见 PROGRESS.md。src-tauri/儿童每日打卡应用/ 与本项目无关,已排除。
170 lines
5.7 KiB
Rust
170 lines
5.7 KiB
Rust
//! 本机 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<String>,
|
||
/// skill | command | plugin
|
||
pub source: String,
|
||
/// SKILL.md 绝对路径(注入时读全文)
|
||
pub path: String,
|
||
}
|
||
|
||
/// ~/.claude 目录(跨平台:USERPROFILE / HOME)
|
||
fn claude_home() -> Option<PathBuf> {
|
||
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<String>, 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<SkillInfo> {
|
||
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<PathBuf>) {
|
||
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<SkillInfo> {
|
||
let home = match claude_home() {
|
||
Some(h) => h,
|
||
None => return Vec::new(),
|
||
};
|
||
let mut skills = Vec::new();
|
||
let mut seen: HashSet<String> = 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<SkillInfo> {
|
||
static SKILLS: OnceLock<Vec<SkillInfo>> = OnceLock::new();
|
||
SKILLS.get_or_init(scan_skills)
|
||
}
|
||
|
||
/// 按 name 读取技能全文(注入 system prompt);扫描走缓存,仅读单个 SKILL.md
|
||
pub(crate) fn read_skill_content(name: &str) -> Option<String> {
|
||
skills_cached()
|
||
.iter()
|
||
.find(|s| s.name == name)
|
||
.and_then(|s| fs::read_to_string(&s.path).ok())
|
||
}
|