Skip to main content

j_agent/infra/
skill.rs

1use crate::permission::JcliConfig;
2use serde::Deserialize;
3use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7// ========== 数据结构 ==========
8
9/// Skill 来源层级
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum SkillSource {
12    /// 用户级: ~/.jdata/agent/skills/
13    User,
14    /// 项目级: .jcli/skills/
15    Project,
16}
17
18impl SkillSource {
19    /// 返回当前来源的中文标签
20    pub fn label(&self) -> &'static str {
21        match self {
22            SkillSource::User => "用户",
23            SkillSource::Project => "项目",
24        }
25    }
26}
27
28/// 技能的 YAML frontmatter 元数据
29#[derive(Debug, Clone, Deserialize)]
30pub struct SkillFrontmatter {
31    pub name: String,
32    pub description: String,
33}
34
35/// 技能定义,包含 frontmatter 元数据、正文内容、目录路径和来源层级
36#[derive(Debug, Clone)]
37pub struct Skill {
38    pub frontmatter: SkillFrontmatter,
39    /// frontmatter 之后的 Markdown 正文
40    pub body: String,
41    /// skill 目录路径
42    pub dir_path: PathBuf,
43    /// 来源层级
44    pub source: SkillSource,
45}
46
47// ========== 加载与解析 ==========
48
49/// 返回用户级 skills 目录: ~/.jdata/agent/skills/
50pub fn skills_dir() -> PathBuf {
51    let dir = crate::constants::data_root().join("agent").join("skills");
52    let _ = fs::create_dir_all(&dir);
53    dir
54}
55
56/// 返回项目级 skills 目录: .jcli/skills/(如果存在)
57pub fn project_skills_dir() -> Option<PathBuf> {
58    let config_dir = JcliConfig::find_config_dir()?;
59    let dir = config_dir.join("skills");
60    if dir.is_dir() { Some(dir) } else { None }
61}
62
63/// 从指定目录加载 skills
64fn load_skills_from_dir(dir: &Path, source: SkillSource) -> Vec<Skill> {
65    let mut skills = Vec::new();
66    let entries = match fs::read_dir(dir) {
67        Ok(e) => e,
68        Err(_) => return skills,
69    };
70    for entry in entries.flatten() {
71        let path = entry.path();
72        if !path.is_dir() {
73            continue;
74        }
75        let skill_md = path.join("SKILL.md");
76        if skill_md.exists()
77            && let Some(mut skill) = parse_skill_md(&skill_md, &path)
78        {
79            skill.source = source;
80            skills.push(skill);
81        }
82    }
83    skills
84}
85
86/// 扫描 skills 目录,加载所有 skill(用户级 + 项目级,同名时项目级覆盖)
87pub fn load_all_skills() -> Vec<Skill> {
88    let mut map: HashMap<String, Skill> = HashMap::new();
89
90    // 1. 用户级
91    for skill in load_skills_from_dir(&skills_dir(), SkillSource::User) {
92        map.insert(skill.frontmatter.name.clone(), skill);
93    }
94
95    // 2. 项目级(覆盖同名)
96    if let Some(dir) = project_skills_dir() {
97        for skill in load_skills_from_dir(&dir, SkillSource::Project) {
98            map.insert(skill.frontmatter.name.clone(), skill);
99        }
100    }
101
102    let mut skills: Vec<Skill> = map.into_values().collect();
103    skills.sort_by(|a, b| a.frontmatter.name.cmp(&b.frontmatter.name));
104    skills
105}
106
107/// 解析 SKILL.md: YAML frontmatter + body
108fn parse_skill_md(path: &Path, dir: &Path) -> Option<Skill> {
109    let content = fs::read_to_string(path).ok()?;
110    let (fm_str, body) = split_frontmatter(&content)?;
111    let frontmatter: SkillFrontmatter = serde_yaml::from_str(&fm_str).ok()?;
112
113    if frontmatter.name.is_empty() {
114        return None;
115    }
116
117    Some(Skill {
118        frontmatter,
119        body: body.trim().to_string(),
120        dir_path: dir.to_path_buf(),
121        source: SkillSource::User, // 由调用方覆盖
122    })
123}
124
125/// 按 `---` 分隔 frontmatter 和 body
126pub(super) fn split_frontmatter(content: &str) -> Option<(String, String)> {
127    let trimmed = content.trim_start();
128    if !trimmed.starts_with("---") {
129        return None;
130    }
131    // 跳过第一个 ---
132    let rest = &trimmed[3..];
133    let end_idx = rest.find("\n---")?;
134    let fm = rest[..end_idx].trim().to_string();
135    let body = rest[end_idx + 4..].to_string();
136    Some((fm, body))
137}
138
139/// 拼合 body + references/ 和 scripts/ 的文件列表(不内联内容,由模型按需 Read/Shell)
140pub fn resolve_skill_content(skill: &Skill) -> String {
141    let mut result = skill.body.clone();
142
143    // 列出 references/ 目录中的文件路径,供模型按需读取
144    if let Some(paths) = list_dir_files(&skill.dir_path.join("references")) {
145        result.push_str("\n\n## 参考文件\n\n以下参考文件可按需使用 Read 工具读取:\n");
146        for p in &paths {
147            result.push_str(&format!("- `{}`\n", p));
148        }
149    }
150
151    // 列出 scripts/ 目录中的脚本路径,供模型按需执行
152    if let Some(paths) = list_dir_files(&skill.dir_path.join("scripts")) {
153        result.push_str("\n\n## 脚本\n\n以下脚本可按需使用 Shell/BackgroundRun 工具执行:\n");
154        for p in &paths {
155            result.push_str(&format!("- `{}`\n", p));
156        }
157    }
158
159    result
160}
161
162/// 列出目录下的文件路径(排序),目录不存在或为空时返回 None
163fn list_dir_files(dir: &Path) -> Option<Vec<String>> {
164    if !dir.is_dir() {
165        return None;
166    }
167    let entries = fs::read_dir(dir).ok()?;
168    let mut files: Vec<_> = entries.flatten().collect();
169    files.sort_by_key(|e| e.file_name());
170    let paths: Vec<String> = files
171        .iter()
172        .filter(|e| e.path().is_file())
173        .map(|e| e.path().display().to_string())
174        .collect();
175    if paths.is_empty() { None } else { Some(paths) }
176}
177
178// ========== build_skills_summary ==========
179
180/// 构建 skills 摘要列表(Markdown 格式),用于系统提示词的 {{.skills}} 占位符
181/// disabled_skills 中的 skill 会被过滤掉
182pub fn build_skills_summary(skills: &[Skill], disabled_skills: &[String]) -> String {
183    let filtered: Vec<&Skill> = skills
184        .iter()
185        .filter(|s| !disabled_skills.iter().any(|d| d == &s.frontmatter.name))
186        .collect();
187    if filtered.is_empty() {
188        return "(暂无可用技能)".to_string();
189    }
190    let mut md = String::new();
191    for s in &filtered {
192        md.push_str(&format!(
193            "- {}: {}\n",
194            s.frontmatter.name, s.frontmatter.description
195        ));
196    }
197    md.trim_end().to_string()
198}