返回 DeepSeek-TUI-2026
skill.rs
根目录 / crates / tui / src / tools / skill.rs
1 //! `load_skill` tool — fetch a `SKILL.md` body and its companion-file
2 //! list into the model's context (#434).
3 //!
4 //! ## Why a tool when skills already surface in the system prompt?
5 //!
6 //! `prompts.rs::system_prompt_for_mode_with_context_and_skills` injects
7 //! a one-line listing of every available skill (name + description +
8 //! file path) so the model knows what's in the catalogue at the start
9 //! of every turn. The full body of each skill is *not* loaded — that
10 //! would blow the prompt budget the moment a user has half a dozen
11 //! skills installed.
12 //!
13 //! Two paths exist for the model to actually read a skill:
14 //!
15 //! 1. The existing progressive-disclosure pattern: model spots a
16 //! skill in the catalogue, calls `read_file <path>` from the
17 //! listing.
18 //! 2. (this tool) `load_skill name=<id>` — single call, name-based
19 //! lookup, also enumerates the sibling files in the skill's
20 //! directory so the model sees the companion resources without
21 //! a separate `list_dir`.
22 //!
23 //! Both are valid; the tool is the higher-level affordance and
24 //! avoids the two-call dance for skills that ship with multiple
25 //! resource files.
26
27 use async_trait::async_trait;
28 use serde_json::{Value, json};
29
30 use crate::skills::{Skill, discover_in_workspace, skills_directories};
31
32 use super::spec::{
33 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
34 };
35
36 pub struct LoadSkillTool;
37
38 #[async_trait]
39 impl ToolSpec for LoadSkillTool {
40 fn name(&self) -> &'static str {
41 "load_skill"
42 }
43
44 fn description(&self) -> &'static str {
45 "Load a skill (SKILL.md body + companion file list) into the next turn's context. \
46 Use this when the user names a skill or the task clearly matches a skill listed in the system prompt's `## Skills` section. Faster than read_file + list_dir."
47 }
48
49 fn input_schema(&self) -> Value {
50 json!({
51 "type": "object",
52 "properties": {
53 "name": {
54 "type": "string",
55 "description": "Skill id (the `name` field from the SKILL.md frontmatter, also shown in the `## Skills` listing)."
56 }
57 },
58 "required": ["name"],
59 "additionalProperties": false
60 })
61 }
62
63 fn capabilities(&self) -> Vec<ToolCapability> {
64 vec![ToolCapability::ReadOnly]
65 }
66
67 fn approval_requirement(&self) -> ApprovalRequirement {
68 ApprovalRequirement::Auto
69 }
70
71 fn supports_parallel(&self) -> bool {
72 true
73 }
74
75 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
76 let name = input
77 .get("name")
78 .and_then(Value::as_str)
79 .ok_or_else(|| ToolError::missing_field("name"))?
80 .trim();
81 if name.is_empty() {
82 return Err(ToolError::invalid_input(
83 "`name` must be a non-empty string",
84 ));
85 }
86
87 // #432: walk every candidate skill directory (workspace
88 // .agents/skills, skills, .opencode/skills, .claude/skills,
89 // .cursor/skills, ~/.agents/skills, global default), merging with
90 // first-wins precedence. The
91 // tool's lookup mirrors what the system-prompt skills block
92 // already lists, so the model never asks for a name it
93 // can't find.
94 let registry = discover_in_workspace(&context.workspace);
95 let Some(skill) = registry.get(name) else {
96 let available: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
97 let hint = if available.is_empty() {
98 let dirs: Vec<String> = skills_directories(&context.workspace)
99 .iter()
100 .map(|p| p.display().to_string())
101 .collect();
102 if dirs.is_empty() {
103 "no skills directories found; install skills under `<workspace>/.agents/skills/<name>/SKILL.md`, `~/.agents/skills/<name>/SKILL.md`, or `~/.deepseek/skills/<name>/SKILL.md`"
104 .to_string()
105 } else {
106 format!("no skills installed. Searched: {}", dirs.join(", "))
107 }
108 } else {
109 format!(
110 "skill `{name}` not found. Available: {}",
111 available.join(", ")
112 )
113 };
114 return Err(ToolError::execution_failed(hint));
115 };
116
117 let body = format_skill_body(skill);
118 Ok(ToolResult::success(body).with_metadata(json!({
119 "skill_name": skill.name,
120 "skill_path": skill.path.display().to_string(),
121 "companion_files": collect_companion_files(skill)
122 .into_iter()
123 .map(|p| p.display().to_string())
124 .collect::<Vec<String>>(),
125 })))
126 }
127 }
128
129 /// Render the skill body the model will see. Includes the description
130 /// up top so a single tool result is self-contained — no need to
131 /// cross-reference the system-prompt catalogue. Companion-file paths
132 /// land at the bottom under a clearly-named heading so the model can
133 /// open them with `read_file` if they're relevant to the task.
134 fn format_skill_body(skill: &Skill) -> String {
135 let mut out = String::new();
136 out.push_str(&format!("# Skill: {}\n\n", skill.name));
137 if !skill.description.trim().is_empty() {
138 out.push_str(&format!("> {}\n\n", skill.description.trim()));
139 }
140 out.push_str(&format!("Source: `{}`\n\n", skill.path.display()));
141 out.push_str("## SKILL.md\n\n");
142 out.push_str(skill.body.trim());
143 out.push('\n');
144
145 let companions = collect_companion_files(skill);
146 if !companions.is_empty() {
147 out.push_str("\n## Companion files\n\n");
148 out.push_str(
149 "Sibling files in the skill directory. Use `read_file` to open them when the task requires.\n\n",
150 );
151 for path in &companions {
152 out.push_str(&format!("- `{}`\n", path.display()));
153 }
154 }
155 out
156 }
157
158 /// List sibling files of `SKILL.md` in the skill's own directory.
159 /// Skips the `SKILL.md` itself and any nested directories so the
160 /// listing stays focused on at-hand resources. Sorted lexically for
161 /// deterministic output (matters for transcript diffing in tests).
162 fn collect_companion_files(skill: &Skill) -> Vec<std::path::PathBuf> {
163 let Some(dir) = skill.path.parent() else {
164 return Vec::new();
165 };
166 let mut entries: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
167 Ok(rd) => rd
168 .flatten()
169 .filter_map(|entry| {
170 let path = entry.path();
171 let is_file = entry.file_type().is_ok_and(|ft| ft.is_file());
172 let is_skill_md = path.file_name().and_then(|s| s.to_str()) == Some("SKILL.md");
173 if is_file && !is_skill_md {
174 Some(path)
175 } else {
176 None
177 }
178 })
179 .collect(),
180 Err(_) => Vec::new(),
181 };
182 entries.sort();
183 entries
184 }
185
186 #[cfg(test)]
187 mod tests {
188 use super::*;
189 use crate::skills::SkillRegistry;
190 use std::fs;
191 use tempfile::tempdir;
192
193 fn write_skill(dir: &std::path::Path, name: &str, description: &str, body: &str) {
194 let skill_dir = dir.join(name);
195 fs::create_dir_all(&skill_dir).unwrap();
196 fs::write(
197 skill_dir.join("SKILL.md"),
198 format!("---\nname: {name}\ndescription: {description}\n---\n{body}\n"),
199 )
200 .unwrap();
201 }
202
203 #[test]
204 fn load_skill_returns_skill_body_with_description_header() {
205 let tmp = tempdir().unwrap();
206 write_skill(
207 tmp.path(),
208 "review-pr",
209 "Run a focused PR review",
210 "# Steps\n1. Read the diff.\n2. Comment.\n",
211 );
212 let skill = SkillRegistry::discover(tmp.path())
213 .get("review-pr")
214 .unwrap()
215 .clone();
216 let body = format_skill_body(&skill);
217 assert!(body.contains("# Skill: review-pr"));
218 assert!(body.contains("Run a focused PR review"));
219 assert!(body.contains("# Steps"));
220 assert!(body.contains("Read the diff."));
221 }
222
223 #[test]
224 fn collect_companion_files_lists_siblings_excluding_skill_md() {
225 let tmp = tempdir().unwrap();
226 let skill_dir = tmp.path().join("rich-skill");
227 fs::create_dir_all(&skill_dir).unwrap();
228 fs::write(
229 skill_dir.join("SKILL.md"),
230 "---\nname: rich-skill\ndescription: x\n---\nbody\n",
231 )
232 .unwrap();
233 fs::write(skill_dir.join("script.py"), "print('hi')").unwrap();
234 fs::write(skill_dir.join("data.json"), "{}").unwrap();
235 // Nested directory — skipped by collect_companion_files.
236 fs::create_dir_all(skill_dir.join("subdir")).unwrap();
237
238 let registry = SkillRegistry::discover(tmp.path());
239 let skill = registry.get("rich-skill").unwrap();
240 let files = collect_companion_files(skill);
241 let names: Vec<String> = files
242 .iter()
243 .filter_map(|p| p.file_name().and_then(|s| s.to_str().map(str::to_string)))
244 .collect();
245 assert_eq!(
246 names,
247 vec!["data.json".to_string(), "script.py".to_string()]
248 );
249 }
250
251 #[test]
252 fn collect_companion_files_returns_empty_for_solo_skill() {
253 let tmp = tempdir().unwrap();
254 write_skill(tmp.path(), "solo", "Just a skill", "body");
255 let registry = SkillRegistry::discover(tmp.path());
256 let skill = registry.get("solo").unwrap();
257 assert!(collect_companion_files(skill).is_empty());
258 }
259
260 #[test]
261 fn format_skill_body_emits_companion_files_section_when_present() {
262 let tmp = tempdir().unwrap();
263 let skill_dir = tmp.path().join("skill-with-friends");
264 fs::create_dir_all(&skill_dir).unwrap();
265 fs::write(
266 skill_dir.join("SKILL.md"),
267 "---\nname: skill-with-friends\ndescription: x\n---\nbody\n",
268 )
269 .unwrap();
270 fs::write(skill_dir.join("helper.sh"), "#!/bin/sh\necho hi").unwrap();
271
272 let registry = SkillRegistry::discover(tmp.path());
273 let skill = registry.get("skill-with-friends").unwrap();
274 let body = format_skill_body(skill);
275 assert!(body.contains("## Companion files"));
276 assert!(body.contains("helper.sh"));
277 }
278
279 #[test]
280 fn format_skill_body_skips_companion_section_when_solo() {
281 let tmp = tempdir().unwrap();
282 write_skill(tmp.path(), "solo", "x", "body");
283 let registry = SkillRegistry::discover(tmp.path());
284 let skill = registry.get("solo").unwrap();
285 let body = format_skill_body(skill);
286 assert!(
287 !body.contains("## Companion files"),
288 "solo skills shouldn't emit an empty Companion files section"
289 );
290 }
291
292 #[tokio::test]
293 async fn execute_finds_skills_in_opencode_dir_via_workspace_discovery() {
294 let tmp = tempdir().unwrap();
295 let workspace = tmp.path().to_path_buf();
296 // Skill installed under workspace `.opencode/skills` (#432).
297 let opencode_dir = workspace.join(".opencode").join("skills");
298 std::fs::create_dir_all(&opencode_dir).unwrap();
299 write_skill(
300 &opencode_dir,
301 "from-opencode",
302 "Skill installed under .opencode/skills",
303 "Body content marker.",
304 );
305
306 let mut context = ToolContext::new(workspace);
307 // The skill tool reads $HOME for the global default; pin it to a
308 // tempdir so the test is hermetic regardless of the host's
309 // ~/.deepseek/skills.
310 context.workspace = tmp.path().to_path_buf();
311
312 let tool = LoadSkillTool;
313 let result = tool
314 .execute(json!({"name": "from-opencode"}), &context)
315 .await
316 .expect("load_skill should succeed");
317 assert!(result.success);
318 assert!(
319 result.content.contains("# Skill: from-opencode"),
320 "body header missing: {}",
321 &result.content
322 );
323 assert!(result.content.contains("Body content marker."));
324
325 let metadata = result.metadata.expect("metadata stamped");
326 assert_eq!(
327 metadata
328 .get("skill_name")
329 .and_then(serde_json::Value::as_str),
330 Some("from-opencode")
331 );
332 let path_str = metadata
333 .get("skill_path")
334 .and_then(serde_json::Value::as_str)
335 .expect("skill_path stamped");
336 assert!(
337 path_str.contains(".opencode"),
338 "skill_path should point at the .opencode dir: {path_str}"
339 );
340 }
341
342 #[tokio::test]
343 async fn execute_returns_helpful_error_for_unknown_skill() {
344 let tmp = tempdir().unwrap();
345 let workspace = tmp.path().to_path_buf();
346 // One real skill so the available list is non-empty.
347 write_skill(
348 &workspace.join(".agents").join("skills"),
349 "real-one",
350 "x",
351 "body",
352 );
353
354 let context = ToolContext::new(workspace);
355 let tool = LoadSkillTool;
356 let err = tool
357 .execute(json!({"name": "imaginary"}), &context)
358 .await
359 .expect_err("unknown skill should error");
360 let msg = err.to_string();
361 assert!(
362 msg.contains("imaginary") && msg.contains("real-one"),
363 "error must name the missing skill and list available ones: {msg}"
364 );
365 }
366 }
367
367 lines RUST