j_cli/command/chat/
skill.rs1use super::tools::{Tool, ToolResult};
2use crate::config::YamlConfig;
3use serde::Deserialize;
4use serde_json::{Value, json};
5use std::fs;
6use std::path::PathBuf;
7
8#[derive(Debug, Clone, Deserialize)]
11pub struct SkillFrontmatter {
12 pub name: String,
13 pub description: String,
14 #[serde(rename = "argument-hint")]
15 pub argument_hint: Option<String>,
16}
17
18#[derive(Debug, Clone)]
19pub struct Skill {
20 pub frontmatter: SkillFrontmatter,
21 pub body: String,
23 pub dir_path: PathBuf,
25}
26
27pub fn skills_dir() -> PathBuf {
31 let dir = YamlConfig::data_dir().join("agent").join("skills");
32 let _ = fs::create_dir_all(&dir);
33 dir
34}
35
36pub fn load_all_skills() -> Vec<Skill> {
38 let dir = skills_dir();
39 let mut skills = Vec::new();
40
41 let entries = match fs::read_dir(&dir) {
42 Ok(e) => e,
43 Err(_) => return skills,
44 };
45
46 for entry in entries.flatten() {
47 let path = entry.path();
48 if !path.is_dir() {
49 continue;
50 }
51 let skill_md = path.join("SKILL.md");
52 if skill_md.exists() {
53 if let Some(skill) = parse_skill_md(&skill_md, &path) {
54 skills.push(skill);
55 }
56 }
57 }
58
59 skills.sort_by(|a, b| a.frontmatter.name.cmp(&b.frontmatter.name));
60 skills
61}
62
63fn parse_skill_md(path: &PathBuf, dir: &PathBuf) -> Option<Skill> {
65 let content = fs::read_to_string(path).ok()?;
66 let (fm_str, body) = split_frontmatter(&content)?;
67 let frontmatter: SkillFrontmatter = serde_yaml::from_str(&fm_str).ok()?;
68
69 if frontmatter.name.is_empty() {
70 return None;
71 }
72
73 Some(Skill {
74 frontmatter,
75 body: body.trim().to_string(),
76 dir_path: dir.clone(),
77 })
78}
79
80fn split_frontmatter(content: &str) -> Option<(String, String)> {
82 let trimmed = content.trim_start();
83 if !trimmed.starts_with("---") {
84 return None;
85 }
86 let rest = &trimmed[3..];
88 let end_idx = rest.find("\n---")?;
89 let fm = rest[..end_idx].trim().to_string();
90 let body = rest[end_idx + 4..].to_string();
91 Some((fm, body))
92}
93
94pub fn resolve_skill_content(skill: &Skill) -> String {
96 const MAX_BYTES: usize = 12000;
97 let mut result = skill.body.clone();
98
99 let refs_dir = skill.dir_path.join("references");
101 if refs_dir.is_dir() {
102 if let Ok(entries) = fs::read_dir(&refs_dir) {
103 let mut ref_files: Vec<_> = entries.flatten().collect();
104 ref_files.sort_by_key(|e| e.file_name());
105 for entry in ref_files {
106 let path = entry.path();
107 if path.is_file() {
108 if let Ok(content) = fs::read_to_string(&path) {
109 let filename = path.file_name().unwrap_or_default().to_string_lossy();
110 result
111 .push_str(&format!("\n\n--- 参考文件: {} ---\n{}", filename, content));
112 }
113 }
114 if result.len() > MAX_BYTES {
115 break;
116 }
117 }
118 }
119 }
120
121 if result.len() > MAX_BYTES {
123 let mut end = MAX_BYTES;
124 while !result.is_char_boundary(end) {
125 end -= 1;
126 }
127 result.truncate(end);
128 result.push_str("\n...(内容已截断)");
129 }
130
131 result
132}
133
134pub fn build_skills_summary(skills: &[Skill]) -> String {
138 if skills.is_empty() {
139 return "(暂无已安装的技能)".to_string();
140 }
141 let mut result = String::new();
142 for skill in skills {
143 result.push_str(&format!(
144 "- **{}**: {}\n",
145 skill.frontmatter.name, skill.frontmatter.description
146 ));
147 }
148 result.trim_end().to_string()
149}
150
151pub struct LoadSkillTool {
154 pub skills: Vec<Skill>,
155}
156
157impl Tool for LoadSkillTool {
158 fn name(&self) -> &str {
159 "load_skill"
160 }
161
162 fn description(&self) -> &str {
163 "加载指定技能的完整内容到上下文。当你判断需要某个技能时调用此工具。"
164 }
165
166 fn parameters_schema(&self) -> Value {
167 json!({
168 "type": "object",
169 "properties": {
170 "name": {
171 "type": "string",
172 "description": "要加载的技能名称"
173 },
174 "arguments": {
175 "type": "string",
176 "description": "传递给技能的参数(可选)"
177 }
178 },
179 "required": ["name"]
180 })
181 }
182
183 fn execute(&self, arguments: &str) -> ToolResult {
184 let parsed = serde_json::from_str::<Value>(arguments).ok();
185
186 let skill_name = parsed
187 .as_ref()
188 .and_then(|v| v.get("name").and_then(|n| n.as_str()))
189 .unwrap_or("");
190
191 let args_str = parsed
192 .as_ref()
193 .and_then(|v| v.get("arguments").and_then(|a| a.as_str()))
194 .unwrap_or("");
195
196 if skill_name.is_empty() {
197 return ToolResult {
198 output: "参数缺少 name 字段".to_string(),
199 is_error: true,
200 };
201 }
202
203 match self
204 .skills
205 .iter()
206 .find(|s| s.frontmatter.name == skill_name)
207 {
208 Some(skill) => {
209 let content = resolve_skill_content(skill);
210 let resolved = content.replace("$ARGUMENTS", args_str);
211 ToolResult {
212 output: resolved,
213 is_error: false,
214 }
215 }
216 None => {
217 let available: Vec<&str> = self
218 .skills
219 .iter()
220 .map(|s| s.frontmatter.name.as_str())
221 .collect();
222 ToolResult {
223 output: format!(
224 "未找到技能 '{}'。可用技能: {}",
225 skill_name,
226 available.join(", ")
227 ),
228 is_error: true,
229 }
230 }
231 }
232 }
233
234 fn requires_confirmation(&self) -> bool {
235 false
236 }
237}