| 1 | //! Skills commands: skills, skill |
| 2 | //! |
| 3 | //! FEAT-022 Phase 4: portable contextual dispatch over |
| 4 | //! [`CommandSkillGroupContext`]; the legacy `RegisterCommand::execute` is a |
| 5 | //! transitional shell that builds the capability envelope and delegates (Phase |
| 6 | //! 6 replaces it with the contract bridge). The dispatcher-only |
| 7 | //! `run_skill_by_name` path and its shared host machinery |
| 8 | //! ([`discover_visible_skills`], [`activate_skill_with_task`]) stay |
| 9 | //! App-carrying and co-located for FEAT-042 extraction. |
| 10 | |
| 11 | use std::fmt::Write; |
| 12 | |
| 13 | use codewhale_command_contract::facets::{ |
| 14 | CommandSkillGroupContext, CommandSkillsContext, RemoteRegistryOutcome, SkillActivationError, |
| 15 | SkillBundledTier, SkillEntry, SkillMutationOutcome, SkillMutationReceipt, SkillSourceKind, |
| 16 | SkillSyncEntry, SkillSyncOutcome, SkillTargetScope, |
| 17 | }; |
| 18 | use codewhale_command_contract::handler::{CommandContexts, CommandHandler}; |
| 19 | use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; |
| 20 | |
| 21 | use crate::commands::CommandResult; |
| 22 | use crate::tui::app::AppAction; |
| 23 | |
| 24 | // --------------------------------------------------------------------------- |
| 25 | // Host-side dispatcher machinery (FEAT-042 handoff — stays App-carrying) |
| 26 | // --------------------------------------------------------------------------- |
| 27 | |
| 28 | /// Discover the enabled visible skills for the current App state. Shared by the |
| 29 | /// dispatcher fallback (`run_skill_by_name`) and the host activation helper; |
| 30 | /// kept co-located for FEAT-042. |
| 31 | fn discover_visible_skills(app: &crate::tui::app::App) -> crate::skills::SkillRegistry { |
| 32 | crate::skills::discover_for_workspace_and_dir_with_mode_and_plugins( |
| 33 | &app.workspace, |
| 34 | &app.skills_dir, |
| 35 | crate::skills::SkillDiscoveryMode::from_codewhale_only(app.skills_scan_codewhale_only), |
| 36 | Some(app.plugin_registry.as_ref()), |
| 37 | ) |
| 38 | .into_enabled() |
| 39 | } |
| 40 | |
| 41 | /// Run a specific skill — activates skill for next user message, or |
| 42 | /// dispatches a sub-command (`install`, `update`, `uninstall`, `trust`). |
| 43 | /// Try to run a skill by exact name (used for unified slash-command namespace, #435). |
| 44 | /// Returns None when no skill with that name exists, so the caller can try other sources. |
| 45 | pub(in crate::commands) fn run_skill_by_name( |
| 46 | app: &mut crate::tui::app::App, |
| 47 | name: &str, |
| 48 | arg: Option<&str>, |
| 49 | ) -> Option<CommandResult> { |
| 50 | let registry = discover_visible_skills(app); |
| 51 | let lookup_name = if name == "new" { "skill-creator" } else { name }; |
| 52 | if registry.get(lookup_name).is_some() { |
| 53 | Some(activate_skill_with_task(app, name, arg)) |
| 54 | } else { |
| 55 | None |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | /// Host-side activation helper shared with the dispatcher fallback. The |
| 60 | /// portable `/skill` path uses the `CommandSkillGroupContext` delegate instead |
| 61 | /// (D2); this App-carrying copy is retained for `run_skill_by_name` (FEAT-042). |
| 62 | fn activate_skill_with_task( |
| 63 | app: &mut crate::tui::app::App, |
| 64 | name: &str, |
| 65 | task: Option<&str>, |
| 66 | ) -> CommandResult { |
| 67 | let mut result = activate_skill(app, name); |
| 68 | if !result.is_error |
| 69 | && let Some(task) = task.map(str::trim).filter(|task| !task.is_empty()) |
| 70 | { |
| 71 | result.action = Some(AppAction::SendMessage(task.to_string())); |
| 72 | } |
| 73 | result |
| 74 | } |
| 75 | |
| 76 | /// Host-side `/skill <name>` activation (FEAT-042 dispatcher machinery). |
| 77 | fn activate_skill(app: &mut crate::tui::app::App, name: &str) -> CommandResult { |
| 78 | // `/skill new` is a friendly alias for `/skill skill-creator`. |
| 79 | let name = if name == "new" { "skill-creator" } else { name }; |
| 80 | |
| 81 | let registry = discover_visible_skills(app); |
| 82 | |
| 83 | if let Some(skill) = registry.get(name) { |
| 84 | let plugin_provenance = match &skill.source { |
| 85 | crate::skills::SkillSource::Native => None, |
| 86 | crate::skills::SkillSource::Plugin { authority, .. } => { |
| 87 | if let Err(reason) = crate::plugins::registry::verify_plugin_component_authority( |
| 88 | authority, |
| 89 | crate::plugins::activation::PluginActivationCapability::Skills, |
| 90 | ) { |
| 91 | return CommandResult::error(format!( |
| 92 | "Plugin skill '{}' is no longer active: {reason}", |
| 93 | skill.name |
| 94 | )); |
| 95 | } |
| 96 | Some(authority.as_ref().clone()) |
| 97 | } |
| 98 | }; |
| 99 | let instruction = format!( |
| 100 | "You are now using a skill. Follow these instructions:\n\n# Skill: {}\n\n{}\n\n---\n\nNow respond to the user's request following the above skill instructions.", |
| 101 | skill.name, skill.body |
| 102 | ); |
| 103 | |
| 104 | app.add_message(crate::tui::history::HistoryCell::System { |
| 105 | content: format!("Activated skill: {}\n\n{}", skill.name, skill.description), |
| 106 | }); |
| 107 | |
| 108 | app.active_skill = Some(instruction); |
| 109 | app.active_skill_provenance = plugin_provenance; |
| 110 | |
| 111 | CommandResult::message(format!( |
| 112 | "Skill '{}' activated.\n\nDescription: {}\n\nType your request and the skill instructions will be applied.", |
| 113 | skill.name, skill.description |
| 114 | )) |
| 115 | } else { |
| 116 | let available: Vec<String> = registry.list().iter().map(|s| s.name.clone()).collect(); |
| 117 | let warnings = render_skill_warnings(registry.warnings()); |
| 118 | |
| 119 | if available.is_empty() { |
| 120 | CommandResult::error(format!( |
| 121 | "Skill '{name}' not found. No skills installed.\n\nUse /skills to see how to add skills.{warnings}" |
| 122 | )) |
| 123 | } else { |
| 124 | CommandResult::error(format!( |
| 125 | "Skill '{}' not found.\n\nAvailable skills: {}{}", |
| 126 | name, |
| 127 | available.join(", "), |
| 128 | warnings |
| 129 | )) |
| 130 | } |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | // --------------------------------------------------------------------------- |
| 135 | // Portable rendering helpers (byte-identical to the pre-migration handlers) |
| 136 | // --------------------------------------------------------------------------- |
| 137 | |
| 138 | /// Render registry warnings as the baseline suffix block. |
| 139 | fn render_skill_warnings(warnings: &[String]) -> String { |
| 140 | if warnings.is_empty() { |
| 141 | return String::new(); |
| 142 | } |
| 143 | |
| 144 | let mut out = String::new(); |
| 145 | let _ = writeln!(out, "\nWarnings ({}):", warnings.len()); |
| 146 | for warning in warnings { |
| 147 | let _ = writeln!(out, " - {warning}"); |
| 148 | } |
| 149 | out |
| 150 | } |
| 151 | |
| 152 | /// Source label used by `/skills inspect` (baseline `skill_source_label`). |
| 153 | fn skill_source_label(source: &SkillSourceKind) -> String { |
| 154 | match source { |
| 155 | SkillSourceKind::Native => "native".to_string(), |
| 156 | SkillSourceKind::Plugin { |
| 157 | plugin_name, |
| 158 | plugin_id, |
| 159 | } => format!("reviewed plugin snapshot {plugin_name} ({plugin_id})"), |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | /// Network-policy approval message (baseline `needs_approval_message`). |
| 164 | fn needs_approval_message(host: &str) -> String { |
| 165 | format!( |
| 166 | "Network policy requires approval for {host}.\n\ |
| 167 | Add it to your allow list with `/network allow {host}` (or set [network].default = \"allow\" in ~/.codewhale/config.toml), then retry." |
| 168 | ) |
| 169 | } |
| 170 | |
| 171 | /// Network-policy denial message (baseline `network_denied_message`). |
| 172 | fn network_denied_message(host: &str) -> String { |
| 173 | format!( |
| 174 | "Network policy denied access to {host}.\n\ |
| 175 | Remove the deny entry from ~/.codewhale/config.toml under [network] or contact your administrator." |
| 176 | ) |
| 177 | } |
| 178 | |
| 179 | /// Render a mutation receipt byte-identically (baseline `format_mutation_receipt`). |
| 180 | fn format_mutation_receipt(receipt: &SkillMutationReceipt) -> String { |
| 181 | match &receipt.outcome { |
| 182 | SkillMutationOutcome::Installed => format!( |
| 183 | "Installed skill '{}'.\nLocation: {}\n\nManage skills with /skills.", |
| 184 | receipt.name, receipt.safe_target_path |
| 185 | ), |
| 186 | SkillMutationOutcome::Updated => format!( |
| 187 | "Skill '{}' updated.\nLocation: {}", |
| 188 | receipt.name, receipt.safe_target_path |
| 189 | ), |
| 190 | SkillMutationOutcome::NoChange => { |
| 191 | format!("Skill '{}': no upstream change.", receipt.name) |
| 192 | } |
| 193 | SkillMutationOutcome::Removed => format!("Removed skill '{}'.", receipt.name), |
| 194 | SkillMutationOutcome::Trusted => format!( |
| 195 | "Marked skill '{}' as trusted. The .trusted marker is advisory and digest-bound; it records your review intent but does not sandbox or auto-authorize scripts.", |
| 196 | receipt.name |
| 197 | ), |
| 198 | SkillMutationOutcome::Imported => format!( |
| 199 | "Imported skill '{}'.\nLocation: {}", |
| 200 | receipt.name, receipt.safe_target_path |
| 201 | ), |
| 202 | SkillMutationOutcome::AlreadyPresent => format!( |
| 203 | "Skill '{}' is already present at {} (exact duplicate).", |
| 204 | receipt.name, receipt.safe_target_path |
| 205 | ), |
| 206 | SkillMutationOutcome::NeedsApproval(host) => needs_approval_message(host), |
| 207 | SkillMutationOutcome::NetworkDenied(host) => network_denied_message(host), |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | /// Parse an optional `--project` / `--global` scope prefix (baseline |
| 212 | /// `parse_scope_args`, portable scope enum). |
| 213 | fn parse_scope_args(args: &str) -> Result<(Option<SkillTargetScope>, &str), String> { |
| 214 | let mut scope = None; |
| 215 | let mut rest = args.trim(); |
| 216 | loop { |
| 217 | if let Some(next) = rest.strip_prefix("--project") { |
| 218 | if scope.is_some() { |
| 219 | return Err("specify at most one of --project / --global".into()); |
| 220 | } |
| 221 | scope = Some(SkillTargetScope::Project); |
| 222 | rest = next.trim_start(); |
| 223 | continue; |
| 224 | } |
| 225 | if let Some(next) = rest.strip_prefix("--global") { |
| 226 | if scope.is_some() { |
| 227 | return Err("specify at most one of --project / --global".into()); |
| 228 | } |
| 229 | scope = Some(SkillTargetScope::Global); |
| 230 | rest = next.trim_start(); |
| 231 | continue; |
| 232 | } |
| 233 | break; |
| 234 | } |
| 235 | Ok((scope, rest.trim())) |
| 236 | } |
| 237 | |
| 238 | // --------------------------------------------------------------------------- |
| 239 | // /skills — portable contextual dispatch |
| 240 | // --------------------------------------------------------------------------- |
| 241 | |
| 242 | pub(in crate::commands) const SKILLS_INFO: CommandInfo = CommandInfo { |
| 243 | name: "skills", |
| 244 | aliases: &["jinengliebiao"], |
| 245 | usage: "/skills [manage|--remote|sync|inspect|suggest <task>|<prefix>] (bare opens Extensions)", |
| 246 | description_key: "cmd_skills_description", |
| 247 | }; |
| 248 | |
| 249 | pub(in crate::commands) struct SkillsCmd; |
| 250 | |
| 251 | impl RegisterCommand<CommandResult> for SkillsCmd { |
| 252 | fn info() -> &'static CommandInfo { |
| 253 | &SKILLS_INFO |
| 254 | } |
| 255 | |
| 256 | fn handler() -> CommandHandler<CommandResult> { |
| 257 | CommandHandler::Contextual { |
| 258 | capabilities: codewhale_command_contract::handler::CommandCapabilities::SKILL_GROUP, |
| 259 | handler: skills_contextual, |
| 260 | } |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | /// Contextual `/skills` dispatch (FEAT-022 D4): exactly the skill-group facet. |
| 265 | fn skills_contextual(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult { |
| 266 | let mut parts = contexts.into_parts(); |
| 267 | let Some(skill_group) = parts.skill_group.as_deref_mut() else { |
| 268 | return CommandResult::error("Command capability unavailable: skill_group"); |
| 269 | }; |
| 270 | list_skills(skill_group, arg) |
| 271 | } |
| 272 | |
| 273 | /// Shared inventory entry, with the dedicated mutation manager kept at `/skills manage`. |
| 274 | fn list_skills(group: &mut dyn CommandSkillGroupContext, arg: Option<&str>) -> CommandResult { |
| 275 | let mut prefix: Option<String> = None; |
| 276 | if let Some(arg) = arg { |
| 277 | let trimmed = arg.trim(); |
| 278 | if trimmed == "manage" { |
| 279 | return CommandResult::action(AppAction::OpenSkillsManager); |
| 280 | } |
| 281 | if trimmed == "--remote" || trimmed == "remote" { |
| 282 | return list_remote_skills(group); |
| 283 | } |
| 284 | if trimmed == "sync" || trimmed == "--sync" { |
| 285 | return sync_skills(group); |
| 286 | } |
| 287 | if trimmed == "inspect" || trimmed == "--inspect" { |
| 288 | return inspect_skills(group); |
| 289 | } |
| 290 | if trimmed == "suggest" || trimmed == "recommend" { |
| 291 | return CommandResult::error("Usage: /skills suggest <task>"); |
| 292 | } |
| 293 | if let Some(task) = trimmed |
| 294 | .strip_prefix("suggest ") |
| 295 | .or_else(|| trimmed.strip_prefix("recommend ")) |
| 296 | { |
| 297 | return suggest_remote_skills(group, task); |
| 298 | } |
| 299 | if !trimmed.is_empty() { |
| 300 | // Anything else is treated as a name-prefix filter (#1318). |
| 301 | // Reject obviously malformed args (whitespace inside the |
| 302 | // prefix, leading dash) so future flag additions don't |
| 303 | // collide with skill names. Skill names that start with |
| 304 | // `-` aren't allowed by the loader so this is safe. |
| 305 | if trimmed.starts_with('-') || trimmed.split_whitespace().count() > 1 { |
| 306 | return CommandResult::error( |
| 307 | "Usage: /skills [--remote|sync|inspect|suggest <task>|<name-prefix>]", |
| 308 | ); |
| 309 | } |
| 310 | prefix = Some(trimmed.to_ascii_lowercase()); |
| 311 | } |
| 312 | } else { |
| 313 | // Bare inventory is owned-only and performs no network requests. |
| 314 | return CommandResult::action(AppAction::OpenExtensions { |
| 315 | tab: crate::tui::views::extensions::ExtensionsTab::Skills, |
| 316 | }); |
| 317 | } |
| 318 | |
| 319 | let projection = group.skill_registry_projection(); |
| 320 | let warnings = render_skill_warnings(&projection.warnings); |
| 321 | let skills_dir = projection.skills_dir.clone(); |
| 322 | |
| 323 | if projection.entries.is_empty() { |
| 324 | let msg = format!( |
| 325 | "No skills found.\n\n\ |
| 326 | Skills location: {}\n\n\ |
| 327 | To add skills, create directories with SKILL.md files:\n \ |
| 328 | {}/my-skill/SKILL.md\n\n\ |
| 329 | Format:\n \ |
| 330 | ---\n \ |
| 331 | name: my-skill\n \ |
| 332 | description: What this skill does\n \ |
| 333 | ---\n\n \ |
| 334 | <instructions here>{warnings}", |
| 335 | skills_dir, skills_dir |
| 336 | ); |
| 337 | return CommandResult::message(msg); |
| 338 | } |
| 339 | |
| 340 | let filtered: Vec<&SkillEntry> = if let Some(p) = prefix.as_deref() { |
| 341 | projection |
| 342 | .entries |
| 343 | .iter() |
| 344 | .filter(|s| s.name.to_ascii_lowercase().starts_with(p)) |
| 345 | .collect() |
| 346 | } else { |
| 347 | projection.entries.iter().collect() |
| 348 | }; |
| 349 | |
| 350 | if filtered.is_empty() { |
| 351 | // The user typed a prefix that matched nothing. Surface what |
| 352 | // they typed plus the full count so they can decide whether |
| 353 | // to adjust the prefix or run `/skills` for the whole list. |
| 354 | let p = prefix.as_deref().unwrap_or(""); |
| 355 | return CommandResult::message(format!( |
| 356 | "No skills match prefix `{p}` (out of {} available).\n\nRun /skills to see them all.{warnings}", |
| 357 | projection.total |
| 358 | )); |
| 359 | } |
| 360 | |
| 361 | let mut output = if let Some(p) = prefix.as_deref() { |
| 362 | format!( |
| 363 | "Available skills matching `{p}` ({} of {}):\n", |
| 364 | filtered.len(), |
| 365 | projection.total |
| 366 | ) |
| 367 | } else { |
| 368 | format!("Available skills ({}):\n", projection.total) |
| 369 | }; |
| 370 | output.push_str("─────────────────────────────\n"); |
| 371 | |
| 372 | if prefix.is_some() { |
| 373 | // Filtered view: keep the flat list — the user already narrowed. |
| 374 | for (idx, skill) in filtered.iter().enumerate() { |
| 375 | if idx > 0 { |
| 376 | output.push('\n'); |
| 377 | } |
| 378 | let _ = writeln!(output, " /{} - {}", skill.name, skill.description); |
| 379 | } |
| 380 | } else { |
| 381 | // Unfiltered view: keep user-created skills prominent, then split the |
| 382 | // shipped catalog into its two curated product tiers. The tier |
| 383 | // classification is resolved host-side into `bundled_tier` so the |
| 384 | // canonical bundle-name list is never duplicated here. |
| 385 | let (user_skills, bundled_skills): (Vec<&SkillEntry>, Vec<&SkillEntry>) = |
| 386 | filtered.iter().partition(|s| s.bundled_tier.is_none()); |
| 387 | |
| 388 | if !user_skills.is_empty() { |
| 389 | let _ = writeln!(output, "Your skills ({}):", user_skills.len()); |
| 390 | for skill in &user_skills { |
| 391 | let _ = writeln!(output, " /{} - {}", skill.name, skill.description); |
| 392 | } |
| 393 | if !bundled_skills.is_empty() { |
| 394 | output.push('\n'); |
| 395 | } |
| 396 | } |
| 397 | |
| 398 | if !bundled_skills.is_empty() { |
| 399 | let (core, tooling): (Vec<&SkillEntry>, Vec<&SkillEntry>) = bundled_skills |
| 400 | .into_iter() |
| 401 | .partition(|skill| skill.bundled_tier == Some(SkillBundledTier::CoreAgentic)); |
| 402 | for (group_idx, (tier, skills)) in [ |
| 403 | (SkillBundledTier::CoreAgentic, core), |
| 404 | (SkillBundledTier::FormatTooling, tooling), |
| 405 | ] |
| 406 | .into_iter() |
| 407 | .enumerate() |
| 408 | { |
| 409 | if skills.is_empty() { |
| 410 | continue; |
| 411 | } |
| 412 | if group_idx > 0 { |
| 413 | output.push('\n'); |
| 414 | } |
| 415 | let _ = writeln!(output, "{} ({}):", tier.heading(), skills.len()); |
| 416 | if user_skills.is_empty() { |
| 417 | for skill in skills { |
| 418 | let _ = writeln!(output, " /{} - {}", skill.name, skill.description); |
| 419 | } |
| 420 | } else { |
| 421 | let names: Vec<String> = skills |
| 422 | .iter() |
| 423 | .map(|skill| format!("/{}", skill.name)) |
| 424 | .collect(); |
| 425 | let _ = writeln!(output, " {}", names.join(", ")); |
| 426 | } |
| 427 | } |
| 428 | if !user_skills.is_empty() { |
| 429 | output.push_str(" (run /skills <name> for details on a built-in)\n"); |
| 430 | } |
| 431 | } |
| 432 | } |
| 433 | |
| 434 | let _ = write!( |
| 435 | output, |
| 436 | "\nUse /skill <name> to run a skill\nSkills location: {}{}", |
| 437 | skills_dir, warnings |
| 438 | ); |
| 439 | |
| 440 | CommandResult::message(output) |
| 441 | } |
| 442 | |
| 443 | /// `/skills inspect` — byte-identical discovery diagnostics. |
| 444 | fn inspect_skills(group: &mut dyn CommandSkillGroupContext) -> CommandResult { |
| 445 | let projection = group.skill_registry_projection(); |
| 446 | let warnings = render_skill_warnings(&projection.warnings); |
| 447 | |
| 448 | let mut output = String::from("Skills Inspect\n"); |
| 449 | output.push_str("─────────────────────────────\n"); |
| 450 | let _ = writeln!(output, "Discovery mode: {}", projection.mode_label); |
| 451 | let _ = writeln!(output, "Workspace: {}", projection.workspace); |
| 452 | let _ = writeln!(output, "Configured skills dir: {}", projection.skills_dir); |
| 453 | |
| 454 | if projection.dirs.is_empty() { |
| 455 | output.push_str("\nSearched directories: none found\n"); |
| 456 | } else { |
| 457 | let _ = writeln!( |
| 458 | output, |
| 459 | "\nSearched directories ({}):", |
| 460 | projection.dirs.len() |
| 461 | ); |
| 462 | for (idx, dir) in projection.dirs.iter().enumerate() { |
| 463 | let _ = writeln!(output, " {}. {}", idx + 1, dir); |
| 464 | } |
| 465 | } |
| 466 | |
| 467 | let _ = writeln!(output, "\nAvailable skills ({}):", projection.total); |
| 468 | if projection.entries.is_empty() { |
| 469 | output.push_str(" (none)\n"); |
| 470 | } else { |
| 471 | for skill in &projection.entries { |
| 472 | if skill.description.trim().is_empty() { |
| 473 | let _ = writeln!(output, " - {}", skill.name); |
| 474 | } else { |
| 475 | let _ = writeln!(output, " - {} — {}", skill.name, skill.description); |
| 476 | } |
| 477 | let _ = writeln!(output, " source: {}", skill_source_label(&skill.source)); |
| 478 | if let Some(path) = skill |
| 479 | .path |
| 480 | .as_ref() |
| 481 | .filter(|_| matches!(skill.source, SkillSourceKind::Native)) |
| 482 | { |
| 483 | let _ = writeln!(output, " path: {}", path); |
| 484 | } |
| 485 | // The model index caps each description; say so here instead of |
| 486 | // cutting an imported skill mid-sentence with nobody told. |
| 487 | let description_chars = skill |
| 488 | .description |
| 489 | .split_whitespace() |
| 490 | .collect::<Vec<_>>() |
| 491 | .join(" ") |
| 492 | .chars() |
| 493 | .count(); |
| 494 | if description_chars > crate::skills::MAX_SKILL_DESCRIPTION_CHARS { |
| 495 | let _ = writeln!( |
| 496 | output, |
| 497 | " note: description is {description_chars} chars; the model index shows at most {} — trim it, or end it with `Use when: <trigger>` so the trigger survives shortening", |
| 498 | crate::skills::MAX_SKILL_DESCRIPTION_CHARS |
| 499 | ); |
| 500 | } |
| 501 | } |
| 502 | } |
| 503 | |
| 504 | output.push_str(&warnings); |
| 505 | CommandResult::message(output) |
| 506 | } |
| 507 | |
| 508 | /// `/skills --remote` — curated registry listing. |
| 509 | fn list_remote_skills(group: &mut dyn CommandSkillGroupContext) -> CommandResult { |
| 510 | match group.fetch_remote_registry() { |
| 511 | Ok(RemoteRegistryOutcome::Loaded { entries }) => { |
| 512 | if entries.is_empty() { |
| 513 | return CommandResult::message("Registry is empty."); |
| 514 | } |
| 515 | let mut out = format!("Available remote skills ({}):\n", entries.len()); |
| 516 | out.push_str("─────────────────────────────\n"); |
| 517 | for entry in &entries { |
| 518 | let _ = writeln!( |
| 519 | out, |
| 520 | " {} — {} (source: {})", |
| 521 | entry.name, |
| 522 | entry.description.clone().unwrap_or_default(), |
| 523 | entry.source |
| 524 | ); |
| 525 | } |
| 526 | let _ = write!(out, "\nInstall with: /skill install <name>"); |
| 527 | CommandResult::message(out) |
| 528 | } |
| 529 | Ok(RemoteRegistryOutcome::NeedsApproval(host)) => { |
| 530 | CommandResult::error(needs_approval_message(&host)) |
| 531 | } |
| 532 | Ok(RemoteRegistryOutcome::Denied(host)) => { |
| 533 | CommandResult::error(network_denied_message(&host)) |
| 534 | } |
| 535 | Err(err) => CommandResult::error(err), |
| 536 | } |
| 537 | } |
| 538 | |
| 539 | /// `/skills suggest <task>` — ranked remote recommendations. |
| 540 | fn suggest_remote_skills(group: &mut dyn CommandSkillGroupContext, task: &str) -> CommandResult { |
| 541 | let task = task.trim(); |
| 542 | if task.chars().count() < 3 { |
| 543 | return CommandResult::error("Usage: /skills suggest <task of at least 3 characters>"); |
| 544 | } |
| 545 | |
| 546 | match group.recommend_skills(task) { |
| 547 | Ok(recommendations) => { |
| 548 | if recommendations.is_empty() { |
| 549 | return CommandResult::message(format!( |
| 550 | "No curated remote skills matched `{task}`.\n\nBrowse the catalog with /skills --remote. Nothing was installed, trusted, or enabled." |
| 551 | )); |
| 552 | } |
| 553 | |
| 554 | let mut out = format!("Suggested remote skills for `{task}`:\n"); |
| 555 | out.push_str("─────────────────────────────\n"); |
| 556 | for recommendation in &recommendations { |
| 557 | let description = recommendation |
| 558 | .description |
| 559 | .as_deref() |
| 560 | .filter(|description| !description.trim().is_empty()) |
| 561 | .unwrap_or("No description provided."); |
| 562 | let _ = writeln!(out, " {} — {description}", recommendation.name); |
| 563 | let _ = writeln!(out, " Why: {}", recommendation.matched_terms.join(", ")); |
| 564 | let _ = writeln!( |
| 565 | out, |
| 566 | " Install if you want it: /skill install {}", |
| 567 | recommendation.name |
| 568 | ); |
| 569 | } |
| 570 | out.push_str("\nNothing was installed, trusted, or enabled."); |
| 571 | CommandResult::message(out) |
| 572 | } |
| 573 | Err(err) => CommandResult::error(err), |
| 574 | } |
| 575 | } |
| 576 | |
| 577 | /// `/skills sync` — registry sync report. |
| 578 | fn sync_skills(group: &mut dyn CommandSkillGroupContext) -> CommandResult { |
| 579 | match group.sync_registry() { |
| 580 | Ok(SkillSyncOutcome::Done { |
| 581 | total, |
| 582 | downloaded, |
| 583 | fresh, |
| 584 | failed, |
| 585 | entries, |
| 586 | }) => { |
| 587 | let mut out = String::from("Registry sync complete.\n\n"); |
| 588 | |
| 589 | for outcome in &entries { |
| 590 | match outcome { |
| 591 | SkillSyncEntry::Downloaded { name, path } => { |
| 592 | let _ = writeln!(out, " [+] {name} — downloaded to {path}"); |
| 593 | } |
| 594 | SkillSyncEntry::Fresh { name } => { |
| 595 | let _ = writeln!(out, " [=] {name} — already up to date"); |
| 596 | } |
| 597 | SkillSyncEntry::Failed { name, reason } => { |
| 598 | let _ = writeln!(out, " [!] {name} — failed: {reason}"); |
| 599 | } |
| 600 | SkillSyncEntry::Denied { name, host } => { |
| 601 | let _ = writeln!(out, " [!] {name} — network denied ({host})"); |
| 602 | } |
| 603 | SkillSyncEntry::NeedsApproval { name, host } => { |
| 604 | let _ = writeln!( |
| 605 | out, |
| 606 | " [?] {name} — needs approval for {host} (run `/network allow {host}` then retry)" |
| 607 | ); |
| 608 | } |
| 609 | } |
| 610 | } |
| 611 | |
| 612 | let _ = write!( |
| 613 | out, |
| 614 | "\n{total} skill(s) processed: {downloaded} downloaded, {fresh} up-to-date, {failed} failed." |
| 615 | ); |
| 616 | |
| 617 | CommandResult::message(out) |
| 618 | } |
| 619 | Ok(SkillSyncOutcome::RegistryNeedsApproval(host)) => { |
| 620 | CommandResult::error(needs_approval_message(&host)) |
| 621 | } |
| 622 | Ok(SkillSyncOutcome::RegistryDenied(host)) => { |
| 623 | CommandResult::error(network_denied_message(&host)) |
| 624 | } |
| 625 | Err(err) => CommandResult::error(err), |
| 626 | } |
| 627 | } |
| 628 | |
| 629 | // --------------------------------------------------------------------------- |
| 630 | // /skill — portable contextual dispatch |
| 631 | // --------------------------------------------------------------------------- |
| 632 | |
| 633 | pub(in crate::commands) const SKILL_INFO: CommandInfo = CommandInfo { |
| 634 | name: "skill", |
| 635 | aliases: &["jineng"], |
| 636 | usage: "/skill <name|install <spec>|update <name>|uninstall <name>|trust <name>>", |
| 637 | description_key: "cmd_skill_description", |
| 638 | }; |
| 639 | |
| 640 | pub(in crate::commands) struct SkillCmd; |
| 641 | |
| 642 | impl RegisterCommand<CommandResult> for SkillCmd { |
| 643 | fn info() -> &'static CommandInfo { |
| 644 | &SKILL_INFO |
| 645 | } |
| 646 | |
| 647 | fn handler() -> CommandHandler<CommandResult> { |
| 648 | CommandHandler::Contextual { |
| 649 | capabilities: codewhale_command_contract::handler::CommandCapabilities::SKILL_GROUP |
| 650 | .union(codewhale_command_contract::handler::CommandCapabilities::SKILLS), |
| 651 | handler: skill_contextual, |
| 652 | } |
| 653 | } |
| 654 | } |
| 655 | |
| 656 | /// Contextual `/skill` dispatch (FEAT-022 D4): exactly the skill-group facet |
| 657 | /// plus the shared SKILLS facet (active-skill reads + cache refresh; D2). |
| 658 | fn skill_contextual(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult { |
| 659 | let mut parts = contexts.into_parts(); |
| 660 | let Some(skill_group) = parts.skill_group.as_deref_mut() else { |
| 661 | return CommandResult::error("Command capability unavailable: skill_group"); |
| 662 | }; |
| 663 | let Some(skills) = parts.skills.as_deref_mut() else { |
| 664 | return CommandResult::error("Command capability unavailable: skills"); |
| 665 | }; |
| 666 | run_skill(skill_group, skills, arg) |
| 667 | } |
| 668 | |
| 669 | /// Portable `/skill` dispatch — byte-identical to the baseline handler. |
| 670 | fn run_skill( |
| 671 | group: &mut dyn CommandSkillGroupContext, |
| 672 | skills: &mut dyn CommandSkillsContext, |
| 673 | arg: Option<&str>, |
| 674 | ) -> CommandResult { |
| 675 | let raw = match arg { |
| 676 | Some(n) => n.trim(), |
| 677 | None => { |
| 678 | return CommandResult::error( |
| 679 | "Usage: /skill <name>\n\nSubcommands:\n /skill install [--project|--global] <github:owner/repo|https://…|<registry-name>>\n /skill update [--project|--global] <name>\n /skill uninstall [--project|--global] <name>\n /skill trust [--project|--global] <name>", |
| 680 | ); |
| 681 | } |
| 682 | }; |
| 683 | |
| 684 | // Sub-command dispatch happens before the activation path so users can't |
| 685 | // accidentally activate a skill literally named "install". |
| 686 | let mut iter = raw.splitn(2, char::is_whitespace); |
| 687 | let head = iter.next().unwrap_or("").trim(); |
| 688 | let rest = iter.next().unwrap_or("").trim(); |
| 689 | match head { |
| 690 | "install" => return install_skill(group, skills, rest), |
| 691 | "update" => return update_skill(group, skills, rest), |
| 692 | "uninstall" => return uninstall_skill(group, skills, rest), |
| 693 | "trust" => return trust_skill(group, rest), |
| 694 | _ => {} |
| 695 | } |
| 696 | |
| 697 | let task = (!rest.is_empty()).then_some(rest); |
| 698 | activate_skill_portable(group, head, task) |
| 699 | } |
| 700 | |
| 701 | /// Portable activation — the host performs lookup, authority verification, and |
| 702 | /// side effects; the handler composes the byte-identical messages/actions. |
| 703 | fn activate_skill_portable( |
| 704 | group: &mut dyn CommandSkillGroupContext, |
| 705 | name: &str, |
| 706 | task: Option<&str>, |
| 707 | ) -> CommandResult { |
| 708 | // `/skill new` is a friendly alias for `/skill skill-creator`; the alias is |
| 709 | // resolved here (parsing stays portable) so the not-found message uses the |
| 710 | // mapped name exactly like the baseline. |
| 711 | let name = if name == "new" { "skill-creator" } else { name }; |
| 712 | |
| 713 | match group.activate_skill(name) { |
| 714 | Ok(outcome) => { |
| 715 | let mut result = CommandResult::message(format!( |
| 716 | "Skill '{}' activated.\n\nDescription: {}\n\nType your request and the skill instructions will be applied.", |
| 717 | outcome.name, outcome.description |
| 718 | )); |
| 719 | if let Some(task) = task.map(str::trim).filter(|task| !task.is_empty()) { |
| 720 | result.action = Some(AppAction::SendMessage(task.to_string())); |
| 721 | } |
| 722 | result |
| 723 | } |
| 724 | Err(SkillActivationError::NotFound { |
| 725 | requested, |
| 726 | available, |
| 727 | warnings, |
| 728 | }) => { |
| 729 | let warnings = render_skill_warnings(&warnings); |
| 730 | if available.is_empty() { |
| 731 | CommandResult::error(format!( |
| 732 | "Skill '{requested}' not found. No skills installed.\n\nUse /skills to see how to add skills.{warnings}" |
| 733 | )) |
| 734 | } else { |
| 735 | CommandResult::error(format!( |
| 736 | "Skill '{}' not found.\n\nAvailable skills: {}{}", |
| 737 | requested, |
| 738 | available.join(", "), |
| 739 | warnings |
| 740 | )) |
| 741 | } |
| 742 | } |
| 743 | Err(SkillActivationError::PluginRejected { name, reason }) => CommandResult::error( |
| 744 | format!("Plugin skill '{}' is no longer active: {reason}", name), |
| 745 | ), |
| 746 | } |
| 747 | } |
| 748 | |
| 749 | // ─── /skill install ──────────────────────────────────────────────────────── |
| 750 | |
| 751 | fn install_skill( |
| 752 | group: &mut dyn CommandSkillGroupContext, |
| 753 | skills: &mut dyn CommandSkillsContext, |
| 754 | args: &str, |
| 755 | ) -> CommandResult { |
| 756 | let (scope, spec) = match parse_scope_args(args) { |
| 757 | Ok(v) => v, |
| 758 | Err(err) => return CommandResult::error(err), |
| 759 | }; |
| 760 | if spec.is_empty() { |
| 761 | return CommandResult::error( |
| 762 | "Usage: /skill install [--project|--global] <github:owner/repo|https://…|<registry-name>>", |
| 763 | ); |
| 764 | } |
| 765 | match group.install_skill(scope, spec) { |
| 766 | Ok(receipt) => { |
| 767 | // Cache refresh is a D2 shared-SKILLS operation: the host returns |
| 768 | // the receipt; the portable handler owns the refresh policy. |
| 769 | if matches!(receipt.outcome, SkillMutationOutcome::Installed) { |
| 770 | skills.refresh_skill_cache(); |
| 771 | } |
| 772 | let message = format_mutation_receipt(&receipt); |
| 773 | if matches!( |
| 774 | receipt.outcome, |
| 775 | SkillMutationOutcome::NeedsApproval(_) | SkillMutationOutcome::NetworkDenied(_) |
| 776 | ) { |
| 777 | CommandResult::error(message) |
| 778 | } else { |
| 779 | CommandResult::message(message) |
| 780 | } |
| 781 | } |
| 782 | Err(err) => CommandResult::error(err), |
| 783 | } |
| 784 | } |
| 785 | |
| 786 | // ─── /skill update ───────────────────────────────────────────────────────── |
| 787 | |
| 788 | fn update_skill( |
| 789 | group: &mut dyn CommandSkillGroupContext, |
| 790 | skills: &mut dyn CommandSkillsContext, |
| 791 | args: &str, |
| 792 | ) -> CommandResult { |
| 793 | let (scope, name) = match parse_scope_args(args) { |
| 794 | Ok(v) => v, |
| 795 | Err(err) => return CommandResult::error(err), |
| 796 | }; |
| 797 | if name.is_empty() { |
| 798 | return CommandResult::error("Usage: /skill update [--project|--global] <name>"); |
| 799 | } |
| 800 | match group.update_skill(scope, name) { |
| 801 | Ok(receipt) => { |
| 802 | if matches!(receipt.outcome, SkillMutationOutcome::Updated) { |
| 803 | skills.refresh_skill_cache(); |
| 804 | } |
| 805 | let message = format_mutation_receipt(&receipt); |
| 806 | if matches!( |
| 807 | receipt.outcome, |
| 808 | SkillMutationOutcome::NeedsApproval(_) | SkillMutationOutcome::NetworkDenied(_) |
| 809 | ) { |
| 810 | CommandResult::error(message) |
| 811 | } else { |
| 812 | CommandResult::message(message) |
| 813 | } |
| 814 | } |
| 815 | Err(err) => CommandResult::error(err), |
| 816 | } |
| 817 | } |
| 818 | |
| 819 | // ─── /skill uninstall ────────────────────────────────────────────────────── |
| 820 | |
| 821 | fn uninstall_skill( |
| 822 | group: &mut dyn CommandSkillGroupContext, |
| 823 | skills: &mut dyn CommandSkillsContext, |
| 824 | args: &str, |
| 825 | ) -> CommandResult { |
| 826 | let (scope, name) = match parse_scope_args(args) { |
| 827 | Ok(v) => v, |
| 828 | Err(err) => return CommandResult::error(err), |
| 829 | }; |
| 830 | if name.is_empty() { |
| 831 | return CommandResult::error("Usage: /skill uninstall [--project|--global] <name>"); |
| 832 | } |
| 833 | match group.uninstall_skill(scope, name) { |
| 834 | Ok(receipt) => { |
| 835 | skills.refresh_skill_cache(); |
| 836 | CommandResult::message(format_mutation_receipt(&receipt)) |
| 837 | } |
| 838 | Err(err) => CommandResult::error(err), |
| 839 | } |
| 840 | } |
| 841 | |
| 842 | // ─── /skill trust ────────────────────────────────────────────────────────── |
| 843 | |
| 844 | fn trust_skill(group: &mut dyn CommandSkillGroupContext, args: &str) -> CommandResult { |
| 845 | let (scope, name) = match parse_scope_args(args) { |
| 846 | Ok(v) => v, |
| 847 | Err(err) => return CommandResult::error(err), |
| 848 | }; |
| 849 | if name.is_empty() { |
| 850 | return CommandResult::error("Usage: /skill trust [--project|--global] <name>"); |
| 851 | } |
| 852 | match group.trust_skill(scope, name) { |
| 853 | Ok(receipt) => CommandResult::message(format_mutation_receipt(&receipt)), |
| 854 | Err(err) => CommandResult::error(err), |
| 855 | } |
| 856 | } |
| 857 | |
| 858 | #[cfg(test)] |
| 859 | mod tests { |
| 860 | use super::*; |
| 861 | use codewhale_command_contract::facets::{ |
| 862 | CommandApprovalState, RemoteRegistryOutcome, RemoteSkillEntry, ReviewOutcome, |
| 863 | SkillActivationError, SkillActivationOutcome, SkillRecommendation, SkillRegistryProjection, |
| 864 | SkillSourceKind, SnapshotEntry, |
| 865 | }; |
| 866 | |
| 867 | /// Shared SKILLS fake: read-only getters + cache refresh (D2 surface). |
| 868 | struct FakeSkills { |
| 869 | refreshed: bool, |
| 870 | } |
| 871 | impl CommandSkillsContext for FakeSkills { |
| 872 | fn active_skill(&self) -> Option<String> { |
| 873 | None |
| 874 | } |
| 875 | fn active_skill_provenance(&self) -> Option<String> { |
| 876 | None |
| 877 | } |
| 878 | fn refresh_skill_cache(&mut self) { |
| 879 | self.refreshed = true; |
| 880 | } |
| 881 | } |
| 882 | |
| 883 | /// Counting fake for preserving the baseline's exact cache-refresh policy. |
| 884 | #[derive(Default)] |
| 885 | struct CountingSkills { |
| 886 | refresh_count: usize, |
| 887 | } |
| 888 | impl CommandSkillsContext for CountingSkills { |
| 889 | fn active_skill(&self) -> Option<String> { |
| 890 | None |
| 891 | } |
| 892 | fn active_skill_provenance(&self) -> Option<String> { |
| 893 | None |
| 894 | } |
| 895 | fn refresh_skill_cache(&mut self) { |
| 896 | self.refresh_count += 1; |
| 897 | } |
| 898 | } |
| 899 | |
| 900 | /// Deterministic fake skill-group facet over portable values only. |
| 901 | struct FakeSkillGroup { |
| 902 | projection: SkillRegistryProjection, |
| 903 | activation: Result<SkillActivationOutcome, SkillActivationError>, |
| 904 | install: Result<SkillMutationReceipt, String>, |
| 905 | update: Result<SkillMutationReceipt, String>, |
| 906 | uninstall: Result<SkillMutationReceipt, String>, |
| 907 | trust: Result<SkillMutationReceipt, String>, |
| 908 | remote: Result<RemoteRegistryOutcome, String>, |
| 909 | recommend: Result<Vec<SkillRecommendation>, String>, |
| 910 | sync: Result<SkillSyncOutcome, String>, |
| 911 | review: Result<ReviewOutcome, String>, |
| 912 | snapshots: Result<Vec<SnapshotEntry>, String>, |
| 913 | restore: Result<(), String>, |
| 914 | approval: CommandApprovalState, |
| 915 | } |
| 916 | |
| 917 | impl FakeSkillGroup { |
| 918 | fn new(entries: Vec<SkillEntry>) -> Self { |
| 919 | let total = entries.len(); |
| 920 | Self { |
| 921 | projection: SkillRegistryProjection { |
| 922 | workspace: "/ws".to_string(), |
| 923 | skills_dir: "/ws/.codewhale/skills".to_string(), |
| 924 | mode_label: "compatible".to_string(), |
| 925 | dirs: vec!["/ws/.codewhale/skills".to_string()], |
| 926 | entries, |
| 927 | warnings: vec![], |
| 928 | total, |
| 929 | }, |
| 930 | activation: Ok(SkillActivationOutcome { |
| 931 | name: "demo".to_string(), |
| 932 | description: "Demo skill".to_string(), |
| 933 | }), |
| 934 | install: Ok(SkillMutationReceipt { |
| 935 | name: "demo".to_string(), |
| 936 | safe_target_path: "/ws/.codewhale/skills/demo".to_string(), |
| 937 | outcome: SkillMutationOutcome::Installed, |
| 938 | }), |
| 939 | update: Ok(SkillMutationReceipt { |
| 940 | name: "demo".to_string(), |
| 941 | safe_target_path: "/ws/.codewhale/skills/demo".to_string(), |
| 942 | outcome: SkillMutationOutcome::Updated, |
| 943 | }), |
| 944 | uninstall: Ok(SkillMutationReceipt { |
| 945 | name: "demo".to_string(), |
| 946 | safe_target_path: "/ws/.codewhale/skills/demo".to_string(), |
| 947 | outcome: SkillMutationOutcome::Removed, |
| 948 | }), |
| 949 | trust: Ok(SkillMutationReceipt { |
| 950 | name: "demo".to_string(), |
| 951 | safe_target_path: "/ws/.codewhale/skills/demo".to_string(), |
| 952 | outcome: SkillMutationOutcome::Trusted, |
| 953 | }), |
| 954 | remote: Ok(RemoteRegistryOutcome::Loaded { |
| 955 | entries: vec![RemoteSkillEntry { |
| 956 | name: "remote-demo".to_string(), |
| 957 | description: Some("Remote demo".to_string()), |
| 958 | source: "github.com/acme/skills".to_string(), |
| 959 | }], |
| 960 | }), |
| 961 | recommend: Ok(vec![SkillRecommendation { |
| 962 | name: "remote-demo".to_string(), |
| 963 | description: Some("Remote demo".to_string()), |
| 964 | matched_terms: vec!["demo".to_string()], |
| 965 | }]), |
| 966 | sync: Ok(SkillSyncOutcome::Done { |
| 967 | total: 1, |
| 968 | downloaded: 1, |
| 969 | fresh: 0, |
| 970 | failed: 0, |
| 971 | entries: vec![SkillSyncEntry::Downloaded { |
| 972 | name: "demo".to_string(), |
| 973 | path: "/cache/demo".to_string(), |
| 974 | }], |
| 975 | }), |
| 976 | review: Ok(ReviewOutcome::Ready), |
| 977 | snapshots: Ok(vec![SnapshotEntry { |
| 978 | id: "abcdef123456".to_string(), |
| 979 | label: "pre-turn:1".to_string(), |
| 980 | timestamp: 1_700_000_000, |
| 981 | }]), |
| 982 | restore: Ok(()), |
| 983 | approval: CommandApprovalState { |
| 984 | yolo: true, |
| 985 | trust_mode: false, |
| 986 | }, |
| 987 | } |
| 988 | } |
| 989 | } |
| 990 | |
| 991 | impl CommandSkillGroupContext for FakeSkillGroup { |
| 992 | fn skill_registry_projection(&self) -> SkillRegistryProjection { |
| 993 | self.projection.clone() |
| 994 | } |
| 995 | fn activate_skill( |
| 996 | &mut self, |
| 997 | _name: &str, |
| 998 | ) -> Result<SkillActivationOutcome, SkillActivationError> { |
| 999 | self.activation.clone() |
| 1000 | } |
| 1001 | fn install_skill( |
| 1002 | &mut self, |
| 1003 | _scope: Option<SkillTargetScope>, |
| 1004 | _spec: &str, |
| 1005 | ) -> Result<SkillMutationReceipt, String> { |
| 1006 | self.install.clone() |
| 1007 | } |
| 1008 | fn update_skill( |
| 1009 | &mut self, |
| 1010 | _scope: Option<SkillTargetScope>, |
| 1011 | _name: &str, |
| 1012 | ) -> Result<SkillMutationReceipt, String> { |
| 1013 | self.update.clone() |
| 1014 | } |
| 1015 | fn uninstall_skill( |
| 1016 | &mut self, |
| 1017 | _scope: Option<SkillTargetScope>, |
| 1018 | _name: &str, |
| 1019 | ) -> Result<SkillMutationReceipt, String> { |
| 1020 | self.uninstall.clone() |
| 1021 | } |
| 1022 | fn trust_skill( |
| 1023 | &mut self, |
| 1024 | _scope: Option<SkillTargetScope>, |
| 1025 | _name: &str, |
| 1026 | ) -> Result<SkillMutationReceipt, String> { |
| 1027 | self.trust.clone() |
| 1028 | } |
| 1029 | fn fetch_remote_registry(&mut self) -> Result<RemoteRegistryOutcome, String> { |
| 1030 | self.remote.clone() |
| 1031 | } |
| 1032 | fn recommend_skills(&mut self, _task: &str) -> Result<Vec<SkillRecommendation>, String> { |
| 1033 | self.recommend.clone() |
| 1034 | } |
| 1035 | fn sync_registry(&mut self) -> Result<SkillSyncOutcome, String> { |
| 1036 | self.sync.clone() |
| 1037 | } |
| 1038 | fn run_review(&mut self) -> Result<ReviewOutcome, String> { |
| 1039 | self.review.clone() |
| 1040 | } |
| 1041 | fn snapshot_list(&mut self, _limit: usize) -> Result<Vec<SnapshotEntry>, String> { |
| 1042 | self.snapshots.clone() |
| 1043 | } |
| 1044 | fn restore_snapshot(&mut self, _id: &str) -> Result<(), String> { |
| 1045 | self.restore.clone() |
| 1046 | } |
| 1047 | fn approval_state(&self) -> CommandApprovalState { |
| 1048 | self.approval |
| 1049 | } |
| 1050 | } |
| 1051 | |
| 1052 | fn demo_entry() -> SkillEntry { |
| 1053 | SkillEntry { |
| 1054 | name: "demo".to_string(), |
| 1055 | description: "Demo skill".to_string(), |
| 1056 | source: SkillSourceKind::Native, |
| 1057 | path: Some("/ws/.codewhale/skills/demo".to_string()), |
| 1058 | bundled_tier: None, |
| 1059 | } |
| 1060 | } |
| 1061 | |
| 1062 | fn bundled_entry(name: &str, tier: SkillBundledTier) -> SkillEntry { |
| 1063 | SkillEntry { |
| 1064 | name: name.to_string(), |
| 1065 | description: format!("{name} skill"), |
| 1066 | source: SkillSourceKind::Native, |
| 1067 | path: None, |
| 1068 | bundled_tier: Some(tier), |
| 1069 | } |
| 1070 | } |
| 1071 | |
| 1072 | // ── /skills parity ──────────────────────────────────────────────────── |
| 1073 | |
| 1074 | #[test] |
| 1075 | fn bare_skills_opens_extensions_and_manage_keeps_mutation_manager() { |
| 1076 | let mut group = FakeSkillGroup::new(vec![demo_entry()]); |
| 1077 | let result = list_skills(&mut group, None); |
| 1078 | assert!(result.message.is_none()); |
| 1079 | assert!(matches!( |
| 1080 | result.action, |
| 1081 | Some(AppAction::OpenExtensions { |
| 1082 | tab: crate::tui::views::extensions::ExtensionsTab::Skills |
| 1083 | }) |
| 1084 | )); |
| 1085 | assert!(matches!( |
| 1086 | list_skills(&mut group, Some("manage")).action, |
| 1087 | Some(AppAction::OpenSkillsManager) |
| 1088 | )); |
| 1089 | } |
| 1090 | |
| 1091 | #[test] |
| 1092 | fn skills_empty_registry_message_is_exact() { |
| 1093 | let mut group = FakeSkillGroup::new(vec![]); |
| 1094 | let result = list_skills(&mut group, Some("")); |
| 1095 | let msg = result.message.expect("expected message"); |
| 1096 | assert!( |
| 1097 | msg.starts_with("No skills found.\n\nSkills location: /ws/.codewhale/skills\n"), |
| 1098 | "{msg}" |
| 1099 | ); |
| 1100 | assert!(msg.contains("/ws/.codewhale/skills/my-skill/SKILL.md")); |
| 1101 | } |
| 1102 | |
| 1103 | #[test] |
| 1104 | fn skills_prefix_listing_flat_format_is_exact() { |
| 1105 | let mut group = FakeSkillGroup::new(vec![demo_entry()]); |
| 1106 | let result = list_skills(&mut group, Some("de")); |
| 1107 | let msg = result.message.expect("expected message"); |
| 1108 | assert!( |
| 1109 | msg.starts_with("Available skills matching `de` (1 of 1):\n"), |
| 1110 | "{msg}" |
| 1111 | ); |
| 1112 | assert!(msg.contains(" /demo - Demo skill")); |
| 1113 | } |
| 1114 | |
| 1115 | #[test] |
| 1116 | fn skills_no_match_reports_prefix_and_total() { |
| 1117 | let mut group = FakeSkillGroup::new(vec![demo_entry()]); |
| 1118 | let result = list_skills(&mut group, Some("zzz")); |
| 1119 | let msg = result.message.expect("expected message"); |
| 1120 | assert!( |
| 1121 | msg.starts_with("No skills match prefix `zzz` (out of 1 available)."), |
| 1122 | "{msg}" |
| 1123 | ); |
| 1124 | } |
| 1125 | |
| 1126 | #[test] |
| 1127 | fn skills_unfiltered_splits_user_and_bundled_tiers() { |
| 1128 | let mut group = FakeSkillGroup::new(vec![ |
| 1129 | demo_entry(), |
| 1130 | bundled_entry("skill-creator", SkillBundledTier::FormatTooling), |
| 1131 | bundled_entry("help", SkillBundledTier::CoreAgentic), |
| 1132 | ]); |
| 1133 | let result = list_skills(&mut group, Some("")); |
| 1134 | let msg = result.message.expect("expected message"); |
| 1135 | assert!(msg.contains("Your skills (1):"), "{msg}"); |
| 1136 | assert!(msg.contains("Core agentic (1):"), "{msg}"); |
| 1137 | assert!(msg.contains(" /help"), "{msg}"); |
| 1138 | assert!(msg.contains("Format & tooling (1):"), "{msg}"); |
| 1139 | assert!(msg.contains(" /skill-creator"), "{msg}"); |
| 1140 | assert!( |
| 1141 | msg.contains("(run /skills <name> for details on a built-in)"), |
| 1142 | "{msg}" |
| 1143 | ); |
| 1144 | } |
| 1145 | |
| 1146 | #[test] |
| 1147 | fn skills_rejects_flag_like_and_multiword_prefixes() { |
| 1148 | let mut group = FakeSkillGroup::new(vec![demo_entry()]); |
| 1149 | let result = list_skills(&mut group, Some("-x")); |
| 1150 | assert!(result.is_error); |
| 1151 | assert!( |
| 1152 | result |
| 1153 | .message |
| 1154 | .unwrap() |
| 1155 | .contains("Usage: /skills [--remote|sync|inspect|suggest <task>|<name-prefix>]") |
| 1156 | ); |
| 1157 | let result = list_skills(&mut group, Some("two words")); |
| 1158 | assert!(result.is_error); |
| 1159 | } |
| 1160 | |
| 1161 | #[test] |
| 1162 | fn skills_suggest_requires_meaningful_task() { |
| 1163 | let mut group = FakeSkillGroup::new(vec![demo_entry()]); |
| 1164 | let result = list_skills(&mut group, Some("suggest")); |
| 1165 | assert!(result.is_error); |
| 1166 | assert!( |
| 1167 | result |
| 1168 | .message |
| 1169 | .unwrap() |
| 1170 | .contains("Usage: /skills suggest <task>") |
| 1171 | ); |
| 1172 | let result = list_skills(&mut group, Some("suggest ab")); |
| 1173 | assert!(result.is_error); |
| 1174 | assert!(result.message.unwrap().contains("at least 3 characters")); |
| 1175 | } |
| 1176 | |
| 1177 | #[test] |
| 1178 | fn skills_inspect_reports_discovery_details() { |
| 1179 | let mut group = FakeSkillGroup::new(vec![demo_entry()]); |
| 1180 | let result = list_skills(&mut group, Some("inspect")); |
| 1181 | let msg = result.message.expect("expected message"); |
| 1182 | assert!(msg.starts_with("Skills Inspect\n"), "{msg}"); |
| 1183 | assert!(msg.contains("Discovery mode: compatible")); |
| 1184 | assert!(msg.contains("Workspace: /ws")); |
| 1185 | assert!(msg.contains("Configured skills dir: /ws/.codewhale/skills")); |
| 1186 | assert!(msg.contains("Searched directories (1):")); |
| 1187 | assert!(msg.contains("Available skills (1):")); |
| 1188 | assert!(msg.contains("source: native")); |
| 1189 | assert!(msg.contains("path: /ws/.codewhale/skills/demo")); |
| 1190 | } |
| 1191 | |
| 1192 | #[test] |
| 1193 | fn skills_remote_lists_entries_and_policy_errors() { |
| 1194 | let mut group = FakeSkillGroup::new(vec![demo_entry()]); |
| 1195 | let result = list_skills(&mut group, Some("--remote")); |
| 1196 | let msg = result.message.expect("expected message"); |
| 1197 | assert!(msg.contains("Available remote skills (1):"), "{msg}"); |
| 1198 | assert!(msg.contains("remote-demo — Remote demo (source: github.com/acme/skills)")); |
| 1199 | assert!(msg.contains("\nInstall with: /skill install <name>")); |
| 1200 | |
| 1201 | group.remote = Ok(RemoteRegistryOutcome::NeedsApproval("acme.com".to_string())); |
| 1202 | let result = list_skills(&mut group, Some("remote")); |
| 1203 | assert!(result.is_error); |
| 1204 | assert!( |
| 1205 | result |
| 1206 | .message |
| 1207 | .unwrap() |
| 1208 | .contains("Network policy requires approval for acme.com") |
| 1209 | ); |
| 1210 | |
| 1211 | group.remote = Ok(RemoteRegistryOutcome::Denied("acme.com".to_string())); |
| 1212 | let result = list_skills(&mut group, Some("remote")); |
| 1213 | assert!(result.is_error); |
| 1214 | assert!( |
| 1215 | result |
| 1216 | .message |
| 1217 | .unwrap() |
| 1218 | .contains("Network policy denied access to acme.com") |
| 1219 | ); |
| 1220 | |
| 1221 | group.remote = Err("Failed to fetch registry: boom".to_string()); |
| 1222 | let result = list_skills(&mut group, Some("--remote")); |
| 1223 | assert!(result.is_error); |
| 1224 | assert_eq!( |
| 1225 | result.message.unwrap(), |
| 1226 | "Error: Failed to fetch registry: boom" |
| 1227 | ); |
| 1228 | } |
| 1229 | |
| 1230 | #[test] |
| 1231 | fn skills_suggest_renders_recommendations() { |
| 1232 | let mut group = FakeSkillGroup::new(vec![demo_entry()]); |
| 1233 | let result = list_skills(&mut group, Some("suggest demo")); |
| 1234 | let msg = result.message.expect("expected message"); |
| 1235 | assert!(msg.contains("Suggested remote skills for `demo`:"), "{msg}"); |
| 1236 | assert!(msg.contains(" remote-demo — Remote demo")); |
| 1237 | assert!(msg.contains(" Why: demo")); |
| 1238 | assert!(msg.contains(" Install if you want it: /skill install remote-demo")); |
| 1239 | assert!(msg.contains("\nNothing was installed, trusted, or enabled.")); |
| 1240 | } |
| 1241 | |
| 1242 | #[test] |
| 1243 | fn skills_sync_renders_per_skill_report() { |
| 1244 | let mut group = FakeSkillGroup::new(vec![demo_entry()]); |
| 1245 | let result = list_skills(&mut group, Some("sync")); |
| 1246 | let msg = result.message.expect("expected message"); |
| 1247 | assert!(msg.starts_with("Registry sync complete.\n"), "{msg}"); |
| 1248 | assert!(msg.contains(" [+] demo — downloaded to /cache/demo")); |
| 1249 | assert!(msg.contains("\n1 skill(s) processed: 1 downloaded, 0 up-to-date, 0 failed.")); |
| 1250 | |
| 1251 | group.sync = Ok(SkillSyncOutcome::RegistryNeedsApproval( |
| 1252 | "acme.com".to_string(), |
| 1253 | )); |
| 1254 | let result = list_skills(&mut group, Some("sync")); |
| 1255 | assert!(result.is_error); |
| 1256 | assert!( |
| 1257 | result |
| 1258 | .message |
| 1259 | .unwrap() |
| 1260 | .contains("requires approval for acme.com") |
| 1261 | ); |
| 1262 | } |
| 1263 | |
| 1264 | // ── /skill parity ───────────────────────────────────────────────────── |
| 1265 | |
| 1266 | #[test] |
| 1267 | fn skill_without_arg_prints_usage() { |
| 1268 | let mut group = FakeSkillGroup::new(vec![demo_entry()]); |
| 1269 | let mut skills = FakeSkills { refreshed: false }; |
| 1270 | let result = run_skill(&mut group, &mut skills, None); |
| 1271 | assert!(result.is_error); |
| 1272 | assert!(result.message.unwrap().contains("Usage: /skill <name>")); |
| 1273 | } |
| 1274 | |
| 1275 | #[test] |
| 1276 | fn skill_activation_success_composes_message_and_task_action() { |
| 1277 | let mut group = FakeSkillGroup::new(vec![demo_entry()]); |
| 1278 | let mut skills = FakeSkills { refreshed: false }; |
| 1279 | let result = run_skill(&mut group, &mut skills, Some("demo")); |
| 1280 | assert!(!result.is_error); |
| 1281 | let msg = result.message.expect("expected message"); |
| 1282 | assert!( |
| 1283 | msg.starts_with("Skill 'demo' activated.\n\nDescription: Demo skill"), |
| 1284 | "{msg}" |
| 1285 | ); |
| 1286 | assert!(result.action.is_none()); |
| 1287 | |
| 1288 | let result = run_skill(&mut group, &mut skills, Some("demo do the thing")); |
| 1289 | assert!( |
| 1290 | matches!(result.action, Some(AppAction::SendMessage(ref t)) if t == "do the thing") |
| 1291 | ); |
| 1292 | } |
| 1293 | |
| 1294 | #[test] |
| 1295 | fn skill_new_aliases_skill_creator_in_not_found_message() { |
| 1296 | let mut group = FakeSkillGroup::new(vec![demo_entry()]); |
| 1297 | group.activation = Err(SkillActivationError::NotFound { |
| 1298 | requested: "skill-creator".to_string(), |
| 1299 | available: vec!["demo".to_string()], |
| 1300 | warnings: vec![], |
| 1301 | }); |
| 1302 | let mut skills = FakeSkills { refreshed: false }; |
| 1303 | let result = run_skill(&mut group, &mut skills, Some("new")); |
| 1304 | assert!(result.is_error); |
| 1305 | assert!( |
| 1306 | result |
| 1307 | .message |
| 1308 | .unwrap() |
| 1309 | .contains("Skill 'skill-creator' not found.") |
| 1310 | ); |
| 1311 | } |
| 1312 | |
| 1313 | #[test] |
| 1314 | fn skill_not_found_lists_available_and_warnings() { |
| 1315 | let mut group = FakeSkillGroup::new(vec![demo_entry()]); |
| 1316 | group.activation = Err(SkillActivationError::NotFound { |
| 1317 | requested: "missing".to_string(), |
| 1318 | available: vec!["demo".to_string()], |
| 1319 | warnings: vec!["one warning".to_string()], |
| 1320 | }); |
| 1321 | let mut skills = FakeSkills { refreshed: false }; |
| 1322 | let result = run_skill(&mut group, &mut skills, Some("missing")); |
| 1323 | assert!(result.is_error); |
| 1324 | let msg = result.message.unwrap(); |
| 1325 | assert!(msg.contains("Skill 'missing' not found."), "{msg}"); |
| 1326 | assert!(msg.contains("Available skills: demo"), "{msg}"); |
| 1327 | assert!(msg.contains("Warnings (1):"), "{msg}"); |
| 1328 | assert!(msg.contains(" - one warning"), "{msg}"); |
| 1329 | } |
| 1330 | |
| 1331 | #[test] |
| 1332 | fn skill_not_found_with_no_skills_uses_install_hint() { |
| 1333 | let mut group = FakeSkillGroup::new(vec![]); |
| 1334 | group.activation = Err(SkillActivationError::NotFound { |
| 1335 | requested: "missing".to_string(), |
| 1336 | available: vec![], |
| 1337 | warnings: vec![], |
| 1338 | }); |
| 1339 | let mut skills = FakeSkills { refreshed: false }; |
| 1340 | let result = run_skill(&mut group, &mut skills, Some("missing")); |
| 1341 | assert!(result.is_error); |
| 1342 | assert!( |
| 1343 | result |
| 1344 | .message |
| 1345 | .unwrap() |
| 1346 | .contains("No skills installed.\n\nUse /skills to see how to add skills.") |
| 1347 | ); |
| 1348 | } |
| 1349 | |
| 1350 | #[test] |
| 1351 | fn skill_plugin_rejected_renders_exact_error() { |
| 1352 | let mut group = FakeSkillGroup::new(vec![demo_entry()]); |
| 1353 | group.activation = Err(SkillActivationError::PluginRejected { |
| 1354 | name: "plug".to_string(), |
| 1355 | reason: "authority revoked".to_string(), |
| 1356 | }); |
| 1357 | let mut skills = FakeSkills { refreshed: false }; |
| 1358 | let result = run_skill(&mut group, &mut skills, Some("plug")); |
| 1359 | assert!(result.is_error); |
| 1360 | assert_eq!( |
| 1361 | result.message.unwrap(), |
| 1362 | "Error: Plugin skill 'plug' is no longer active: authority revoked" |
| 1363 | ); |
| 1364 | } |
| 1365 | |
| 1366 | #[test] |
| 1367 | fn skill_install_receipt_refreshes_cache_exactly_once() { |
| 1368 | let mut group = FakeSkillGroup::new(vec![demo_entry()]); |
| 1369 | let mut skills = CountingSkills::default(); |
| 1370 | let result = run_skill(&mut group, &mut skills, Some("install github:acme/demo")); |
| 1371 | assert!(!result.is_error); |
| 1372 | assert!( |
| 1373 | result |
| 1374 | .message |
| 1375 | .unwrap() |
| 1376 | .starts_with("Installed skill 'demo'.\nLocation: /ws/.codewhale/skills/demo"), |
| 1377 | ); |
| 1378 | assert_eq!( |
| 1379 | skills.refresh_count, 1, |
| 1380 | "Installed receipt must refresh the skill cache exactly once" |
| 1381 | ); |
| 1382 | } |
| 1383 | |
| 1384 | #[test] |
| 1385 | fn skill_update_and_uninstall_refresh_cache_exactly_once_each() { |
| 1386 | let mut group = FakeSkillGroup::new(vec![demo_entry()]); |
| 1387 | let mut skills = CountingSkills::default(); |
| 1388 | let result = run_skill(&mut group, &mut skills, Some("update demo")); |
| 1389 | assert!(!result.is_error); |
| 1390 | assert_eq!(skills.refresh_count, 1, "update refresh count"); |
| 1391 | |
| 1392 | skills.refresh_count = 0; |
| 1393 | let result = run_skill(&mut group, &mut skills, Some("uninstall --global demo")); |
| 1394 | assert!(!result.is_error); |
| 1395 | assert_eq!(skills.refresh_count, 1, "uninstall refresh count"); |
| 1396 | assert!(result.message.unwrap().contains("Removed skill 'demo'.")); |
| 1397 | } |
| 1398 | |
| 1399 | #[test] |
| 1400 | fn skill_trust_does_not_refresh_cache() { |
| 1401 | let mut group = FakeSkillGroup::new(vec![demo_entry()]); |
| 1402 | let mut skills = CountingSkills::default(); |
| 1403 | let result = run_skill(&mut group, &mut skills, Some("trust demo")); |
| 1404 | assert!(!result.is_error); |
| 1405 | assert_eq!(skills.refresh_count, 0, "trust must not refresh the cache"); |
| 1406 | assert!( |
| 1407 | result |
| 1408 | .message |
| 1409 | .unwrap() |
| 1410 | .contains("Marked skill 'demo' as trusted.") |
| 1411 | ); |
| 1412 | } |
| 1413 | |
| 1414 | #[test] |
| 1415 | fn skill_install_empty_spec_prints_usage() { |
| 1416 | let mut group = FakeSkillGroup::new(vec![demo_entry()]); |
| 1417 | let mut skills = FakeSkills { refreshed: false }; |
| 1418 | let result = run_skill(&mut group, &mut skills, Some("install")); |
| 1419 | assert!(result.is_error); |
| 1420 | assert!(result.message.unwrap().contains("Usage: /skill install")); |
| 1421 | } |
| 1422 | |
| 1423 | #[test] |
| 1424 | fn skill_scope_conflict_errors() { |
| 1425 | let mut group = FakeSkillGroup::new(vec![demo_entry()]); |
| 1426 | let mut skills = FakeSkills { refreshed: false }; |
| 1427 | let result = run_skill( |
| 1428 | &mut group, |
| 1429 | &mut skills, |
| 1430 | Some("install --project --global x"), |
| 1431 | ); |
| 1432 | assert!(result.is_error); |
| 1433 | assert!( |
| 1434 | result |
| 1435 | .message |
| 1436 | .unwrap() |
| 1437 | .contains("specify at most one of --project / --global") |
| 1438 | ); |
| 1439 | } |
| 1440 | |
| 1441 | #[test] |
| 1442 | fn skill_missing_facet_errors_are_safe() { |
| 1443 | let result = skills_contextual(CommandContexts::empty(), Some("demo")); |
| 1444 | assert!(result.is_error); |
| 1445 | assert_eq!( |
| 1446 | result.message.unwrap(), |
| 1447 | "Error: Command capability unavailable: skill_group" |
| 1448 | ); |
| 1449 | } |
| 1450 | } |
| 1451 |