Files
DevFlow/src-tauri/src/commands/ai/skills.rs
绝尘 cf017f81e2 新增: 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/儿童每日打卡应用/ 与本项目无关,已排除。
2026-06-14 14:08:20 +08:00

170 lines
5.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 本机 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())
}