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