| 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 |