返回 CodeWhale
skills.rs
根目录 / crates / tui / src / commands / groups / skills / skills.rs
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
1451 lines RUST