| 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_authority(authority).map_err(|reason| { |
| 225 | ToolError::execution_failed(format!( |
| 226 | "Plugin skill `{}` was denied: {reason}. Run `/plugin reload`, inspect `/plugin show {plugin_name}`, then repeat the displayed trust command and enable it before retrying", |
| 227 | skill.name |
| 228 | )) |
| 229 | }) |
| 230 | } |
| 231 | |
| 232 | /// Render the skill body the model will see. Includes the description |
| 233 | /// up top so a single tool result is self-contained — no need to |
| 234 | /// cross-reference the system-prompt catalogue. Companion-file paths |
| 235 | /// land at the bottom under a clearly-named heading so the model can |
| 236 | /// open them with `read_file` if they're relevant to the task. |
| 237 | fn format_skill_body(skill: &Skill) -> String { |
| 238 | let mut out = String::new(); |
| 239 | out.push_str(&format!("# Skill: {}\n\n", skill.name)); |
| 240 | if !skill.description.trim().is_empty() { |
| 241 | out.push_str(&format!("> {}\n\n", skill.description.trim())); |
| 242 | } |
| 243 | let invocation = match skill.invocation { |
| 244 | crate::skills::SkillInvocation::ModelAndUser => "model+user", |
| 245 | crate::skills::SkillInvocation::ExplicitOnly => "explicit-only", |
| 246 | }; |
| 247 | out.push_str(&format!("Invocation: `{invocation}`\n")); |
| 248 | if !skill.aliases.is_empty() { |
| 249 | out.push_str(&format!("Aliases: `{}`\n", skill.aliases.join("`, `"))); |
| 250 | } |
| 251 | out.push('\n'); |
| 252 | match &skill.source { |
| 253 | SkillSource::Native => out.push_str(&format!("Source: `{}`\n\n", skill.path.display())), |
| 254 | SkillSource::Plugin { |
| 255 | plugin_id, |
| 256 | plugin_name, |
| 257 | .. |
| 258 | } => out.push_str(&format!( |
| 259 | "Source: reviewed in-memory plugin snapshot `{plugin_name}` ({plugin_id})\n\n" |
| 260 | )), |
| 261 | } |
| 262 | out.push_str("## SKILL.md\n\n"); |
| 263 | out.push_str(skill.body.trim()); |
| 264 | out.push('\n'); |
| 265 | |
| 266 | let companions = collect_companion_files(skill); |
| 267 | if !companions.is_empty() { |
| 268 | out.push_str("\n## Companion files\n\n"); |
| 269 | out.push_str( |
| 270 | "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", |
| 271 | ); |
| 272 | for path in &companions { |
| 273 | out.push_str(&format!("- `{}`\n", path.display())); |
| 274 | } |
| 275 | } |
| 276 | out |
| 277 | } |
| 278 | |
| 279 | /// List sibling files of `SKILL.md` in the skill's own directory. |
| 280 | /// Skips the `SKILL.md` itself and any nested directories so the |
| 281 | /// listing stays focused on at-hand resources. Sorted lexically for |
| 282 | /// deterministic output (matters for transcript diffing in tests). |
| 283 | fn collect_companion_files(skill: &Skill) -> Vec<std::path::PathBuf> { |
| 284 | if matches!(&skill.source, SkillSource::Plugin { .. }) { |
| 285 | // Companion files remain hashed, but exposing their mutable on-disk |
| 286 | // paths would let content change after review and bypass the snapshot. |
| 287 | return Vec::new(); |
| 288 | } |
| 289 | let Some(dir) = skill.path.parent() else { |
| 290 | return Vec::new(); |
| 291 | }; |
| 292 | let mut entries: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) { |
| 293 | Ok(rd) => rd |
| 294 | .flatten() |
| 295 | .filter_map(|entry| { |
| 296 | let path = entry.path(); |
| 297 | let is_file = entry.file_type().is_ok_and(|ft| ft.is_file()); |
| 298 | let is_skill_md = path.file_name().and_then(|s| s.to_str()) == Some("SKILL.md"); |
| 299 | if is_file && !is_skill_md { |
| 300 | Some(path) |
| 301 | } else { |
| 302 | None |
| 303 | } |
| 304 | }) |
| 305 | .collect(), |
| 306 | Err(_) => Vec::new(), |
| 307 | }; |
| 308 | entries.sort(); |
| 309 | entries |
| 310 | } |
| 311 | |
| 312 | #[cfg(test)] |
| 313 | mod tests { |
| 314 | use super::*; |
| 315 | use crate::skills::SkillRegistry; |
| 316 | use std::fs; |
| 317 | use tempfile::tempdir; |
| 318 | |
| 319 | fn write_skill(dir: &std::path::Path, name: &str, description: &str, body: &str) { |
| 320 | let skill_dir = dir.join(name); |
| 321 | fs::create_dir_all(&skill_dir).unwrap(); |
| 322 | fs::write( |
| 323 | skill_dir.join("SKILL.md"), |
| 324 | format!("---\nname: {name}\ndescription: {description}\n---\n{body}\n"), |
| 325 | ) |
| 326 | .unwrap(); |
| 327 | } |
| 328 | |
| 329 | #[test] |
| 330 | fn load_skill_returns_skill_body_with_description_header() { |
| 331 | let tmp = tempdir().unwrap(); |
| 332 | write_skill( |
| 333 | tmp.path(), |
| 334 | "review-pr", |
| 335 | "Run a focused PR review", |
| 336 | "# Steps\n1. Read the diff.\n2. Comment.\n", |
| 337 | ); |
| 338 | let skill = SkillRegistry::discover(tmp.path()) |
| 339 | .get("review-pr") |
| 340 | .unwrap() |
| 341 | .clone(); |
| 342 | let body = format_skill_body(&skill); |
| 343 | assert!(body.contains("# Skill: review-pr")); |
| 344 | assert!(body.contains("Run a focused PR review")); |
| 345 | assert!(body.contains("# Steps")); |
| 346 | assert!(body.contains("Read the diff.")); |
| 347 | } |
| 348 | |
| 349 | #[test] |
| 350 | fn collect_companion_files_lists_siblings_excluding_skill_md() { |
| 351 | let tmp = tempdir().unwrap(); |
| 352 | let skill_dir = tmp.path().join("rich-skill"); |
| 353 | fs::create_dir_all(&skill_dir).unwrap(); |
| 354 | fs::write( |
| 355 | skill_dir.join("SKILL.md"), |
| 356 | "---\nname: rich-skill\ndescription: x\n---\nbody\n", |
| 357 | ) |
| 358 | .unwrap(); |
| 359 | fs::write(skill_dir.join("script.py"), "print('hi')").unwrap(); |
| 360 | fs::write(skill_dir.join("data.json"), "{}").unwrap(); |
| 361 | // Nested directory — skipped by collect_companion_files. |
| 362 | fs::create_dir_all(skill_dir.join("subdir")).unwrap(); |
| 363 | |
| 364 | let registry = SkillRegistry::discover(tmp.path()); |
| 365 | let skill = registry.get("rich-skill").unwrap(); |
| 366 | let files = collect_companion_files(skill); |
| 367 | let names: Vec<String> = files |
| 368 | .iter() |
| 369 | .filter_map(|p| p.file_name().and_then(|s| s.to_str().map(str::to_string))) |
| 370 | .collect(); |
| 371 | assert_eq!( |
| 372 | names, |
| 373 | vec!["data.json".to_string(), "script.py".to_string()] |
| 374 | ); |
| 375 | } |
| 376 | |
| 377 | #[test] |
| 378 | fn native_skill_with_vanished_file_fails_loudly_with_the_path() { |
| 379 | // §2.5: a registry entry pointing at a SKILL.md that no longer exists |
| 380 | // must surface the exact path instead of silently serving the stale |
| 381 | // cached body — this is the "delegate skill silently never loads" |
| 382 | // symptom class. |
| 383 | let tmp = tempdir().unwrap(); |
| 384 | let missing = tmp.path().join("delegate").join("SKILL.md"); |
| 385 | let skill = Skill { |
| 386 | name: "delegate".to_string(), |
| 387 | description: "delegate work".to_string(), |
| 388 | localized_descriptions: std::collections::HashMap::new(), |
| 389 | invocation: crate::skills::SkillInvocation::ModelAndUser, |
| 390 | aliases: Vec::new(), |
| 391 | body: "cached body".to_string(), |
| 392 | path: missing.clone(), |
| 393 | source: SkillSource::Native, |
| 394 | }; |
| 395 | let err = ensure_native_skill_file_present(&skill) |
| 396 | .expect_err("a vanished SKILL.md must fail loudly"); |
| 397 | let message = err.to_string(); |
| 398 | assert!( |
| 399 | message.contains(&missing.display().to_string()), |
| 400 | "error names the exact path: {message}" |
| 401 | ); |
| 402 | assert!( |
| 403 | message.contains("did not load"), |
| 404 | "error says the skill did not load: {message}" |
| 405 | ); |
| 406 | |
| 407 | // An existing file passes, and plugin skills are untouched (their |
| 408 | // content-bound snapshot never consults the mutable path). |
| 409 | let present_dir = tempdir().unwrap(); |
| 410 | let present = present_dir.path().join("SKILL.md"); |
| 411 | fs::write(&present, "body").unwrap(); |
| 412 | let mut on_disk = skill.clone(); |
| 413 | on_disk.path = present; |
| 414 | ensure_native_skill_file_present(&on_disk).expect("present file loads"); |
| 415 | let mut plugin = skill; |
| 416 | plugin.source = SkillSource::Plugin { |
| 417 | plugin_id: "workspace/1/demo".to_string(), |
| 418 | plugin_name: "demo".to_string(), |
| 419 | authority: Box::new(crate::plugins::types::PluginAuthority { |
| 420 | plugin_id: crate::plugins::types::PluginId("workspace/1/demo".to_string()), |
| 421 | plugin_name: "demo".to_string(), |
| 422 | workspace: tmp.path().to_path_buf(), |
| 423 | state_path: tmp.path().join("state.json"), |
| 424 | source_manifest: tmp.path().join("plugin.toml"), |
| 425 | staged_manifest: tmp.path().join("staged/plugin.toml"), |
| 426 | content_hash: "0".repeat(64), |
| 427 | capability_hash: "0".repeat(64), |
| 428 | state_generation: 0, |
| 429 | }), |
| 430 | }; |
| 431 | ensure_native_skill_file_present(&plugin).expect("plugin snapshot skips the disk check"); |
| 432 | } |
| 433 | |
| 434 | #[test] |
| 435 | fn plugin_skill_body_uses_reviewed_snapshot_without_mutable_file_paths() { |
| 436 | let tmp = tempdir().unwrap(); |
| 437 | let skill_path = tmp.path().join("SKILL.md"); |
| 438 | fs::write(&skill_path, "changed on disk").unwrap(); |
| 439 | fs::write(tmp.path().join("companion.txt"), "changed companion").unwrap(); |
| 440 | let skill = Skill { |
| 441 | name: "demo:hello".to_string(), |
| 442 | description: "hello".to_string(), |
| 443 | localized_descriptions: std::collections::HashMap::new(), |
| 444 | invocation: crate::skills::SkillInvocation::ModelAndUser, |
| 445 | aliases: Vec::new(), |
| 446 | body: "reviewed body".to_string(), |
| 447 | path: skill_path.clone(), |
| 448 | source: SkillSource::Plugin { |
| 449 | plugin_id: "workspace/123/demo".to_string(), |
| 450 | plugin_name: "demo".to_string(), |
| 451 | authority: Box::new(crate::plugins::types::PluginAuthority { |
| 452 | plugin_id: crate::plugins::types::PluginId("workspace/123/demo".to_string()), |
| 453 | plugin_name: "demo".to_string(), |
| 454 | workspace: tmp.path().to_path_buf(), |
| 455 | state_path: tmp.path().join("state.json"), |
| 456 | source_manifest: tmp.path().join("plugin.toml"), |
| 457 | staged_manifest: tmp.path().join("staged/plugin.toml"), |
| 458 | content_hash: "0".repeat(64), |
| 459 | capability_hash: "0".repeat(64), |
| 460 | state_generation: 0, |
| 461 | }), |
| 462 | }, |
| 463 | }; |
| 464 | |
| 465 | let rendered = format_skill_body(&skill); |
| 466 | assert!(rendered.contains("reviewed body")); |
| 467 | assert!(rendered.contains("reviewed in-memory plugin snapshot")); |
| 468 | assert!(!rendered.contains(&skill_path.display().to_string())); |
| 469 | assert!(collect_companion_files(&skill).is_empty()); |
| 470 | } |
| 471 | |
| 472 | #[test] |
| 473 | fn plugin_skill_load_fails_closed_when_reviewed_bundle_drifts() { |
| 474 | let _lock = crate::test_support::lock_test_env(); |
| 475 | let tmp = tempdir().unwrap(); |
| 476 | let home = tmp.path().join("home"); |
| 477 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home); |
| 478 | let bundle = tmp.path().join(".codewhale/plugins/demo"); |
| 479 | let skill_dir = bundle.join("skills/hello"); |
| 480 | fs::create_dir_all(&skill_dir).unwrap(); |
| 481 | fs::write( |
| 482 | bundle.join("plugin.toml"), |
| 483 | "schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n[skills]\npath = \"skills\"\n", |
| 484 | ) |
| 485 | .unwrap(); |
| 486 | fs::write( |
| 487 | skill_dir.join("SKILL.md"), |
| 488 | "---\nname: hello\ndescription: hello\n---\nreviewed body\n", |
| 489 | ) |
| 490 | .unwrap(); |
| 491 | fs::write(skill_dir.join("companion.txt"), "reviewed companion").unwrap(); |
| 492 | |
| 493 | let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv(); |
| 494 | let mut plugins = discovery.registry_for_workspace(tmp.path()); |
| 495 | std::sync::Arc::make_mut(&mut plugins) |
| 496 | .trust("demo") |
| 497 | .unwrap(); |
| 498 | std::sync::Arc::make_mut(&mut plugins) |
| 499 | .enable("demo") |
| 500 | .unwrap(); |
| 501 | let registry = crate::skills::discover_in_workspace_with_mode_and_plugins( |
| 502 | tmp.path(), |
| 503 | SkillDiscoveryMode::CodeWhaleOnly, |
| 504 | Some(plugins.as_ref()), |
| 505 | ); |
| 506 | let skill = registry.get("demo:hello").expect("active plugin skill"); |
| 507 | ensure_reviewed_plugin_skill_is_current(skill, tmp.path()) |
| 508 | .expect("stable reviewed snapshot"); |
| 509 | |
| 510 | fs::write(skill_dir.join("companion.txt"), "changed after review").unwrap(); |
| 511 | let error = ensure_reviewed_plugin_skill_is_current(skill, tmp.path()) |
| 512 | .expect_err("bundle drift must deny the reviewed skill snapshot"); |
| 513 | assert!(error.to_string().contains("changed after review")); |
| 514 | } |
| 515 | |
| 516 | #[test] |
| 517 | fn collect_companion_files_returns_empty_for_solo_skill() { |
| 518 | let tmp = tempdir().unwrap(); |
| 519 | write_skill(tmp.path(), "solo", "Just a skill", "body"); |
| 520 | let registry = SkillRegistry::discover(tmp.path()); |
| 521 | let skill = registry.get("solo").unwrap(); |
| 522 | assert!(collect_companion_files(skill).is_empty()); |
| 523 | } |
| 524 | |
| 525 | #[test] |
| 526 | fn format_skill_body_emits_companion_files_section_when_present() { |
| 527 | let tmp = tempdir().unwrap(); |
| 528 | let skill_dir = tmp.path().join("skill-with-friends"); |
| 529 | fs::create_dir_all(&skill_dir).unwrap(); |
| 530 | fs::write( |
| 531 | skill_dir.join("SKILL.md"), |
| 532 | "---\nname: skill-with-friends\ndescription: x\n---\nbody\n", |
| 533 | ) |
| 534 | .unwrap(); |
| 535 | fs::write(skill_dir.join("helper.sh"), "#!/bin/sh\necho hi").unwrap(); |
| 536 | |
| 537 | let registry = SkillRegistry::discover(tmp.path()); |
| 538 | let skill = registry.get("skill-with-friends").unwrap(); |
| 539 | let body = format_skill_body(skill); |
| 540 | assert!(body.contains("## Companion files")); |
| 541 | assert!(body.contains("helper.sh")); |
| 542 | } |
| 543 | |
| 544 | #[test] |
| 545 | fn format_skill_body_skips_companion_section_when_solo() { |
| 546 | let tmp = tempdir().unwrap(); |
| 547 | write_skill(tmp.path(), "solo", "x", "body"); |
| 548 | let registry = SkillRegistry::discover(tmp.path()); |
| 549 | let skill = registry.get("solo").unwrap(); |
| 550 | let body = format_skill_body(skill); |
| 551 | assert!( |
| 552 | !body.contains("## Companion files"), |
| 553 | "solo skills shouldn't emit an empty Companion files section" |
| 554 | ); |
| 555 | } |
| 556 | |
| 557 | #[tokio::test] |
| 558 | async fn execute_lists_available_skills_for_empty_star_and_list_names() { |
| 559 | let _lock = crate::test_support::lock_test_env(); |
| 560 | let tmp = tempdir().unwrap(); |
| 561 | // Pin home-based global skill roots to the tempdir so host skills |
| 562 | // never leak into the listing count. |
| 563 | let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path().join("home")); |
| 564 | let _cw_home = |
| 565 | crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join("cw-home")); |
| 566 | let workspace = tmp.path().to_path_buf(); |
| 567 | let skills_dir = workspace.join(".codewhale").join("skills"); |
| 568 | write_skill(&skills_dir, "alpha-skill", "First demo skill", "Body A."); |
| 569 | write_skill(&skills_dir, "beta-skill", "", "Body B."); |
| 570 | |
| 571 | let context = ToolContext::new(workspace); |
| 572 | let tool = LoadSkillTool; |
| 573 | |
| 574 | // #4651: listing is an action inside the single load_skill tool — |
| 575 | // empty name, "*", and "list" all enumerate the reviewed registry. |
| 576 | for listing_name in [json!({}), json!({"name": "*"}), json!({"name": "list"})] { |
| 577 | let result = tool |
| 578 | .execute(listing_name.clone(), &context) |
| 579 | .await |
| 580 | .expect("listing should succeed"); |
| 581 | assert!(result.success); |
| 582 | assert!( |
| 583 | result.content.contains("Available skills (2)"), |
| 584 | "listing for {listing_name} should count skills: {}", |
| 585 | result.content |
| 586 | ); |
| 587 | assert!( |
| 588 | result.content.contains("alpha-skill — First demo skill"), |
| 589 | "listing should include name and description: {}", |
| 590 | result.content |
| 591 | ); |
| 592 | assert!( |
| 593 | result.content.contains("- beta-skill"), |
| 594 | "listing should include description-less skills: {}", |
| 595 | result.content |
| 596 | ); |
| 597 | } |
| 598 | } |
| 599 | |
| 600 | #[tokio::test] |
| 601 | async fn execute_listing_reports_empty_registry_plainly() { |
| 602 | let _lock = crate::test_support::lock_test_env(); |
| 603 | let tmp = tempdir().unwrap(); |
| 604 | let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path().join("home")); |
| 605 | let _cw_home = |
| 606 | crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join("cw-home")); |
| 607 | let context = ToolContext::new(tmp.path().to_path_buf()); |
| 608 | let result = LoadSkillTool |
| 609 | .execute(json!({"name": "list"}), &context) |
| 610 | .await |
| 611 | .expect("empty listing should still succeed"); |
| 612 | assert!(result.success); |
| 613 | assert!( |
| 614 | result.content.contains("No skills installed."), |
| 615 | "{}", |
| 616 | result.content |
| 617 | ); |
| 618 | } |
| 619 | |
| 620 | #[tokio::test] |
| 621 | async fn execute_finds_skills_in_opencode_dir_via_workspace_discovery() { |
| 622 | let tmp = tempdir().unwrap(); |
| 623 | let workspace = tmp.path().to_path_buf(); |
| 624 | // Skill installed under workspace `.opencode/skills` (#432). |
| 625 | let opencode_dir = workspace.join(".opencode").join("skills"); |
| 626 | std::fs::create_dir_all(&opencode_dir).unwrap(); |
| 627 | write_skill( |
| 628 | &opencode_dir, |
| 629 | "from-opencode", |
| 630 | "Skill installed under .opencode/skills", |
| 631 | "Body content marker.", |
| 632 | ); |
| 633 | |
| 634 | let mut context = ToolContext::new(workspace); |
| 635 | // The skill tool reads $HOME for the global default; pin it to a |
| 636 | // tempdir so the test is hermetic regardless of the host's |
| 637 | // ~/.deepseek/skills. |
| 638 | context.workspace = tmp.path().to_path_buf(); |
| 639 | |
| 640 | let tool = LoadSkillTool; |
| 641 | let result = tool |
| 642 | .execute(json!({"name": "from-opencode"}), &context) |
| 643 | .await |
| 644 | .expect("load_skill should succeed"); |
| 645 | assert!(result.success); |
| 646 | assert!( |
| 647 | result.content.contains("# Skill: from-opencode"), |
| 648 | "body header missing: {}", |
| 649 | result.content |
| 650 | ); |
| 651 | assert!(result.content.contains("Body content marker.")); |
| 652 | |
| 653 | let metadata = result.metadata.expect("metadata stamped"); |
| 654 | assert_eq!( |
| 655 | metadata |
| 656 | .get("skill_name") |
| 657 | .and_then(serde_json::Value::as_str), |
| 658 | Some("from-opencode") |
| 659 | ); |
| 660 | let path_str = metadata |
| 661 | .get("skill_path") |
| 662 | .and_then(serde_json::Value::as_str) |
| 663 | .expect("skill_path stamped"); |
| 664 | assert!( |
| 665 | path_str.contains(".opencode"), |
| 666 | "skill_path should point at the .opencode dir: {path_str}" |
| 667 | ); |
| 668 | } |
| 669 | |
| 670 | #[tokio::test] |
| 671 | async fn execute_respects_codewhale_only_skill_discovery() { |
| 672 | let tmp = tempdir().unwrap(); |
| 673 | let workspace = tmp.path().to_path_buf(); |
| 674 | write_skill( |
| 675 | &workspace.join(".claude").join("skills"), |
| 676 | "claude-only", |
| 677 | "Claude skill", |
| 678 | "Body content marker.", |
| 679 | ); |
| 680 | let codewhale_dir = workspace.join(".codewhale").join("skills"); |
| 681 | write_skill( |
| 682 | &codewhale_dir, |
| 683 | "codewhale-only", |
| 684 | "CodeWhale skill", |
| 685 | "Body content marker.", |
| 686 | ); |
| 687 | |
| 688 | let context = ToolContext::new(workspace).with_skills_config(codewhale_dir, true); |
| 689 | let tool = LoadSkillTool; |
| 690 | |
| 691 | let result = tool |
| 692 | .execute(json!({"name": "codewhale-only"}), &context) |
| 693 | .await |
| 694 | .expect("CodeWhale skill should load"); |
| 695 | assert!(result.success); |
| 696 | |
| 697 | let err = tool |
| 698 | .execute(json!({"name": "claude-only"}), &context) |
| 699 | .await |
| 700 | .expect_err("Claude skill should be hidden in CodeWhale-only mode"); |
| 701 | let msg = err.to_string(); |
| 702 | assert!( |
| 703 | msg.contains("claude-only") && msg.contains("codewhale-only"), |
| 704 | "error should name the missing skill and available strict catalog: {msg}" |
| 705 | ); |
| 706 | } |
| 707 | |
| 708 | #[tokio::test] |
| 709 | async fn execute_loads_configured_external_skill_without_workspace_trust() { |
| 710 | let tmp = tempdir().unwrap(); |
| 711 | let workspace = tmp.path().join("workspace"); |
| 712 | let home = tmp.path().join("home"); |
| 713 | let global_skills = home.join(".codewhale/skills"); |
| 714 | fs::create_dir_all(&workspace).unwrap(); |
| 715 | write_skill( |
| 716 | &global_skills, |
| 717 | "global-helper", |
| 718 | "Global helper", |
| 719 | "Global body marker.", |
| 720 | ); |
| 721 | |
| 722 | // Keep this test independent of the process-native home directory: |
| 723 | // `crate::config::effective_home_dir()` cannot be redirected reliably after process start |
| 724 | // on Windows. The injected-home discovery test in `skills::tests` |
| 725 | // separately proves that ~/.codewhale/skills enters the default catalog. |
| 726 | let context = ToolContext::new(&workspace).with_skills_config(global_skills.clone(), false); |
| 727 | assert!(!context.trust_mode); |
| 728 | assert!( |
| 729 | context |
| 730 | .resolve_path( |
| 731 | global_skills |
| 732 | .join("global-helper/SKILL.md") |
| 733 | .to_str() |
| 734 | .unwrap() |
| 735 | ) |
| 736 | .is_err(), |
| 737 | "ordinary file tools must retain the workspace boundary" |
| 738 | ); |
| 739 | |
| 740 | let result = LoadSkillTool |
| 741 | .execute(json!({"name": "global-helper"}), &context) |
| 742 | .await |
| 743 | .expect("load_skill host lookup should open a configured external skill root"); |
| 744 | assert!(result.success); |
| 745 | assert!(result.content.contains("Global body marker.")); |
| 746 | } |
| 747 | |
| 748 | #[tokio::test] |
| 749 | async fn execute_returns_helpful_error_for_unknown_skill() { |
| 750 | let tmp = tempdir().unwrap(); |
| 751 | let workspace = tmp.path().to_path_buf(); |
| 752 | // One real skill so the available list is non-empty. |
| 753 | write_skill( |
| 754 | &workspace.join(".agents").join("skills"), |
| 755 | "real-one", |
| 756 | "x", |
| 757 | "body", |
| 758 | ); |
| 759 | |
| 760 | let context = ToolContext::new(workspace); |
| 761 | let tool = LoadSkillTool; |
| 762 | let err = tool |
| 763 | .execute(json!({"name": "imaginary"}), &context) |
| 764 | .await |
| 765 | .expect_err("unknown skill should error"); |
| 766 | let msg = err.to_string(); |
| 767 | assert!( |
| 768 | msg.contains("imaginary") && msg.contains("real-one"), |
| 769 | "error must name the missing skill and list available ones: {msg}" |
| 770 | ); |
| 771 | } |
| 772 | } |
| 773 |