新增: Phase2 阶段收尾(Sprint 1-20)
重构:删 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/儿童每日打卡应用/ 与本项目无关,已排除。
This commit is contained in:
169
src-tauri/src/commands/ai/skills.rs
Normal file
169
src-tauri/src/commands/ai/skills.rs
Normal file
@@ -0,0 +1,169 @@
|
||||
//! 本机 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())
|
||||
}
|
||||
Reference in New Issue
Block a user