返回 CodeWhale
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 a
7 //! budgeted first page of routing metadata. The full catalogue is available
8 //! through `name="list"`, and each full body is loaded only by exact name.
9 //!
10 //! `load_skill name=<id>` is the canonical progressive-disclosure path. It
11 //! performs a name-based host lookup, so native global skills work without
12 //! widening the model's workspace file authority, and it enumerates companion
13 //! files without a separate `list_dir`. Reviewed plugin skills are exposed
14 //! only through this tool's content-bound in-memory snapshot; their mutable
15 //! source paths and companion files are deliberately not returned.
16
17 use async_trait::async_trait;
18 use serde_json::{Value, json};
19
20 use crate::skills::{
21 Skill, SkillDiscoveryMode, SkillSource, discover_for_workspace_and_dir_with_mode_and_plugins,
22 discover_in_workspace_with_mode_and_plugins, skill_directories_for_workspace_and_dir,
23 skills_directories_for_mode,
24 };
25
26 use super::spec::{
27 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
28 };
29
30 pub struct LoadSkillTool;
31
32 #[async_trait]
33 impl ToolSpec for LoadSkillTool {
34 fn name(&self) -> &'static str {
35 "load_skill"
36 }
37
38 fn description(&self) -> &'static str {
39 "Load a named skill's SKILL.md body and companion file list into this turn. Use when \
40 the user names a skill, or when an entry in the system prompt's `## Skills` index \
41 matches the task -- load it before starting the work, not after. Pass query=\"...\" to \
42 search names and descriptions, or name=\"list\" for the whole catalogue. Resolves \
43 global and plugin skills that `read` cannot reach."
44 }
45
46 fn input_schema(&self) -> Value {
47 json!({
48 "type": "object",
49 "properties": {
50 "name": {
51 "type": "string",
52 "description": "Skill id to load. Omit or pass \"list\" to see all available skills."
53 },
54 "query": {
55 "type": "string",
56 "description": "Search term matched against skill names and descriptions. Use when the index was truncated or no name is known."
57 }
58 },
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 .unwrap_or("")
80 .trim();
81
82 // #432: walk every candidate skill directory (workspace
83 // .agents/skills, skills, .opencode/skills, .claude/skills,
84 // .cursor/skills, ~/.agents/skills, global default), merging with
85 // first-wins precedence. The
86 // tool's lookup mirrors what the system-prompt skills block
87 // already lists, so the model never asks for a name it
88 // can't find.
89 let discovery_mode = context.skills_discovery_mode;
90 let registry = if let Some(skills_dir) = context.skills_dir.as_deref() {
91 discover_for_workspace_and_dir_with_mode_and_plugins(
92 &context.workspace,
93 skills_dir,
94 discovery_mode,
95 context.plugin_registry.as_deref(),
96 )
97 } else {
98 discover_in_workspace_with_mode_and_plugins(
99 &context.workspace,
100 discovery_mode,
101 context.plugin_registry.as_deref(),
102 )
103 }
104 .into_enabled();
105
106 // Listing mode: empty name, "*", or "list" returns the full registry (#4651).
107 // A `query` filters that listing over the same routing metadata the
108 // ambient index carries, so a truncated index does not force the model
109 // to pull every skill to find one.
110 let query = input
111 .get("query")
112 .and_then(Value::as_str)
113 .unwrap_or("")
114 .trim()
115 .to_lowercase();
116 if !query.is_empty() || name.is_empty() || name == "*" || name == "list" {
117 let all = registry.list();
118 let skills: Vec<&_> = all
119 .iter()
120 .filter(|skill| skill.invocation.model_invocable())
121 .filter(|skill| {
122 query.is_empty()
123 || skill.name.to_lowercase().contains(&query)
124 || skill.description.to_lowercase().contains(&query)
125 })
126 .collect();
127 if skills.is_empty() {
128 return Ok(ToolResult::success(if query.is_empty() {
129 "No skills installed.".to_string()
130 } else {
131 format!("No skill matches {query:?}. Pass name=\"list\" for the catalogue.")
132 }));
133 }
134 let mut listing = if query.is_empty() {
135 format!("Available skills ({}):\n", skills.len())
136 } else {
137 format!("Skills matching {:?} ({}):\n", query, skills.len())
138 };
139 for skill in skills {
140 if skill.description.trim().is_empty() {
141 listing.push_str(&format!(" - {}\n", skill.name));
142 } else {
143 listing.push_str(&format!(" - {} — {}\n", skill.name, skill.description));
144 }
145 }
146 return Ok(ToolResult::success(listing));
147 }
148
149 let Some(skill) = registry.get(name) else {
150 let available: Vec<&str> = registry
151 .list()
152 .iter()
153 .filter(|s| s.invocation.model_invocable())
154 .map(|s| s.name.as_str())
155 .collect();
156 let hint = if available.is_empty() {
157 let dirs: Vec<String> = context
158 .skills_dir
159 .as_deref()
160 .map(|skills_dir| {
161 skill_directories_for_workspace_and_dir(
162 &context.workspace,
163 skills_dir,
164 discovery_mode,
165 )
166 })
167 .unwrap_or_else(|| {
168 skills_directories_for_mode(&context.workspace, discovery_mode)
169 })
170 .iter()
171 .map(|p| p.display().to_string())
172 .collect();
173 if dirs.is_empty() {
174 if context.skills_discovery_mode == SkillDiscoveryMode::CodeWhaleOnly {
175 "no skills directories found; install skills under `<workspace>/.codewhale/skills/<name>/SKILL.md` or `~/.codewhale/skills/<name>/SKILL.md`"
176 .to_string()
177 } else {
178 "no skills directories found; install skills under `<workspace>/.agents/skills/<name>/SKILL.md`, `~/.codewhale/skills/<name>/SKILL.md`, or `~/.deepseek/skills/<name>/SKILL.md`"
179 .to_string()
180 }
181 } else {
182 format!("no skills installed. Searched: {}", dirs.join(", "))
183 }
184 } else {
185 format!(
186 "skill `{name}` not found. Available: {}",
187 available.join(", ")
188 )
189 };
190 return Err(ToolError::execution_failed(hint));
191 };
192
193 if !skill.invocation.model_invocable() {
194 return Err(ToolError::execution_failed(format!(
195 "Skill `{}` does not allow model invocation; ask the user to invoke an enabled skill explicitly",
196 skill.name
197 )));
198 }
199 ensure_reviewed_plugin_skill_is_current_for(
200 skill,
201 &context.workspace,
202 context.plugin_registry.as_deref(),
203 )?;
204 ensure_native_skill_file_present(skill)?;
205 let body = format_skill_body(skill);
206 let (skill_path, skill_source) = match &skill.source {
207 SkillSource::Native => (Some(skill.path.display().to_string()), "native".to_string()),
208 SkillSource::Plugin {
209 plugin_id,
210 plugin_name,
211 ..
212 } => (
213 None,
214 format!("reviewed-plugin-snapshot:{plugin_name}:{plugin_id}"),
215 ),
216 };
217 Ok(ToolResult::success(body).with_metadata(json!({
218 "skill_name": skill.name,
219 "skill_path": skill_path,
220 "skill_source": skill_source,
221 "companion_files": collect_companion_files(skill)
222 .into_iter()
223 .map(|p| p.display().to_string())
224 .collect::<Vec<String>>(),
225 })))
226 }
227 }
228
229 /// A native registry entry whose SKILL.md vanished from disk after discovery
230 /// (deleted, or resolved under a wrong home directory) must fail loudly with
231 /// the exact path — never silently serve the stale cached body while the user
232 /// believes the skill loaded (§2.5).
233 fn ensure_native_skill_file_present(skill: &Skill) -> Result<(), ToolError> {
234 if !matches!(skill.source, SkillSource::Native) || skill.path.is_file() {
235 return Ok(());
236 }
237 let message = format!(
238 "Skill `{}` is registered at {} but that file no longer exists on disk, \
239 so the skill did not load. Restore the file, or fix the skills directory it \
240 came from (`skills_dir` in config.toml, `$CODEWHALE_HOME`, or the OS home) — \
241 the path above shows exactly where the runtime looked.",
242 skill.name,
243 skill.path.display()
244 );
245 crate::logging::warn(&message);
246 Err(ToolError::execution_failed(message))
247 }
248
249 #[cfg(test)]
250 fn ensure_reviewed_plugin_skill_is_current(
251 skill: &Skill,
252 workspace: &std::path::Path,
253 ) -> Result<(), ToolError> {
254 ensure_reviewed_plugin_skill_is_current_for(skill, workspace, None)
255 }
256 fn ensure_reviewed_plugin_skill_is_current_for(
257 skill: &Skill,
258 workspace: &std::path::Path,
259 plugins: Option<&crate::plugins::PluginRegistry>,
260 ) -> Result<(), ToolError> {
261 let Some(provenance) = skill.source.provenance() else {
262 return Ok(());
263 };
264 provenance.verify_for(workspace,plugins).map_err(|reason| ToolError::execution_failed(format!(
265 "Plugin skill `{}` was denied: {reason}. Reload and select the skill again before retrying", skill.name)))
266 }
267
268 /// Render the skill body the model will see. Includes the description
269 /// up top so a single tool result is self-contained — no need to
270 /// cross-reference the system-prompt catalogue. Companion-file paths
271 /// land at the bottom under a clearly-named heading so the model can
272 /// open them with `read_file` if they're relevant to the task.
273 fn format_skill_body(skill: &Skill) -> String {
274 let mut out = String::new();
275 out.push_str(&format!("# Skill: {}\n\n", skill.name));
276 if !skill.description.trim().is_empty() {
277 out.push_str(&format!("> {}\n\n", skill.description.trim()));
278 }
279 let invocation = match skill.invocation {
280 crate::skills::SkillInvocation::ModelAndUser => "model+user",
281 crate::skills::SkillInvocation::ExplicitOnly => "explicit-only",
282 crate::skills::SkillInvocation::ModelOnly => "model-only",
283 crate::skills::SkillInvocation::Disabled => "disabled",
284 };
285 out.push_str(&format!("Invocation: `{invocation}`\n"));
286 if !skill.aliases.is_empty() {
287 out.push_str(&format!("Aliases: `{}`\n", skill.aliases.join("`, `")));
288 }
289 out.push('\n');
290 match &skill.source {
291 SkillSource::Native => out.push_str(&format!("Source: `{}`\n\n", skill.path.display())),
292 SkillSource::Plugin {
293 plugin_id,
294 plugin_name,
295 ..
296 } => out.push_str(&format!(
297 "Source: reviewed in-memory plugin snapshot `{plugin_name}` ({plugin_id})\n\n"
298 )),
299 }
300 out.push_str("## SKILL.md\n\n");
301 out.push_str(skill.body.trim());
302 out.push('\n');
303
304 let companions = collect_companion_files(skill);
305 if !companions.is_empty() {
306 out.push_str("\n## Companion files\n\n");
307 out.push_str(
308 "Sibling files in the skill directory. Open one with `read` (path=...) when the task requires it; a skill stored outside the workspace has to be read through `bash` instead.\n\n",
309 );
310 for path in &companions {
311 out.push_str(&format!("- `{}`\n", path.display()));
312 }
313 }
314 out
315 }
316
317 /// List sibling files of `SKILL.md` in the skill's own directory.
318 /// Skips the `SKILL.md` itself and any nested directories so the
319 /// listing stays focused on at-hand resources. Sorted lexically for
320 /// deterministic output (matters for transcript diffing in tests).
321 fn collect_companion_files(skill: &Skill) -> Vec<std::path::PathBuf> {
322 if matches!(&skill.source, SkillSource::Plugin { .. }) {
323 // Companion files remain hashed, but exposing their mutable on-disk
324 // paths would let content change after review and bypass the snapshot.
325 return Vec::new();
326 }
327 let Some(dir) = skill.path.parent() else {
328 return Vec::new();
329 };
330 let mut entries: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
331 Ok(rd) => rd
332 .flatten()
333 .filter_map(|entry| {
334 let path = entry.path();
335 let is_file = entry.file_type().is_ok_and(|ft| ft.is_file());
336 let is_skill_md = path.file_name().and_then(|s| s.to_str()) == Some("SKILL.md");
337 if is_file && !is_skill_md {
338 Some(path)
339 } else {
340 None
341 }
342 })
343 .collect(),
344 Err(_) => Vec::new(),
345 };
346 entries.sort();
347 entries
348 }
349
350 #[cfg(test)]
351 mod tests {
352 use super::*;
353 use crate::skills::SkillRegistry;
354 use std::fs;
355 use tempfile::tempdir;
356
357 fn write_skill(dir: &std::path::Path, name: &str, description: &str, body: &str) {
358 let skill_dir = dir.join(name);
359 fs::create_dir_all(&skill_dir).unwrap();
360 fs::write(
361 skill_dir.join("SKILL.md"),
362 format!("---\nname: {name}\ndescription: {description}\n---\n{body}\n"),
363 )
364 .unwrap();
365 }
366
367 #[test]
368 fn load_skill_returns_skill_body_with_description_header() {
369 let tmp = tempdir().unwrap();
370 write_skill(
371 tmp.path(),
372 "review-pr",
373 "Run a focused PR review",
374 "# Steps\n1. Read the diff.\n2. Comment.\n",
375 );
376 let skill = SkillRegistry::discover(tmp.path())
377 .get("review-pr")
378 .unwrap()
379 .clone();
380 let body = format_skill_body(&skill);
381 assert!(body.contains("# Skill: review-pr"));
382 assert!(body.contains("Run a focused PR review"));
383 assert!(body.contains("# Steps"));
384 assert!(body.contains("Read the diff."));
385 }
386
387 #[test]
388 fn collect_companion_files_lists_siblings_excluding_skill_md() {
389 let tmp = tempdir().unwrap();
390 let skill_dir = tmp.path().join("rich-skill");
391 fs::create_dir_all(&skill_dir).unwrap();
392 fs::write(
393 skill_dir.join("SKILL.md"),
394 "---\nname: rich-skill\ndescription: x\n---\nbody\n",
395 )
396 .unwrap();
397 fs::write(skill_dir.join("script.py"), "print('hi')").unwrap();
398 fs::write(skill_dir.join("data.json"), "{}").unwrap();
399 // Nested directory — skipped by collect_companion_files.
400 fs::create_dir_all(skill_dir.join("subdir")).unwrap();
401
402 let registry = SkillRegistry::discover(tmp.path());
403 let skill = registry.get("rich-skill").unwrap();
404 let files = collect_companion_files(skill);
405 let names: Vec<String> = files
406 .iter()
407 .filter_map(|p| p.file_name().and_then(|s| s.to_str().map(str::to_string)))
408 .collect();
409 assert_eq!(
410 names,
411 vec!["data.json".to_string(), "script.py".to_string()]
412 );
413 }
414
415 #[test]
416 fn native_skill_with_vanished_file_fails_loudly_with_the_path() {
417 // §2.5: a registry entry pointing at a SKILL.md that no longer exists
418 // must surface the exact path instead of silently serving the stale
419 // cached body — this is the "delegate skill silently never loads"
420 // symptom class.
421 let tmp = tempdir().unwrap();
422 let missing = tmp.path().join("delegate").join("SKILL.md");
423 let skill = Skill {
424 legacy_activation_name: None,
425 name: "delegate".to_string(),
426 description: "delegate work".to_string(),
427 localized_descriptions: std::collections::HashMap::new(),
428 invocation: crate::skills::SkillInvocation::ModelAndUser,
429 aliases: Vec::new(),
430 argument_hint: None,
431 body: "cached body".to_string(),
432 path: missing.clone(),
433 source: SkillSource::Native,
434 };
435 let err = ensure_native_skill_file_present(&skill)
436 .expect_err("a vanished SKILL.md must fail loudly");
437 let message = err.to_string();
438 assert!(
439 message.contains(&missing.display().to_string()),
440 "error names the exact path: {message}"
441 );
442 assert!(
443 message.contains("did not load"),
444 "error says the skill did not load: {message}"
445 );
446
447 // An existing file passes, and plugin skills are untouched (their
448 // content-bound snapshot never consults the mutable path).
449 let present_dir = tempdir().unwrap();
450 let present = present_dir.path().join("SKILL.md");
451 fs::write(&present, "body").unwrap();
452 let mut on_disk = skill.clone();
453 on_disk.path = present;
454 ensure_native_skill_file_present(&on_disk).expect("present file loads");
455 let mut plugin = skill;
456 plugin.source = SkillSource::Plugin {
457 plugin_id: "workspace/1/demo".to_string(),
458 plugin_name: "demo".to_string(),
459 native_registration: None,
460 authority: Box::new(crate::plugins::types::PluginAuthority {
461 plugin_id: crate::plugins::types::PluginId("workspace/1/demo".to_string()),
462 plugin_name: "demo".to_string(),
463 workspace: tmp.path().to_path_buf(),
464 state_path: tmp.path().join("state.json"),
465 source_manifest: tmp.path().join("plugin.toml"),
466 staged_manifest: tmp.path().join("staged/plugin.toml"),
467 content_hash: "0".repeat(64),
468 capability_hash: "0".repeat(64),
469 state_generation: 0,
470 }),
471 };
472 ensure_native_skill_file_present(&plugin).expect("plugin snapshot skips the disk check");
473 }
474
475 #[test]
476 fn plugin_skill_body_uses_reviewed_snapshot_without_mutable_file_paths() {
477 let tmp = tempdir().unwrap();
478 let skill_path = tmp.path().join("SKILL.md");
479 fs::write(&skill_path, "changed on disk").unwrap();
480 fs::write(tmp.path().join("companion.txt"), "changed companion").unwrap();
481 let skill = Skill {
482 legacy_activation_name: None,
483 name: "demo:hello".to_string(),
484 description: "hello".to_string(),
485 localized_descriptions: std::collections::HashMap::new(),
486 invocation: crate::skills::SkillInvocation::ModelAndUser,
487 aliases: Vec::new(),
488 argument_hint: None,
489 body: "reviewed body".to_string(),
490 path: skill_path.clone(),
491 source: SkillSource::Plugin {
492 plugin_id: "workspace/123/demo".to_string(),
493 plugin_name: "demo".to_string(),
494 native_registration: None,
495 authority: Box::new(crate::plugins::types::PluginAuthority {
496 plugin_id: crate::plugins::types::PluginId("workspace/123/demo".to_string()),
497 plugin_name: "demo".to_string(),
498 workspace: tmp.path().to_path_buf(),
499 state_path: tmp.path().join("state.json"),
500 source_manifest: tmp.path().join("plugin.toml"),
501 staged_manifest: tmp.path().join("staged/plugin.toml"),
502 content_hash: "0".repeat(64),
503 capability_hash: "0".repeat(64),
504 state_generation: 0,
505 }),
506 },
507 };
508
509 let rendered = format_skill_body(&skill);
510 assert!(rendered.contains("reviewed body"));
511 assert!(rendered.contains("reviewed in-memory plugin snapshot"));
512 assert!(!rendered.contains(&skill_path.display().to_string()));
513 assert!(collect_companion_files(&skill).is_empty());
514 }
515
516 #[test]
517 fn plugin_skill_load_fails_closed_when_reviewed_bundle_drifts() {
518 let _lock = crate::test_support::lock_test_env();
519 let tmp = tempdir().unwrap();
520 let home = tmp.path().join("home");
521 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
522 let bundle = tmp.path().join(".codewhale/plugins/demo");
523 let skill_dir = bundle.join("skills/hello");
524 fs::create_dir_all(&skill_dir).unwrap();
525 fs::write(
526 bundle.join("plugin.toml"),
527 "schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n[skills]\npath = \"skills\"\n",
528 )
529 .unwrap();
530 fs::write(
531 skill_dir.join("SKILL.md"),
532 "---\nname: hello\ndescription: hello\n---\nreviewed body\n",
533 )
534 .unwrap();
535 fs::write(skill_dir.join("companion.txt"), "reviewed companion").unwrap();
536
537 let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
538 let mut plugins = discovery.registry_for_workspace(tmp.path());
539 std::sync::Arc::make_mut(&mut plugins)
540 .trust("demo")
541 .unwrap();
542 std::sync::Arc::make_mut(&mut plugins)
543 .enable("demo")
544 .unwrap();
545 let registry = crate::skills::discover_in_workspace_with_mode_and_plugins(
546 tmp.path(),
547 SkillDiscoveryMode::CodeWhaleOnly,
548 Some(plugins.as_ref()),
549 );
550 let skill = registry.get("demo:hello").expect("active plugin skill");
551 ensure_reviewed_plugin_skill_is_current(skill, tmp.path())
552 .expect("stable reviewed snapshot");
553
554 fs::write(skill_dir.join("companion.txt"), "changed after review").unwrap();
555 let error = ensure_reviewed_plugin_skill_is_current(skill, tmp.path())
556 .expect_err("bundle drift must deny the reviewed skill snapshot");
557 assert!(error.to_string().contains("changed after review"));
558 }
559
560 #[test]
561 fn collect_companion_files_returns_empty_for_solo_skill() {
562 let tmp = tempdir().unwrap();
563 write_skill(tmp.path(), "solo", "Just a skill", "body");
564 let registry = SkillRegistry::discover(tmp.path());
565 let skill = registry.get("solo").unwrap();
566 assert!(collect_companion_files(skill).is_empty());
567 }
568
569 #[test]
570 fn format_skill_body_emits_companion_files_section_when_present() {
571 let tmp = tempdir().unwrap();
572 let skill_dir = tmp.path().join("skill-with-friends");
573 fs::create_dir_all(&skill_dir).unwrap();
574 fs::write(
575 skill_dir.join("SKILL.md"),
576 "---\nname: skill-with-friends\ndescription: x\n---\nbody\n",
577 )
578 .unwrap();
579 fs::write(skill_dir.join("helper.sh"), "#!/bin/sh\necho hi").unwrap();
580
581 let registry = SkillRegistry::discover(tmp.path());
582 let skill = registry.get("skill-with-friends").unwrap();
583 let body = format_skill_body(skill);
584 assert!(body.contains("## Companion files"));
585 assert!(body.contains("helper.sh"));
586 // Companion guidance names the model-visible tools only.
587 assert!(body.contains("`read` (path=...)"), "{body}");
588 assert!(body.contains("`bash`"), "{body}");
589 assert!(
590 !body.contains("File action=") && !body.contains("through Bash"),
591 "{body}"
592 );
593 }
594
595 #[test]
596 fn format_skill_body_skips_companion_section_when_solo() {
597 let tmp = tempdir().unwrap();
598 write_skill(tmp.path(), "solo", "x", "body");
599 let registry = SkillRegistry::discover(tmp.path());
600 let skill = registry.get("solo").unwrap();
601 let body = format_skill_body(skill);
602 assert!(
603 !body.contains("## Companion files"),
604 "solo skills shouldn't emit an empty Companion files section"
605 );
606 }
607
608 #[tokio::test]
609 async fn execute_lists_available_skills_for_empty_star_and_list_names() {
610 let _lock = crate::test_support::lock_test_env();
611 let tmp = tempdir().unwrap();
612 // Pin home-based global skill roots to the tempdir so host skills
613 // never leak into the listing count.
614 let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path().join("home"));
615 let _cw_home =
616 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join("cw-home"));
617 let workspace = tmp.path().to_path_buf();
618 crate::test_support::trust_workspace(&workspace);
619 let skills_dir = workspace.join(".codewhale").join("skills");
620 write_skill(&skills_dir, "alpha-skill", "First demo skill", "Body A.");
621 write_skill(&skills_dir, "beta-skill", "", "Body B.");
622
623 let context = ToolContext::new(workspace);
624 let tool = LoadSkillTool;
625
626 // #4651: listing is an action inside the single load_skill tool —
627 // empty name, "*", and "list" all enumerate the reviewed registry.
628 for listing_name in [json!({}), json!({"name": "*"}), json!({"name": "list"})] {
629 let result = tool
630 .execute(listing_name.clone(), &context)
631 .await
632 .expect("listing should succeed");
633 assert!(result.success);
634 assert!(
635 result.content.contains("Available skills (2)"),
636 "listing for {listing_name} should count skills: {}",
637 result.content
638 );
639 assert!(
640 result.content.contains("alpha-skill — First demo skill"),
641 "listing should include name and description: {}",
642 result.content
643 );
644 assert!(
645 result.content.contains("- beta-skill"),
646 "listing should include description-less skills: {}",
647 result.content
648 );
649 }
650 }
651
652 #[tokio::test]
653 async fn execute_listing_reports_empty_registry_plainly() {
654 let _lock = crate::test_support::lock_test_env();
655 let tmp = tempdir().unwrap();
656 let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path().join("home"));
657 let _cw_home =
658 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join("cw-home"));
659 let context = ToolContext::new(tmp.path().to_path_buf());
660 let result = LoadSkillTool
661 .execute(json!({"name": "list"}), &context)
662 .await
663 .expect("empty listing should still succeed");
664 assert!(result.success);
665 assert!(
666 result.content.contains("No skills installed."),
667 "{}",
668 result.content
669 );
670 }
671
672 #[tokio::test]
673 async fn execute_finds_skills_in_opencode_dir_via_workspace_discovery() {
674 let tmp = tempdir().unwrap();
675 let workspace = tmp.path().to_path_buf();
676 crate::test_support::trust_workspace(&workspace);
677 // Skill installed under workspace `.opencode/skills` (#432).
678 let opencode_dir = workspace.join(".opencode").join("skills");
679 std::fs::create_dir_all(&opencode_dir).unwrap();
680 write_skill(
681 &opencode_dir,
682 "from-opencode",
683 "Skill installed under .opencode/skills",
684 "Body content marker.",
685 );
686
687 let mut context = ToolContext::new(workspace);
688 // The skill tool reads $HOME for the global default; pin it to a
689 // tempdir so the test is hermetic regardless of the host's
690 // ~/.deepseek/skills.
691 context.workspace = tmp.path().to_path_buf();
692
693 let tool = LoadSkillTool;
694 let result = tool
695 .execute(json!({"name": "from-opencode"}), &context)
696 .await
697 .expect("load_skill should succeed");
698 assert!(result.success);
699 assert!(
700 result.content.contains("# Skill: from-opencode"),
701 "body header missing: {}",
702 result.content
703 );
704 assert!(result.content.contains("Body content marker."));
705
706 let metadata = result.metadata.expect("metadata stamped");
707 assert_eq!(
708 metadata
709 .get("skill_name")
710 .and_then(serde_json::Value::as_str),
711 Some("from-opencode")
712 );
713 let path_str = metadata
714 .get("skill_path")
715 .and_then(serde_json::Value::as_str)
716 .expect("skill_path stamped");
717 assert!(
718 path_str.contains(".opencode"),
719 "skill_path should point at the .opencode dir: {path_str}"
720 );
721 }
722
723 #[tokio::test]
724 async fn execute_respects_codewhale_only_skill_discovery() {
725 let tmp = tempdir().unwrap();
726 let workspace = tmp.path().to_path_buf();
727 crate::test_support::trust_workspace(&workspace);
728 write_skill(
729 &workspace.join(".claude").join("skills"),
730 "claude-only",
731 "Claude skill",
732 "Body content marker.",
733 );
734 let codewhale_dir = workspace.join(".codewhale").join("skills");
735 write_skill(
736 &codewhale_dir,
737 "codewhale-only",
738 "CodeWhale skill",
739 "Body content marker.",
740 );
741
742 let context = ToolContext::new(workspace).with_skills_config(
743 codewhale_dir,
744 crate::skills::SkillDiscoveryMode::CodeWhaleOnly,
745 );
746 let tool = LoadSkillTool;
747
748 let result = tool
749 .execute(json!({"name": "codewhale-only"}), &context)
750 .await
751 .expect("CodeWhale skill should load");
752 assert!(result.success);
753
754 let err = tool
755 .execute(json!({"name": "claude-only"}), &context)
756 .await
757 .expect_err("Claude skill should be hidden in CodeWhale-only mode");
758 let msg = err.to_string();
759 assert!(
760 msg.contains("claude-only") && msg.contains("codewhale-only"),
761 "error should name the missing skill and available strict catalog: {msg}"
762 );
763 }
764
765 #[tokio::test]
766 async fn execute_loads_configured_external_skill_without_workspace_trust() {
767 let tmp = tempdir().unwrap();
768 let workspace = tmp.path().join("workspace");
769 let home = tmp.path().join("home");
770 let global_skills = home.join(".codewhale/skills");
771 fs::create_dir_all(&workspace).unwrap();
772 write_skill(
773 &global_skills,
774 "global-helper",
775 "Global helper",
776 "Global body marker.",
777 );
778
779 // Keep this test independent of the process-native home directory:
780 // `crate::config::effective_home_dir()` cannot be redirected reliably after process start
781 // on Windows. The injected-home discovery test in `skills::tests`
782 // separately proves that ~/.codewhale/skills enters the default catalog.
783 let context = ToolContext::new(&workspace).with_skills_config(
784 global_skills.clone(),
785 crate::skills::SkillDiscoveryMode::Compatible,
786 );
787 assert!(!context.trust_mode);
788 assert!(
789 context
790 .resolve_path(
791 global_skills
792 .join("global-helper/SKILL.md")
793 .to_str()
794 .unwrap()
795 )
796 .is_err(),
797 "ordinary file tools must retain the workspace boundary"
798 );
799
800 let result = LoadSkillTool
801 .execute(json!({"name": "global-helper"}), &context)
802 .await
803 .expect("load_skill host lookup should open a configured external skill root");
804 assert!(result.success);
805 assert!(result.content.contains("Global body marker."));
806 }
807
808 #[tokio::test]
809 async fn execute_returns_helpful_error_for_unknown_skill() {
810 let tmp = tempdir().unwrap();
811 let workspace = tmp.path().to_path_buf();
812 crate::test_support::trust_workspace(&workspace);
813 // One real skill so the available list is non-empty.
814 write_skill(
815 &workspace.join(".agents").join("skills"),
816 "real-one",
817 "x",
818 "body",
819 );
820
821 let context = ToolContext::new(workspace);
822 let tool = LoadSkillTool;
823 let err = tool
824 .execute(json!({"name": "imaginary"}), &context)
825 .await
826 .expect_err("unknown skill should error");
827 let msg = err.to_string();
828 assert!(
829 msg.contains("imaginary") && msg.contains("real-one"),
830 "error must name the missing skill and list available ones: {msg}"
831 );
832 }
833 #[tokio::test]
834 async fn model_load_and_listing_obey_independent_invocation_gates() {
835 let tmp = tempfile::tempdir().unwrap();
836 let workspace = tmp.path().join("workspace");
837 crate::test_support::trust_workspace(&workspace);
838 let root = workspace.join(".codewhale/skills");
839 for (name, policy) in [
840 ("explicit", "disable-model-invocation: true"),
841 (
842 "disabled",
843 "disable-model-invocation: true\nuser-invocable: false",
844 ),
845 ("model", "user-invocable: false"),
846 ] {
847 let dir = root.join(name);
848 std::fs::create_dir_all(&dir).unwrap();
849 std::fs::write(dir.join("SKILL.md"), format!("---\nname: {name}\ndescription: routing\n{policy}\n---\nsecret steps for {name}")).unwrap();
850 }
851 let context = ToolContext::new(&workspace)
852 .with_skills_config(&root, SkillDiscoveryMode::CodeWhaleOnly);
853 let tool = LoadSkillTool;
854 for name in ["explicit", "disabled"] {
855 let error = tool
856 .execute(json!({"name":name}), &context)
857 .await
858 .unwrap_err();
859 assert!(
860 error
861 .to_string()
862 .contains("does not allow model invocation")
863 );
864 }
865 let listing = tool
866 .execute(json!({"name":"list"}), &context)
867 .await
868 .unwrap();
869 assert!(listing.content.contains("model"));
870 assert!(!listing.content.contains("explicit"));
871 assert!(!listing.content.contains("disabled"));
872 let query = tool
873 .execute(json!({"query":"explicit"}), &context)
874 .await
875 .unwrap();
876 assert!(!query.content.contains("secret steps"));
877 assert!(!query.content.contains(" - explicit"));
878 assert!(
879 tool.execute(json!({"name":"model"}), &context)
880 .await
881 .unwrap()
882 .content
883 .contains("secret steps for model")
884 );
885 }
886 }
887
887 lines RUST