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