返回 CodeWhale
skills.rs
根目录 / crates / tui / src / commands / groups / skills / skills.rs
1 //! Skills commands: skills, skill
2
3 use std::fmt::Write;
4
5 use crate::network_policy::NetworkPolicy;
6 use crate::skills::install::{
7 self, DEFAULT_MAX_SIZE_BYTES, DEFAULT_REGISTRY_URL, InstallSource, RegistryFetchResult,
8 SkillSyncOutcome, SyncResult,
9 };
10 use crate::skills::{SkillRegistry, SkillSource};
11 use crate::tui::app::{App, AppAction};
12 use crate::tui::history::HistoryCell;
13
14 use crate::commands::CommandResult;
15
16 #[cfg(test)]
17 thread_local! {
18 static TEST_HOME_DIR: std::cell::RefCell<Option<std::path::PathBuf>> =
19 const { std::cell::RefCell::new(None) };
20 }
21
22 #[cfg(not(test))]
23 fn discover_visible_skills(app: &App) -> SkillRegistry {
24 crate::skills::discover_for_workspace_and_dir_with_mode_and_plugins(
25 &app.workspace,
26 &app.skills_dir,
27 crate::skills::SkillDiscoveryMode::from_codewhale_only(app.skills_scan_codewhale_only),
28 Some(app.plugin_registry.as_ref()),
29 )
30 .into_enabled()
31 }
32
33 #[cfg(test)]
34 fn discover_visible_skills(app: &App) -> SkillRegistry {
35 let mode =
36 crate::skills::SkillDiscoveryMode::from_codewhale_only(app.skills_scan_codewhale_only);
37 TEST_HOME_DIR
38 .with(|home| {
39 if let Some(home) = home.borrow().as_deref() {
40 crate::skills::discover_for_workspace_and_dir_with_home_and_mode_and_plugins(
41 &app.workspace,
42 &app.skills_dir,
43 Some(home),
44 mode,
45 Some(app.plugin_registry.as_ref()),
46 )
47 } else {
48 crate::skills::discover_for_workspace_and_dir_with_mode_and_plugins(
49 &app.workspace,
50 &app.skills_dir,
51 mode,
52 Some(app.plugin_registry.as_ref()),
53 )
54 }
55 })
56 .into_enabled()
57 }
58
59 fn render_skill_warnings(registry: &SkillRegistry) -> String {
60 if registry.warnings().is_empty() {
61 return String::new();
62 }
63
64 let mut out = String::new();
65 let _ = writeln!(out, "\nWarnings ({}):", registry.warnings().len());
66 for warning in registry.warnings() {
67 let _ = writeln!(out, " - {warning}");
68 }
69 out
70 }
71
72 fn skill_discovery_mode(app: &App) -> crate::skills::SkillDiscoveryMode {
73 crate::skills::SkillDiscoveryMode::from_codewhale_only(app.skills_scan_codewhale_only)
74 }
75
76 fn skill_discovery_mode_label(mode: crate::skills::SkillDiscoveryMode) -> &'static str {
77 match mode {
78 crate::skills::SkillDiscoveryMode::Compatible => "compatible",
79 crate::skills::SkillDiscoveryMode::CodeWhaleOnly => "codewhale-only",
80 }
81 }
82
83 fn visible_skill_directories(app: &App) -> Vec<std::path::PathBuf> {
84 crate::skills::skill_directories_for_workspace_and_dir(
85 &app.workspace,
86 &app.skills_dir,
87 skill_discovery_mode(app),
88 )
89 }
90
91 fn skill_source_label(source: &SkillSource) -> String {
92 match source {
93 SkillSource::Native => "native".to_string(),
94 SkillSource::Plugin {
95 plugin_id,
96 plugin_name,
97 ..
98 } => format!("reviewed plugin snapshot {plugin_name} ({plugin_id})"),
99 }
100 }
101
102 fn inspect_skills(app: &mut App) -> CommandResult {
103 let mode = skill_discovery_mode(app);
104 let dirs = visible_skill_directories(app);
105 let registry = discover_visible_skills(app);
106 let warnings = render_skill_warnings(&registry);
107
108 let mut output = String::from("Skills Inspect\n");
109 output.push_str("─────────────────────────────\n");
110 let _ = writeln!(
111 output,
112 "Discovery mode: {}",
113 skill_discovery_mode_label(mode)
114 );
115 let _ = writeln!(output, "Workspace: {}", app.workspace.display());
116 let _ = writeln!(
117 output,
118 "Configured skills dir: {}",
119 app.skills_dir.display()
120 );
121
122 if dirs.is_empty() {
123 output.push_str("\nSearched directories: none found\n");
124 } else {
125 let _ = writeln!(output, "\nSearched directories ({}):", dirs.len());
126 for (idx, dir) in dirs.iter().enumerate() {
127 let _ = writeln!(output, " {}. {}", idx + 1, dir.display());
128 }
129 }
130
131 let _ = writeln!(output, "\nAvailable skills ({}):", registry.len());
132 if registry.is_empty() {
133 output.push_str(" (none)\n");
134 } else {
135 for skill in registry.list() {
136 if skill.description.trim().is_empty() {
137 let _ = writeln!(output, " - {}", skill.name);
138 } else {
139 let _ = writeln!(output, " - {} — {}", skill.name, skill.description);
140 }
141 let _ = writeln!(output, " source: {}", skill_source_label(&skill.source));
142 if matches!(skill.source, SkillSource::Native) {
143 let _ = writeln!(output, " path: {}", skill.path.display());
144 }
145 }
146 }
147
148 output.push_str(&warnings);
149 CommandResult::message(output)
150 }
151
152 /// List all available skills. Pass `--remote` (or `remote`) to fetch the
153 /// curated registry instead of scanning the local skills directory. Pass
154 /// `suggest <task>` to rank remote catalog entries for a task without
155 /// installing anything.
156 /// Pass `sync` to pull the registry index and download all skills to the
157 /// local cache (`~/.codewhale/cache/skills/`). Pass `inspect` to show local
158 /// discovery mode, searched directories, and skill source paths.
159 fn list_skills(app: &mut App, arg: Option<&str>) -> CommandResult {
160 let mut prefix: Option<String> = None;
161 if let Some(arg) = arg {
162 let trimmed = arg.trim();
163 if trimmed == "--remote" || trimmed == "remote" {
164 return list_remote_skills(app);
165 }
166 if trimmed == "sync" || trimmed == "--sync" {
167 return sync_skills(app);
168 }
169 if trimmed == "inspect" || trimmed == "--inspect" {
170 return inspect_skills(app);
171 }
172 if trimmed == "suggest" || trimmed == "recommend" {
173 return CommandResult::error("Usage: /skills suggest <task>");
174 }
175 if let Some(task) = trimmed
176 .strip_prefix("suggest ")
177 .or_else(|| trimmed.strip_prefix("recommend "))
178 {
179 return suggest_remote_skills(app, task);
180 }
181 if !trimmed.is_empty() {
182 // Anything else is treated as a name-prefix filter (#1318).
183 // Reject obviously malformed args (whitespace inside the
184 // prefix, leading dash) so future flag additions don't
185 // collide with skill names. Skill names that start with
186 // `-` aren't allowed by the loader so this is safe.
187 if trimmed.starts_with('-') || trimmed.split_whitespace().count() > 1 {
188 return CommandResult::error(
189 "Usage: /skills [--remote|sync|inspect|suggest <task>|<name-prefix>]",
190 );
191 }
192 prefix = Some(trimmed.to_ascii_lowercase());
193 }
194 } else {
195 // Bare `/skills` opens the unified manager (owned-only, zero network).
196 return CommandResult::action(AppAction::OpenSkillsManager);
197 }
198 let skills_dir = app.skills_dir.clone();
199 let registry = discover_visible_skills(app);
200 let warnings = render_skill_warnings(&registry);
201
202 if registry.is_empty() {
203 let msg = format!(
204 "No skills found.\n\n\
205 Skills location: {}\n\n\
206 To add skills, create directories with SKILL.md files:\n \
207 {}/my-skill/SKILL.md\n\n\
208 Format:\n \
209 ---\n \
210 name: my-skill\n \
211 description: What this skill does\n \
212 ---\n\n \
213 <instructions here>{warnings}",
214 skills_dir.display(),
215 skills_dir.display()
216 );
217 return CommandResult::message(msg);
218 }
219
220 let filtered: Vec<&crate::skills::Skill> = if let Some(p) = prefix.as_deref() {
221 registry
222 .list()
223 .iter()
224 .filter(|s| s.name.to_ascii_lowercase().starts_with(p))
225 .collect()
226 } else {
227 registry.list().iter().collect()
228 };
229
230 if filtered.is_empty() {
231 // The user typed a prefix that matched nothing. Surface what
232 // they typed plus the full count so they can decide whether
233 // to adjust the prefix or run `/skills` for the whole list.
234 let p = prefix.as_deref().unwrap_or("");
235 return CommandResult::message(format!(
236 "No skills match prefix `{p}` (out of {} available).\n\nRun /skills to see them all.{warnings}",
237 registry.len()
238 ));
239 }
240
241 let mut output = if let Some(p) = prefix.as_deref() {
242 format!(
243 "Available skills matching `{p}` ({} of {}):\n",
244 filtered.len(),
245 registry.len()
246 )
247 } else {
248 format!("Available skills ({}):\n", registry.len())
249 };
250 output.push_str("─────────────────────────────\n");
251
252 if prefix.is_some() {
253 // Filtered view: keep the flat list — the user already narrowed.
254 for (idx, skill) in filtered.iter().enumerate() {
255 if idx > 0 {
256 output.push('\n');
257 }
258 let _ = writeln!(output, " /{} - {}", skill.name, skill.description);
259 }
260 } else {
261 // Unfiltered view: keep user-created skills prominent, then split the
262 // shipped catalog into its two curated product tiers.
263 let (user_skills, bundled_skills): (
264 Vec<&&crate::skills::Skill>,
265 Vec<&&crate::skills::Skill>,
266 ) = filtered
267 .iter()
268 .partition(|s| !crate::skills::is_bundled_skill_name(&s.name));
269
270 if !user_skills.is_empty() {
271 let _ = writeln!(output, "Your skills ({}):", user_skills.len());
272 for skill in &user_skills {
273 let _ = writeln!(output, " /{} - {}", skill.name, skill.description);
274 }
275 if !bundled_skills.is_empty() {
276 output.push('\n');
277 }
278 }
279
280 if !bundled_skills.is_empty() {
281 use crate::skills::{BundledSkillTier, bundled_skill_tier};
282
283 let (core, tooling): (Vec<&&crate::skills::Skill>, Vec<&&crate::skills::Skill>) =
284 bundled_skills.into_iter().partition(|skill| {
285 bundled_skill_tier(&skill.name) == Some(BundledSkillTier::CoreAgentic)
286 });
287 for (group_idx, (tier, skills)) in [
288 (BundledSkillTier::CoreAgentic, core),
289 (BundledSkillTier::FormatTooling, tooling),
290 ]
291 .into_iter()
292 .enumerate()
293 {
294 if skills.is_empty() {
295 continue;
296 }
297 if group_idx > 0 {
298 output.push('\n');
299 }
300 let _ = writeln!(output, "{} ({}):", tier.heading(), skills.len());
301 if user_skills.is_empty() {
302 for skill in skills {
303 let _ = writeln!(output, " /{} - {}", skill.name, skill.description);
304 }
305 } else {
306 let names: Vec<String> = skills
307 .iter()
308 .map(|skill| format!("/{}", skill.name))
309 .collect();
310 let _ = writeln!(output, " {}", names.join(", "));
311 }
312 }
313 if !user_skills.is_empty() {
314 output.push_str(" (run /skills <name> for details on a built-in)\n");
315 }
316 }
317 }
318
319 let _ = write!(
320 output,
321 "\nUse /skill <name> to run a skill\nSkills location: {}{}",
322 skills_dir.display(),
323 warnings
324 );
325
326 CommandResult::message(output)
327 }
328
329 /// Run a specific skill — activates skill for next user message, or
330 /// dispatches a sub-command (`install`, `update`, `uninstall`, `trust`).
331 /// Try to run a skill by exact name (used for unified slash-command namespace, #435).
332 /// Returns None when no skill with that name exists, so the caller can try other sources.
333 pub(in crate::commands) fn run_skill_by_name(
334 app: &mut App,
335 name: &str,
336 arg: Option<&str>,
337 ) -> Option<CommandResult> {
338 let registry = discover_visible_skills(app);
339 let lookup_name = if name == "new" { "skill-creator" } else { name };
340 if registry.get(lookup_name).is_some() {
341 Some(activate_skill_with_task(app, name, arg))
342 } else {
343 None
344 }
345 }
346
347 fn run_skill(app: &mut App, name: Option<&str>) -> CommandResult {
348 let raw = match name {
349 Some(n) => n.trim(),
350 None => {
351 return CommandResult::error(
352 "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>",
353 );
354 }
355 };
356
357 // Sub-command dispatch happens before the activation path so users can't
358 // accidentally activate a skill literally named "install".
359 let mut iter = raw.splitn(2, char::is_whitespace);
360 let head = iter.next().unwrap_or("").trim();
361 let rest = iter.next().unwrap_or("").trim();
362 match head {
363 "install" => return install_skill(app, rest),
364 "update" => return update_skill(app, rest),
365 "uninstall" => return uninstall_skill(app, rest),
366 "trust" => return trust_skill(app, rest),
367 _ => {}
368 }
369
370 let task = (!rest.is_empty()).then_some(rest);
371 activate_skill_with_task(app, head, task)
372 }
373
374 /// Parse optional `--project` / `--global` scope prefix from a skill subcommand.
375 fn parse_scope_args(
376 args: &str,
377 ) -> Result<(Option<crate::skills::mutation::SkillTargetScope>, &str), String> {
378 use crate::skills::mutation::SkillTargetScope;
379 let mut scope = None;
380 let mut rest = args.trim();
381 loop {
382 if let Some(next) = rest.strip_prefix("--project") {
383 if scope.is_some() {
384 return Err("specify at most one of --project / --global".into());
385 }
386 scope = Some(SkillTargetScope::Project);
387 rest = next.trim_start();
388 continue;
389 }
390 if let Some(next) = rest.strip_prefix("--global") {
391 if scope.is_some() {
392 return Err("specify at most one of --project / --global".into());
393 }
394 scope = Some(SkillTargetScope::Global);
395 rest = next.trim_start();
396 continue;
397 }
398 break;
399 }
400 Ok((scope, rest.trim()))
401 }
402
403 fn format_mutation_receipt(receipt: &crate::skills::mutation::SkillMutationReceipt) -> String {
404 use crate::skills::mutation::SkillMutationOutcome;
405 match &receipt.outcome {
406 SkillMutationOutcome::Installed => format!(
407 "Installed skill '{}'.\nLocation: {}\n\nManage skills with /skills.",
408 receipt.name, receipt.safe_target_path
409 ),
410 SkillMutationOutcome::Updated => format!(
411 "Skill '{}' updated.\nLocation: {}",
412 receipt.name, receipt.safe_target_path
413 ),
414 SkillMutationOutcome::NoChange => {
415 format!("Skill '{}': no upstream change.", receipt.name)
416 }
417 SkillMutationOutcome::Removed => format!("Removed skill '{}'.", receipt.name),
418 SkillMutationOutcome::Trusted => format!(
419 "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.",
420 receipt.name
421 ),
422 SkillMutationOutcome::Imported => format!(
423 "Imported skill '{}'.\nLocation: {}",
424 receipt.name, receipt.safe_target_path
425 ),
426 SkillMutationOutcome::AlreadyPresent => format!(
427 "Skill '{}' is already present at {} (exact duplicate).",
428 receipt.name, receipt.safe_target_path
429 ),
430 SkillMutationOutcome::NeedsApproval(host) => needs_approval_message(host),
431 SkillMutationOutcome::NetworkDenied(host) => network_denied_message(host),
432 }
433 }
434
435 /// Activate a skill and, when the invocation includes a task, send that task
436 /// immediately. `AppAction::SendMessage` is converted into a `QueuedMessage`
437 /// by the UI, where `app.active_skill` is consumed and attached to this turn.
438 fn activate_skill_with_task(app: &mut App, name: &str, task: Option<&str>) -> CommandResult {
439 let mut result = activate_skill(app, name);
440 if !result.is_error
441 && let Some(task) = task.map(str::trim).filter(|task| !task.is_empty())
442 {
443 result.action = Some(AppAction::SendMessage(task.to_string()));
444 }
445 result
446 }
447
448 fn activate_skill(app: &mut App, name: &str) -> CommandResult {
449 // `/skill new` is a friendly alias for `/skill skill-creator`.
450 let name = if name == "new" { "skill-creator" } else { name };
451
452 let registry = discover_visible_skills(app);
453
454 if let Some(skill) = registry.get(name) {
455 let plugin_provenance = match &skill.source {
456 SkillSource::Native => None,
457 SkillSource::Plugin { authority, .. } => {
458 if let Err(reason) = crate::plugins::registry::verify_plugin_authority(authority) {
459 return CommandResult::error(format!(
460 "Plugin skill '{}' is no longer active: {reason}",
461 skill.name
462 ));
463 }
464 Some(authority.as_ref().clone())
465 }
466 };
467 let instruction = format!(
468 "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.",
469 skill.name, skill.body
470 );
471
472 app.add_message(HistoryCell::System {
473 content: format!("Activated skill: {}\n\n{}", skill.name, skill.description),
474 });
475
476 app.active_skill = Some(instruction);
477 app.active_skill_provenance = plugin_provenance;
478
479 CommandResult::message(format!(
480 "Skill '{}' activated.\n\nDescription: {}\n\nType your request and the skill instructions will be applied.",
481 skill.name, skill.description
482 ))
483 } else {
484 let available: Vec<String> = registry.list().iter().map(|s| s.name.clone()).collect();
485 let warnings = render_skill_warnings(&registry);
486
487 if available.is_empty() {
488 CommandResult::error(format!(
489 "Skill '{name}' not found. No skills installed.\n\nUse /skills to see how to add skills.{warnings}"
490 ))
491 } else {
492 CommandResult::error(format!(
493 "Skill '{}' not found.\n\nAvailable skills: {}{}",
494 name,
495 available.join(", "),
496 warnings
497 ))
498 }
499 }
500 }
501
502 // ─── /skill install ────────────────────────────────────────────────────────
503
504 fn install_skill(app: &mut App, args: &str) -> CommandResult {
505 use crate::skills::mutation::{MutationContext, SkillMutationRequest, SkillTargetScope};
506
507 let (scope, spec) = match parse_scope_args(args) {
508 Ok(v) => v,
509 Err(err) => return CommandResult::error(err),
510 };
511 if spec.is_empty() {
512 return CommandResult::error(
513 "Usage: /skill install [--project|--global] <github:owner/repo|https://…|<registry-name>>",
514 );
515 }
516 let source = match InstallSource::parse(spec) {
517 Ok(s) => s,
518 Err(err) => return CommandResult::error(format!("Invalid install source: {err}")),
519 };
520 // Legacy no-scope install maps to the CodeWhale global owned root.
521 let target = scope.unwrap_or(SkillTargetScope::Global);
522 let workspace = app.workspace.clone();
523 let home = crate::config::effective_home_dir();
524 let (network, max_size, registry_url) = installer_settings(app);
525
526 let outcome = run_async(async move {
527 let ctx = MutationContext {
528 workspace: &workspace,
529 home: home.as_deref(),
530 configured_skills_dir: None,
531 network: &network,
532 max_size,
533 registry_url: &registry_url,
534 };
535 crate::skills::mutation::execute(
536 SkillMutationRequest::InstallRemote { source, target },
537 &ctx,
538 )
539 .await
540 });
541
542 match outcome {
543 Ok(receipt) => {
544 if matches!(
545 receipt.outcome,
546 crate::skills::mutation::SkillMutationOutcome::Installed
547 ) {
548 app.refresh_skill_cache();
549 }
550 let message = format_mutation_receipt(&receipt);
551 if matches!(
552 receipt.outcome,
553 crate::skills::mutation::SkillMutationOutcome::NeedsApproval(_)
554 | crate::skills::mutation::SkillMutationOutcome::NetworkDenied(_)
555 ) {
556 CommandResult::error(message)
557 } else {
558 CommandResult::message(message)
559 }
560 }
561 Err(err) => CommandResult::error(format!("Install failed: {err:#}")),
562 }
563 }
564
565 // ─── /skill update ─────────────────────────────────────────────────────────
566
567 fn update_skill(app: &mut App, args: &str) -> CommandResult {
568 use crate::skills::mutation::{MutationContext, SkillMutationRequest};
569
570 let (scope, name) = match parse_scope_args(args) {
571 Ok(v) => v,
572 Err(err) => return CommandResult::error(err),
573 };
574 if name.is_empty() {
575 return CommandResult::error("Usage: /skill update [--project|--global] <name>");
576 }
577 let workspace = app.workspace.clone();
578 let home = crate::config::effective_home_dir();
579 let (network, max_size, registry_url) = installer_settings(app);
580 let owned_name = name.to_string();
581
582 let outcome = run_async(async move {
583 let ctx = MutationContext {
584 workspace: &workspace,
585 home: home.as_deref(),
586 configured_skills_dir: None,
587 network: &network,
588 max_size,
589 registry_url: &registry_url,
590 };
591 crate::skills::mutation::execute(
592 SkillMutationRequest::UpdateByName {
593 name: owned_name,
594 scope,
595 expected_digest: None,
596 },
597 &ctx,
598 )
599 .await
600 });
601
602 match outcome {
603 Ok(receipt) => {
604 if matches!(
605 receipt.outcome,
606 crate::skills::mutation::SkillMutationOutcome::Updated
607 ) {
608 app.refresh_skill_cache();
609 }
610 let message = format_mutation_receipt(&receipt);
611 if matches!(
612 receipt.outcome,
613 crate::skills::mutation::SkillMutationOutcome::NeedsApproval(_)
614 | crate::skills::mutation::SkillMutationOutcome::NetworkDenied(_)
615 ) {
616 CommandResult::error(message)
617 } else {
618 CommandResult::message(message)
619 }
620 }
621 Err(err) => CommandResult::error(format!("Update failed: {err:#}")),
622 }
623 }
624
625 // ─── /skill uninstall ──────────────────────────────────────────────────────
626
627 fn uninstall_skill(app: &mut App, args: &str) -> CommandResult {
628 use crate::skills::mutation::{MutationContext, SkillMutationRequest};
629
630 let (scope, name) = match parse_scope_args(args) {
631 Ok(v) => v,
632 Err(err) => return CommandResult::error(err),
633 };
634 if name.is_empty() {
635 return CommandResult::error("Usage: /skill uninstall [--project|--global] <name>");
636 }
637 let home = crate::config::effective_home_dir();
638 let (network, max_size, registry_url) = installer_settings(app);
639 let ctx = MutationContext {
640 workspace: &app.workspace,
641 home: home.as_deref(),
642 configured_skills_dir: None,
643 network: &network,
644 max_size,
645 registry_url: &registry_url,
646 };
647
648 match crate::skills::mutation::execute_sync(
649 SkillMutationRequest::RemoveByName {
650 name: name.to_string(),
651 scope,
652 expected_digest: None,
653 },
654 &ctx,
655 ) {
656 Ok(receipt) => {
657 app.refresh_skill_cache();
658 CommandResult::message(format_mutation_receipt(&receipt))
659 }
660 Err(err) => CommandResult::error(format!("Uninstall failed: {err:#}")),
661 }
662 }
663
664 // ─── /skill trust ──────────────────────────────────────────────────────────
665
666 fn trust_skill(app: &mut App, args: &str) -> CommandResult {
667 use crate::skills::mutation::{MutationContext, SkillMutationRequest};
668
669 let (scope, name) = match parse_scope_args(args) {
670 Ok(v) => v,
671 Err(err) => return CommandResult::error(err),
672 };
673 if name.is_empty() {
674 return CommandResult::error("Usage: /skill trust [--project|--global] <name>");
675 }
676 let home = crate::config::effective_home_dir();
677 let (network, max_size, registry_url) = installer_settings(app);
678 let ctx = MutationContext {
679 workspace: &app.workspace,
680 home: home.as_deref(),
681 configured_skills_dir: None,
682 network: &network,
683 max_size,
684 registry_url: &registry_url,
685 };
686
687 match crate::skills::mutation::execute_sync(
688 SkillMutationRequest::TrustByName {
689 name: name.to_string(),
690 scope,
691 expected_digest: None,
692 },
693 &ctx,
694 ) {
695 Ok(receipt) => CommandResult::message(format_mutation_receipt(&receipt)),
696 Err(err) => CommandResult::error(format!("Trust failed: {err:#}")),
697 }
698 }
699
700 // ─── /skills --remote ──────────────────────────────────────────────────────
701
702 /// List skills available in the configured curated registry.
703 fn list_remote_skills(app: &mut App) -> CommandResult {
704 let (network, _max_size, registry_url) = installer_settings(app);
705 let registry = run_async(async move { install::fetch_registry(&network, &registry_url).await });
706 match registry {
707 Ok(RegistryFetchResult::Loaded(doc)) => {
708 if doc.skills.is_empty() {
709 return CommandResult::message("Registry is empty.");
710 }
711 let mut out = format!("Available remote skills ({}):\n", doc.skills.len());
712 out.push_str("─────────────────────────────\n");
713 for (name, entry) in &doc.skills {
714 let _ = writeln!(
715 out,
716 " {name} — {} (source: {})",
717 entry.description.clone().unwrap_or_default(),
718 entry.source
719 );
720 }
721 let _ = write!(out, "\nInstall with: /skill install <name>");
722 CommandResult::message(out)
723 }
724 Ok(RegistryFetchResult::NeedsApproval(host)) => {
725 CommandResult::error(needs_approval_message(&host))
726 }
727 Ok(RegistryFetchResult::Denied(host)) => {
728 CommandResult::error(network_denied_message(&host))
729 }
730 Err(err) => CommandResult::error(format_registry_error("Failed to fetch registry", &err)),
731 }
732 }
733
734 // ─── /skills suggest ──────────────────────────────────────────────────────
735
736 /// Recommend a small set of remote skills for a task. This performs the same
737 /// network-policy-gated registry read as `/skills --remote`, but it cannot
738 /// download, trust, enable, or activate a skill.
739 fn suggest_remote_skills(app: &mut App, task: &str) -> CommandResult {
740 let task = task.trim();
741 if task.chars().count() < 3 {
742 return CommandResult::error("Usage: /skills suggest <task of at least 3 characters>");
743 }
744
745 let (network, _max_size, registry_url) = installer_settings(app);
746 let registry = run_async(async move { install::fetch_registry(&network, &registry_url).await });
747 match registry {
748 Ok(RegistryFetchResult::Loaded(doc)) => {
749 let recommendations = crate::skills::recommend::recommend_remote_skills(task, &doc, 3);
750 if recommendations.is_empty() {
751 return CommandResult::message(format!(
752 "No curated remote skills matched `{task}`.\n\nBrowse the catalog with /skills --remote. Nothing was installed, trusted, or enabled."
753 ));
754 }
755
756 let mut out = format!("Suggested remote skills for `{task}`:\n");
757 out.push_str("─────────────────────────────\n");
758 for recommendation in recommendations {
759 let description = recommendation
760 .entry
761 .description
762 .as_deref()
763 .filter(|description| !description.trim().is_empty())
764 .unwrap_or("No description provided.");
765 let _ = writeln!(out, " {} — {description}", recommendation.name);
766 let _ = writeln!(out, " Why: {}", recommendation.matched_terms.join(", "));
767 let _ = writeln!(
768 out,
769 " Install if you want it: /skill install {}",
770 recommendation.name
771 );
772 }
773 out.push_str("\nNothing was installed, trusted, or enabled.");
774 CommandResult::message(out)
775 }
776 Ok(RegistryFetchResult::NeedsApproval(host)) => {
777 CommandResult::error(needs_approval_message(&host))
778 }
779 Ok(RegistryFetchResult::Denied(host)) => {
780 CommandResult::error(network_denied_message(&host))
781 }
782 Err(err) => CommandResult::error(format_registry_error("Failed to fetch registry", &err)),
783 }
784 }
785
786 // ─── /skills sync ──────────────────────────────────────────────────────────
787
788 /// Fetch the remote registry index and download every listed skill into the
789 /// local cache (`~/.codewhale/cache/skills/<name>/`).
790 ///
791 /// For each skill the sync checks the cached ETag / SHA-256 before
792 /// downloading so unchanged skills are skipped in O(1) network round-trips.
793 fn sync_skills(app: &mut App) -> CommandResult {
794 let (network, max_size, registry_url) = installer_settings(app);
795 let cache_dir = install::default_cache_skills_dir();
796
797 let result = run_async(async move {
798 install::sync_registry(&network, &registry_url, &cache_dir, max_size).await
799 });
800
801 match result {
802 Ok(SyncResult::RegistryDenied(host)) => CommandResult::error(network_denied_message(&host)),
803 Ok(SyncResult::RegistryNeedsApproval(host)) => {
804 CommandResult::error(needs_approval_message(&host))
805 }
806 Ok(SyncResult::Done { outcomes }) => {
807 let total = outcomes.len();
808 let mut downloaded = 0usize;
809 let mut fresh = 0usize;
810 let mut failed = 0usize;
811 let mut out = String::from("Registry sync complete.\n\n");
812
813 for outcome in &outcomes {
814 match outcome {
815 SkillSyncOutcome::Downloaded { name, path } => {
816 downloaded += 1;
817 let _ = writeln!(out, " [+] {name} — downloaded to {}", path.display());
818 }
819 SkillSyncOutcome::Fresh { name } => {
820 fresh += 1;
821 let _ = writeln!(out, " [=] {name} — already up to date");
822 }
823 SkillSyncOutcome::Failed { name, reason } => {
824 failed += 1;
825 let _ = writeln!(out, " [!] {name} — failed: {reason}");
826 }
827 SkillSyncOutcome::Denied { name, host } => {
828 failed += 1;
829 let _ = writeln!(out, " [!] {name} — network denied ({host})");
830 }
831 SkillSyncOutcome::NeedsApproval { name, host } => {
832 failed += 1;
833 let _ = writeln!(
834 out,
835 " [?] {name} — needs approval for {host} (run `/network allow {host}` then retry)"
836 );
837 }
838 }
839 }
840
841 let _ = write!(
842 out,
843 "\n{total} skill(s) processed: {downloaded} downloaded, {fresh} up-to-date, {failed} failed."
844 );
845
846 CommandResult::message(out)
847 }
848 Err(err) => CommandResult::error(format_registry_error("Sync failed", &err)),
849 }
850 }
851
852 // ─── helpers ───────────────────────────────────────────────────────────────
853
854 /// Read the active config knobs for the installer.
855 ///
856 /// We load `Config::load` on demand because [`App`] does not carry a `Config`
857 /// field — and loading is cheap (small TOML file) compared to the network
858 /// round-trip the install/update operation will incur next. If the config
859 /// fails to parse, we fall back to defaults so the user still gets a
860 /// network-gated install rather than a silent crash.
861 fn installer_settings(_app: &App) -> (NetworkPolicy, u64, String) {
862 let cfg = crate::config::Config::load(None, None).unwrap_or_default();
863 let network = cfg
864 .network
865 .clone()
866 .map(|policy| policy.into_runtime())
867 .unwrap_or_default();
868 let skills_cfg = cfg.skills.as_ref();
869 let max_size = skills_cfg
870 .and_then(|s| s.max_install_size_bytes)
871 .unwrap_or(DEFAULT_MAX_SIZE_BYTES);
872 let registry_url = skills_cfg
873 .and_then(|s| s.registry_url.clone())
874 .unwrap_or_else(|| DEFAULT_REGISTRY_URL.to_string());
875 (network, max_size, registry_url)
876 }
877
878 fn run_async<F, T>(future: F) -> T
879 where
880 F: std::future::Future<Output = T>,
881 {
882 // We're on the TUI's thread, which is part of the multi-threaded runtime.
883 // `block_in_place` + `Handle::current().block_on` bridges sync
884 // slash-command handlers back into the async ecosystem.
885 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
886 }
887
888 #[allow(dead_code)] // retained for sync/remote listing helpers
889 fn path_or_default(path: &std::path::Path) -> String {
890 path.file_name()
891 .map(|n| {
892 // Display with parent so the user sees the full skill location.
893 // We intentionally use `display()` here because it's just for
894 // user-facing output, not for path comparisons.
895 let parent = path
896 .parent()
897 .map(|p| p.display().to_string())
898 .unwrap_or_default();
899 if parent.is_empty() {
900 n.to_string_lossy().to_string()
901 } else {
902 format!("{parent}/{}", n.to_string_lossy())
903 }
904 })
905 .unwrap_or_else(|| path.display().to_string())
906 }
907
908 fn needs_approval_message(host: &str) -> String {
909 format!(
910 "Network policy requires approval for {host}.\n\
911 Add it to your allow list with `/network allow {host}` (or set [network].default = \"allow\" in ~/.codewhale/config.toml), then retry."
912 )
913 }
914
915 fn network_denied_message(host: &str) -> String {
916 format!(
917 "Network policy denied access to {host}.\n\
918 Remove the deny entry from ~/.codewhale/config.toml under [network] or contact your administrator."
919 )
920 }
921
922 /// Inspect an anyhow chain and surface a one-line hint pointing at the most
923 /// common cause of a registry fetch failure (DNS, refused, TLS, HTTP status,
924 /// timeout). The chain itself is still rendered with `{err:#}`; this hint is
925 /// appended below it so users on `/skills --remote` and `/skills sync` get an
926 /// actionable next step instead of an opaque reqwest error.
927 fn registry_fetch_error_hint(err: &anyhow::Error) -> Option<&'static str> {
928 let msg = format!("{err:#}").to_lowercase();
929 if msg.contains("dns")
930 || msg.contains("name resolution")
931 || msg.contains("getaddrinfo")
932 || msg.contains("nodename nor servname")
933 {
934 Some(
935 "Hint: DNS lookup failed. Check internet/DNS connectivity, or override the registry URL in [skills] of ~/.codewhale/config.toml.",
936 )
937 } else if msg.contains("connection refused")
938 || msg.contains("connection reset")
939 || msg.contains("connection aborted")
940 {
941 Some(
942 "Hint: connection refused/reset. The registry host may be unreachable from this network (corporate proxy, firewall, offline).",
943 )
944 } else if msg.contains("tls")
945 || msg.contains("certificate")
946 || msg.contains("ssl")
947 || msg.contains("handshake")
948 {
949 Some(
950 "Hint: TLS handshake failed. The system trust store may be missing the registry's CA, or a TLS-intercepting proxy is rewriting the certificate.",
951 )
952 } else if msg.contains(" 404") || msg.contains("not found") {
953 Some(
954 "Hint: registry URL returned 404. Verify the registry URL in [skills] of ~/.codewhale/config.toml.",
955 )
956 } else if msg.contains(" 401") || msg.contains(" 403") || msg.contains("forbidden") {
957 Some(
958 "Hint: registry returned an auth error. The registry may require credentials or have been moved.",
959 )
960 } else if msg.contains(" 429") || msg.contains("rate limit") || msg.contains("too many") {
961 Some("Hint: rate-limited by the registry. Try again in a moment.")
962 } else if msg.contains("timed out") || msg.contains("timeout") {
963 Some("Hint: request timed out. Network may be slow or the registry host may be down.")
964 } else {
965 None
966 }
967 }
968
969 fn format_registry_error(prefix: &str, err: &anyhow::Error) -> String {
970 let mut out = format!("{prefix}: {err:#}");
971 if let Some(hint) = registry_fetch_error_hint(err) {
972 out.push_str("\n\n");
973 out.push_str(hint);
974 }
975 out
976 }
977
978 pub(in crate::commands) const SKILLS_INFO: crate::commands::traits::CommandInfo =
979 crate::commands::traits::CommandInfo {
980 name: "skills",
981 aliases: &["jinengliebiao"],
982 usage: "/skills [--remote|sync|inspect|suggest <task>|<prefix>] (bare opens manager)",
983 description_id: crate::localization::MessageId::CmdSkillsDescription,
984 };
985
986 pub(in crate::commands) struct SkillsCmd;
987
988 impl crate::commands::traits::RegisterCommand for SkillsCmd {
989 fn info() -> &'static crate::commands::traits::CommandInfo {
990 &SKILLS_INFO
991 }
992
993 fn execute(
994 app: &mut crate::tui::app::App,
995 arg: Option<&str>,
996 ) -> crate::commands::CommandResult {
997 list_skills(app, arg)
998 }
999 }
1000
1001 pub(in crate::commands) const SKILL_INFO: crate::commands::traits::CommandInfo =
1002 crate::commands::traits::CommandInfo {
1003 name: "skill",
1004 aliases: &["jineng"],
1005 usage: "/skill <name|install <spec>|update <name>|uninstall <name>|trust <name>>",
1006 description_id: crate::localization::MessageId::CmdSkillDescription,
1007 };
1008
1009 pub(in crate::commands) struct SkillCmd;
1010
1011 impl crate::commands::traits::RegisterCommand for SkillCmd {
1012 fn info() -> &'static crate::commands::traits::CommandInfo {
1013 &SKILL_INFO
1014 }
1015
1016 fn execute(
1017 app: &mut crate::tui::app::App,
1018 arg: Option<&str>,
1019 ) -> crate::commands::CommandResult {
1020 run_skill(app, arg)
1021 }
1022 }
1023
1024 #[cfg(test)]
1025 mod tests {
1026 use super::*;
1027 use crate::config::Config;
1028 use crate::tui::app::{App, TuiOptions};
1029 use std::ffi::OsString;
1030 use tempfile::TempDir;
1031
1032 struct IsolatedHome {
1033 _lock: crate::test_support::TestEnvLock,
1034 home_prev: Option<OsString>,
1035 userprofile_prev: Option<OsString>,
1036 test_home_prev: Option<std::path::PathBuf>,
1037 }
1038
1039 impl IsolatedHome {
1040 fn new(tmpdir: &TempDir) -> Self {
1041 let lock = crate::test_support::lock_test_env();
1042 let home = tmpdir.path().join("home");
1043 std::fs::create_dir_all(&home).unwrap();
1044 let home_prev = std::env::var_os("HOME");
1045 let userprofile_prev = std::env::var_os("USERPROFILE");
1046 // SAFETY: tests that mutate process env hold the shared test env
1047 // mutex for the full lifetime of this guard.
1048 unsafe {
1049 std::env::set_var("HOME", &home);
1050 std::env::set_var("USERPROFILE", &home);
1051 }
1052 let test_home_prev = TEST_HOME_DIR.with(|slot| slot.replace(Some(home)));
1053 Self {
1054 _lock: lock,
1055 home_prev,
1056 userprofile_prev,
1057 test_home_prev,
1058 }
1059 }
1060
1061 unsafe fn restore_var(key: &str, value: Option<OsString>) {
1062 if let Some(value) = value {
1063 unsafe { std::env::set_var(key, value) };
1064 } else {
1065 unsafe { std::env::remove_var(key) };
1066 }
1067 }
1068 }
1069
1070 impl Drop for IsolatedHome {
1071 fn drop(&mut self) {
1072 TEST_HOME_DIR.with(|slot| {
1073 *slot.borrow_mut() = self.test_home_prev.take();
1074 });
1075 // SAFETY: the shared test env mutex is still held while Drop runs.
1076 unsafe {
1077 Self::restore_var("HOME", self.home_prev.take());
1078 Self::restore_var("USERPROFILE", self.userprofile_prev.take());
1079 }
1080 }
1081 }
1082
1083 fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
1084 let options = TuiOptions {
1085 skills_dir: tmpdir.path().join("skills"),
1086 memory_path: tmpdir.path().join("memory.md"),
1087 notes_path: tmpdir.path().join("notes.txt"),
1088 mcp_config_path: tmpdir.path().join("mcp.json"),
1089 ..crate::test_support::test_tui_options(tmpdir.path())
1090 };
1091 let mut app = App::new(options, &Config::default());
1092 app.skills_dir = tmpdir.path().join("skills");
1093 app
1094 }
1095
1096 fn create_skill_dir(tmpdir: &TempDir, skill_name: &str, skill_content: &str) {
1097 let skill_dir = tmpdir.path().join("skills").join(skill_name);
1098 std::fs::create_dir_all(&skill_dir).unwrap();
1099 std::fs::write(skill_dir.join("SKILL.md"), skill_content).unwrap();
1100 }
1101
1102 #[test]
1103 fn registry_fetch_error_hint_recognises_dns_failures() {
1104 let err = anyhow::Error::msg("error sending request: dns error: failed to lookup")
1105 .context("failed to fetch registry https://example.com/registry.json");
1106 let hint = registry_fetch_error_hint(&err).expect("dns hint");
1107 assert!(hint.contains("DNS"), "got: {hint}");
1108 }
1109
1110 #[test]
1111 fn registry_fetch_error_hint_recognises_connection_refused() {
1112 let err = anyhow::Error::msg("error sending request: tcp connect: connection refused");
1113 let hint = registry_fetch_error_hint(&err).expect("refused hint");
1114 assert!(hint.contains("refused"), "got: {hint}");
1115 }
1116
1117 #[test]
1118 fn registry_fetch_error_hint_recognises_tls_failures() {
1119 let err = anyhow::Error::msg("invalid peer certificate: UnknownIssuer (TLS handshake)");
1120 let hint = registry_fetch_error_hint(&err).expect("tls hint");
1121 assert!(hint.contains("TLS"), "got: {hint}");
1122 }
1123
1124 #[test]
1125 fn registry_fetch_error_hint_recognises_http_status_codes() {
1126 let err_404 = anyhow::Error::msg("registry returned an error status: 404 Not Found");
1127 assert!(
1128 registry_fetch_error_hint(&err_404)
1129 .map(|h| h.contains("404"))
1130 .unwrap_or(false)
1131 );
1132 let err_429 =
1133 anyhow::Error::msg("registry returned an error status: 429 Too Many Requests");
1134 assert!(
1135 registry_fetch_error_hint(&err_429)
1136 .map(|h| h.contains("rate"))
1137 .unwrap_or(false)
1138 );
1139 }
1140
1141 #[test]
1142 fn registry_fetch_error_hint_returns_none_for_unrecognised_errors() {
1143 let err = anyhow::Error::msg("a totally novel error nobody anticipated");
1144 assert!(registry_fetch_error_hint(&err).is_none());
1145 }
1146
1147 #[test]
1148 fn format_registry_error_appends_hint_when_pattern_matches() {
1149 let err = anyhow::Error::msg("dns error: nodename nor servname provided");
1150 let formatted = format_registry_error("Failed to fetch registry", &err);
1151 assert!(formatted.starts_with("Failed to fetch registry: "));
1152 assert!(
1153 formatted.contains("Hint: DNS"),
1154 "expected hint, got: {formatted}"
1155 );
1156 }
1157
1158 #[test]
1159 fn format_registry_error_omits_hint_when_no_pattern_matches() {
1160 let err = anyhow::Error::msg("inscrutable opaque failure");
1161 let formatted = format_registry_error("Sync failed", &err);
1162 assert_eq!(formatted, "Sync failed: inscrutable opaque failure");
1163 }
1164
1165 #[test]
1166 fn test_bare_skills_opens_manager() {
1167 let tmpdir = TempDir::new().unwrap();
1168 let _home = IsolatedHome::new(&tmpdir);
1169 let mut app = create_test_app_with_tmpdir(&tmpdir);
1170 let result = list_skills(&mut app, None);
1171 assert!(matches!(result.action, Some(AppAction::OpenSkillsManager)));
1172 }
1173
1174 #[test]
1175 fn test_list_skills_empty_directory() {
1176 let tmpdir = TempDir::new().unwrap();
1177 let _home = IsolatedHome::new(&tmpdir);
1178 let mut app = create_test_app_with_tmpdir(&tmpdir);
1179 // Empty arg still uses the legacy text inventory (prefix path).
1180 let result = list_skills(&mut app, Some(""));
1181 assert!(result.message.is_some());
1182 let msg = result.message.unwrap();
1183 assert!(msg.contains("No skills found"));
1184 assert!(msg.contains("Skills location:"));
1185 assert!(
1186 !msg.contains("allowed-tools"),
1187 "empty-state template must not advertise unenforced tool restrictions: {msg}"
1188 );
1189 }
1190
1191 #[test]
1192 fn test_list_skills_with_skills() {
1193 let tmpdir = TempDir::new().unwrap();
1194 let _home = IsolatedHome::new(&tmpdir);
1195 create_skill_dir(
1196 &tmpdir,
1197 "test-skill",
1198 "---\nname: test-skill\ndescription: A test skill\n---\nDo something",
1199 );
1200 let mut app = create_test_app_with_tmpdir(&tmpdir);
1201 let result = list_skills(&mut app, Some(""));
1202 assert!(result.message.is_some());
1203 let msg = result.message.unwrap();
1204 assert!(msg.contains("Available skills"));
1205 assert!(msg.contains("/test-skill"));
1206 }
1207
1208 #[test]
1209 fn test_list_skills_filters_by_name_prefix() {
1210 // #1318: a `/skills <prefix>` argument should narrow the list to
1211 // skills whose names start with the prefix. The header reflects
1212 // both the matched count and the registry total so the user
1213 // knows what they're looking at.
1214 let tmpdir = TempDir::new().unwrap();
1215 let _home = IsolatedHome::new(&tmpdir);
1216 create_skill_dir(
1217 &tmpdir,
1218 "alpha-skill",
1219 "---\nname: alpha-skill\ndescription: First\n---\nbody",
1220 );
1221 create_skill_dir(
1222 &tmpdir,
1223 "alphabet-helper",
1224 "---\nname: alphabet-helper\ndescription: Helper\n---\nbody",
1225 );
1226 create_skill_dir(
1227 &tmpdir,
1228 "beta-skill",
1229 "---\nname: beta-skill\ndescription: Second\n---\nbody",
1230 );
1231
1232 let mut app = create_test_app_with_tmpdir(&tmpdir);
1233 let result = list_skills(&mut app, Some("alph"));
1234 let msg = result.message.expect("filter result has message");
1235
1236 assert!(msg.contains("/alpha-skill"));
1237 assert!(msg.contains("/alphabet-helper"));
1238 assert!(
1239 !msg.contains("/beta-skill"),
1240 "beta-skill must be filtered out"
1241 );
1242 assert!(
1243 msg.contains("matching `alph`") && msg.contains("2 of 3"),
1244 "header should show count + total, got: {msg}"
1245 );
1246 }
1247
1248 #[test]
1249 fn test_list_skills_filter_is_case_insensitive() {
1250 // Prefix matching is case-insensitive — typing `Alph` finds
1251 // `alpha-skill` the same as `alph` does.
1252 let tmpdir = TempDir::new().unwrap();
1253 let _home = IsolatedHome::new(&tmpdir);
1254 create_skill_dir(
1255 &tmpdir,
1256 "alpha-skill",
1257 "---\nname: alpha-skill\ndescription: First\n---\nbody",
1258 );
1259 let mut app = create_test_app_with_tmpdir(&tmpdir);
1260 let result = list_skills(&mut app, Some("ALPH"));
1261 let msg = result.message.expect("case-insensitive filter has message");
1262 assert!(msg.contains("/alpha-skill"));
1263 }
1264
1265 #[test]
1266 fn test_list_skills_filter_with_zero_matches_says_so() {
1267 // When the prefix matches nothing, the message must say so
1268 // explicitly (rather than printing an empty list) and point
1269 // the user back at the unfiltered command.
1270 let tmpdir = TempDir::new().unwrap();
1271 let _home = IsolatedHome::new(&tmpdir);
1272 create_skill_dir(
1273 &tmpdir,
1274 "alpha-skill",
1275 "---\nname: alpha-skill\ndescription: First\n---\nbody",
1276 );
1277 let mut app = create_test_app_with_tmpdir(&tmpdir);
1278 let result = list_skills(&mut app, Some("nonexistent"));
1279 let msg = result.message.expect("zero-match filter still has message");
1280 assert!(msg.contains("No skills match prefix `nonexistent`"));
1281 assert!(msg.contains("Run /skills"));
1282 }
1283
1284 #[test]
1285 fn test_list_skills_rejects_flag_like_prefix() {
1286 // `--remote` and `sync` stay reserved as subcommands; any other
1287 // dash-prefixed argument is rejected so we don't silently turn
1288 // a future flag into a no-match filter.
1289 let tmpdir = TempDir::new().unwrap();
1290 let _home = IsolatedHome::new(&tmpdir);
1291 let mut app = create_test_app_with_tmpdir(&tmpdir);
1292 let result = list_skills(&mut app, Some("--bogus"));
1293 assert!(
1294 result.is_error,
1295 "expected usage error for --bogus, got: {result:?}"
1296 );
1297 assert!(
1298 result
1299 .message
1300 .as_deref()
1301 .is_some_and(|m| m.contains("name-prefix")),
1302 "expected --bogus error message to mention name-prefix, got: {result:?}"
1303 );
1304 }
1305
1306 #[test]
1307 fn test_list_skills_suggest_requires_a_meaningful_task_before_network_access() {
1308 let tmpdir = TempDir::new().unwrap();
1309 let _home = IsolatedHome::new(&tmpdir);
1310 let mut app = create_test_app_with_tmpdir(&tmpdir);
1311
1312 for arg in ["suggest", "recommend", "suggest go"] {
1313 let result = list_skills(&mut app, Some(arg));
1314 assert!(
1315 result.is_error,
1316 "expected usage error for {arg}: {result:?}"
1317 );
1318 assert!(
1319 result
1320 .message
1321 .as_deref()
1322 .is_some_and(|message| message.contains("/skills suggest <task")),
1323 "expected suggestion usage for {arg}: {result:?}"
1324 );
1325 }
1326 }
1327
1328 #[test]
1329 fn test_list_skills_renders_user_skills_under_your_skills_section() {
1330 let tmpdir = TempDir::new().unwrap();
1331 let _home = IsolatedHome::new(&tmpdir);
1332 create_skill_dir(
1333 &tmpdir,
1334 "alpha-skill",
1335 "---\nname: alpha-skill\ndescription: First skill\n---\nDo alpha work",
1336 );
1337 create_skill_dir(
1338 &tmpdir,
1339 "beta-skill",
1340 "---\nname: beta-skill\ndescription: Second skill\n---\nDo beta work",
1341 );
1342
1343 let mut app = create_test_app_with_tmpdir(&tmpdir);
1344 let result = list_skills(&mut app, Some(""));
1345 let msg = result.message.unwrap();
1346
1347 // User-created skills must appear in their own section so they
1348 // stay visible even when many bundled skills are installed.
1349 let section = msg
1350 .find("Your skills")
1351 .expect("user skills section header missing");
1352 let alpha = msg.find("/alpha-skill").expect("alpha skill should render");
1353 let beta = msg.find("/beta-skill").expect("beta skill should render");
1354 assert!(
1355 alpha > section,
1356 "alpha-skill should follow the header: {msg}"
1357 );
1358 assert!(beta > section, "beta-skill should follow the header: {msg}");
1359 // Each entry on its own line with the description inline.
1360 assert!(msg.contains("/alpha-skill - First skill"), "got: {msg}");
1361 assert!(msg.contains("/beta-skill - Second skill"), "got: {msg}");
1362 }
1363
1364 #[test]
1365 fn test_list_skills_tiers_bundled_catalog_and_omits_false_image_capability() {
1366 let tmpdir = TempDir::new().unwrap();
1367 let _home = IsolatedHome::new(&tmpdir);
1368 let mut app = create_test_app_with_tmpdir(&tmpdir);
1369 crate::skills::install_system_skills(&app.skills_dir).unwrap();
1370
1371 let result = list_skills(&mut app, Some(""));
1372 let msg = result.message.unwrap();
1373 let core = msg.find("Core agentic").expect("core tier");
1374 let best = msg.find("/best-of-n").expect("best-of-n skill");
1375 let tooling = msg.find("Format & tooling").expect("tooling tier");
1376 let pdf = msg.find("/pdf").expect("pdf skill");
1377
1378 assert!(core < best && best < tooling && tooling < pdf, "got: {msg}");
1379 assert!(
1380 !msg.contains("/imagine"),
1381 "catalog must not advertise an unavailable image-generation tool: {msg}"
1382 );
1383 }
1384
1385 #[test]
1386 fn test_list_skills_merges_workspace_and_configured_dirs() {
1387 let tmpdir = TempDir::new().unwrap();
1388 let _home = IsolatedHome::new(&tmpdir);
1389 let workspace_skill_dir = tmpdir
1390 .path()
1391 .join(".agents")
1392 .join("skills")
1393 .join("workspace-skill");
1394 std::fs::create_dir_all(&workspace_skill_dir).unwrap();
1395 std::fs::write(
1396 workspace_skill_dir.join("SKILL.md"),
1397 "---\nname: workspace-skill\ndescription: Workspace skill\n---\nDo workspace work",
1398 )
1399 .unwrap();
1400 create_skill_dir(
1401 &tmpdir,
1402 "configured-skill",
1403 "---\nname: configured-skill\ndescription: Configured skill\n---\nDo configured work",
1404 );
1405
1406 let mut app = create_test_app_with_tmpdir(&tmpdir);
1407 let result = list_skills(&mut app, Some(""));
1408 let msg = result.message.unwrap();
1409
1410 assert!(msg.contains("/workspace-skill"), "got: {msg}");
1411 assert!(msg.contains("/configured-skill"), "got: {msg}");
1412 }
1413
1414 #[test]
1415 fn test_skills_inspect_reports_discovery_details_and_source_paths() {
1416 let tmpdir = TempDir::new().unwrap();
1417 let _home = IsolatedHome::new(&tmpdir);
1418 let workspace_skill_dir = tmpdir
1419 .path()
1420 .join(".agents")
1421 .join("skills")
1422 .join("workspace-skill");
1423 std::fs::create_dir_all(&workspace_skill_dir).unwrap();
1424 std::fs::write(
1425 workspace_skill_dir.join("SKILL.md"),
1426 "---\nname: workspace-skill\ndescription: Workspace skill\n---\nDo workspace work",
1427 )
1428 .unwrap();
1429 create_skill_dir(
1430 &tmpdir,
1431 "configured-skill",
1432 "---\nname: configured-skill\ndescription: Configured skill\n---\nDo configured work",
1433 );
1434
1435 let mut app = create_test_app_with_tmpdir(&tmpdir);
1436 let result = list_skills(&mut app, Some("inspect"));
1437 let msg = result.message.expect("inspect should return a message");
1438
1439 let normalized = msg.replace('\\', "/");
1440 assert!(normalized.contains("Skills Inspect"), "got: {msg}");
1441 assert!(
1442 normalized.contains("Discovery mode: compatible"),
1443 "got: {msg}"
1444 );
1445 assert!(normalized.contains("Searched directories"), "got: {msg}");
1446 assert!(normalized.contains(".agents/skills"), "got: {msg}");
1447 assert!(normalized.contains("skills"), "got: {msg}");
1448 assert!(normalized.contains("Available skills (2):"), "got: {msg}");
1449 assert!(normalized.contains("workspace-skill"), "got: {msg}");
1450 assert!(normalized.contains("configured-skill"), "got: {msg}");
1451 assert!(normalized.contains("path:"), "got: {msg}");
1452 }
1453
1454 #[test]
1455 fn test_list_skills_respects_codewhale_only_scan() {
1456 let tmpdir = TempDir::new().unwrap();
1457 let _home = IsolatedHome::new(&tmpdir);
1458 let claude_skill_dir = tmpdir
1459 .path()
1460 .join(".claude")
1461 .join("skills")
1462 .join("claude-skill");
1463 std::fs::create_dir_all(&claude_skill_dir).unwrap();
1464 std::fs::write(
1465 claude_skill_dir.join("SKILL.md"),
1466 "---\nname: claude-skill\ndescription: Claude skill\n---\nbody",
1467 )
1468 .unwrap();
1469 let codewhale_skill_dir = tmpdir
1470 .path()
1471 .join(".codewhale")
1472 .join("skills")
1473 .join("codewhale-skill");
1474 std::fs::create_dir_all(&codewhale_skill_dir).unwrap();
1475 std::fs::write(
1476 codewhale_skill_dir.join("SKILL.md"),
1477 "---\nname: codewhale-skill\ndescription: CodeWhale skill\n---\nbody",
1478 )
1479 .unwrap();
1480
1481 let mut app = create_test_app_with_tmpdir(&tmpdir);
1482 app.skills_dir = tmpdir.path().join(".codewhale").join("skills");
1483 app.skills_scan_codewhale_only = true;
1484 let result = list_skills(&mut app, Some(""));
1485 let msg = result.message.unwrap();
1486
1487 assert!(msg.contains("/codewhale-skill"), "got: {msg}");
1488 assert!(!msg.contains("/claude-skill"), "got: {msg}");
1489 }
1490
1491 #[test]
1492 fn test_skills_inspect_reports_codewhale_only_scan_mode() {
1493 let tmpdir = TempDir::new().unwrap();
1494 let _home = IsolatedHome::new(&tmpdir);
1495 let claude_skill_dir = tmpdir
1496 .path()
1497 .join(".claude")
1498 .join("skills")
1499 .join("claude-skill");
1500 std::fs::create_dir_all(&claude_skill_dir).unwrap();
1501 std::fs::write(
1502 claude_skill_dir.join("SKILL.md"),
1503 "---\nname: claude-skill\ndescription: Claude skill\n---\nbody",
1504 )
1505 .unwrap();
1506 let codewhale_skill_dir = tmpdir
1507 .path()
1508 .join(".codewhale")
1509 .join("skills")
1510 .join("codewhale-skill");
1511 std::fs::create_dir_all(&codewhale_skill_dir).unwrap();
1512 std::fs::write(
1513 codewhale_skill_dir.join("SKILL.md"),
1514 "---\nname: codewhale-skill\ndescription: CodeWhale skill\n---\nbody",
1515 )
1516 .unwrap();
1517
1518 let mut app = create_test_app_with_tmpdir(&tmpdir);
1519 app.skills_dir = tmpdir.path().join(".codewhale").join("skills");
1520 app.skills_scan_codewhale_only = true;
1521 let result = list_skills(&mut app, Some("--inspect"));
1522 let msg = result.message.expect("inspect should return a message");
1523
1524 let normalized = msg.replace('\\', "/");
1525 assert!(
1526 normalized.contains("Discovery mode: codewhale-only"),
1527 "got: {msg}"
1528 );
1529 assert!(normalized.contains("codewhale-skill"), "got: {msg}");
1530 assert!(!normalized.contains("claude-skill"), "got: {msg}");
1531 assert!(!normalized.contains(".claude/skills"), "got: {msg}");
1532 }
1533
1534 #[test]
1535 fn test_skill_subcommand_dispatch_install_usage() {
1536 let tmpdir = TempDir::new().unwrap();
1537 let _home = IsolatedHome::new(&tmpdir);
1538 let mut app = create_test_app_with_tmpdir(&tmpdir);
1539 // Empty install spec → usage hint, not invalid-source error.
1540 let result = run_skill(&mut app, Some("install"));
1541 let msg = result.message.unwrap();
1542 assert!(msg.contains("/skill install"), "got: {msg}");
1543 }
1544
1545 #[test]
1546 fn test_skill_subcommand_dispatch_uninstall_missing() {
1547 let tmpdir = TempDir::new().unwrap();
1548 let _home = IsolatedHome::new(&tmpdir);
1549 let mut app = create_test_app_with_tmpdir(&tmpdir);
1550 let result = run_skill(&mut app, Some("uninstall absent-skill"));
1551 let msg = result.message.unwrap();
1552 assert!(
1553 msg.contains("not found") || msg.contains("not installed"),
1554 "got: {msg}"
1555 );
1556 }
1557
1558 #[test]
1559 fn test_skill_trust_message_marks_marker_advisory() {
1560 let tmpdir = TempDir::new().unwrap();
1561 let _home = IsolatedHome::new(&tmpdir);
1562 // Mutations only touch CodeWhale-owned roots; place under project scope.
1563 let skill_dir = tmpdir
1564 .path()
1565 .join(".codewhale")
1566 .join("skills")
1567 .join("trusted-skill");
1568 std::fs::create_dir_all(&skill_dir).unwrap();
1569 std::fs::write(
1570 skill_dir.join("SKILL.md"),
1571 "---\nname: trusted-skill\ndescription: Trust copy\n---\nbody",
1572 )
1573 .unwrap();
1574 install::write_installed_from_v2(
1575 &skill_dir,
1576 "github:owner/repo",
1577 None,
1578 "src",
1579 "placeholder",
1580 "trusted-skill",
1581 )
1582 .unwrap();
1583
1584 let mut app = create_test_app_with_tmpdir(&tmpdir);
1585 let result = run_skill(&mut app, Some("trust --project trusted-skill"));
1586 assert!(!result.is_error, "got: {:?}", result.message);
1587 let msg = result.message.expect("trust result");
1588 assert!(msg.contains("advisory"), "got: {msg}");
1589 assert!(!msg.contains("may now invoke"), "got: {msg}");
1590 }
1591
1592 #[test]
1593 fn parse_scope_args_and_default_install_target_is_global() {
1594 use crate::skills::mutation::SkillTargetScope;
1595
1596 let (scope, rest) = parse_scope_args("github:o/r").unwrap();
1597 assert_eq!(scope, None);
1598 assert_eq!(rest, "github:o/r");
1599 // Bare install (no --project/--global) maps to the CodeWhale global root.
1600 assert_eq!(
1601 scope.unwrap_or(SkillTargetScope::Global),
1602 SkillTargetScope::Global
1603 );
1604
1605 let (scope, rest) = parse_scope_args("--project my-skill").unwrap();
1606 assert_eq!(scope, Some(SkillTargetScope::Project));
1607 assert_eq!(rest, "my-skill");
1608
1609 let (scope, rest) = parse_scope_args("--global my-skill").unwrap();
1610 assert_eq!(scope, Some(SkillTargetScope::Global));
1611 assert_eq!(rest, "my-skill");
1612
1613 assert!(parse_scope_args("--project --global x").is_err());
1614 }
1615
1616 #[test]
1617 fn uninstall_external_only_skill_refuses_write() {
1618 let tmpdir = TempDir::new().unwrap();
1619 let _home = IsolatedHome::new(&tmpdir);
1620 let ext = tmpdir
1621 .path()
1622 .join(".claude")
1623 .join("skills")
1624 .join("ext-only");
1625 std::fs::create_dir_all(&ext).unwrap();
1626 std::fs::write(
1627 ext.join("SKILL.md"),
1628 "---\nname: ext-only\ndescription: d\n---\nbody\n",
1629 )
1630 .unwrap();
1631 let sentinel = tmpdir
1632 .path()
1633 .join(".claude")
1634 .join("skills")
1635 .join("SENTINEL");
1636 std::fs::write(&sentinel, "keep").unwrap();
1637
1638 let mut app = create_test_app_with_tmpdir(&tmpdir);
1639 app.workspace = tmpdir.path().to_path_buf();
1640 let result = run_skill(&mut app, Some("uninstall ext-only"));
1641 assert!(result.is_error, "got: {:?}", result.message);
1642 let msg = result.message.unwrap_or_default();
1643 assert!(
1644 msg.contains("compatible external") || msg.contains("not found"),
1645 "got: {msg}"
1646 );
1647 assert_eq!(std::fs::read_to_string(&sentinel).unwrap(), "keep");
1648 assert!(ext.join("SKILL.md").is_file());
1649 }
1650
1651 #[test]
1652 fn test_run_skill_without_name() {
1653 let tmpdir = TempDir::new().unwrap();
1654 let _home = IsolatedHome::new(&tmpdir);
1655 let mut app = create_test_app_with_tmpdir(&tmpdir);
1656 let result = run_skill(&mut app, None);
1657 assert!(result.message.is_some());
1658 assert!(result.message.unwrap().contains("Usage: /skill"));
1659 }
1660
1661 #[test]
1662 fn test_run_skill_not_found() {
1663 let tmpdir = TempDir::new().unwrap();
1664 let _home = IsolatedHome::new(&tmpdir);
1665 let mut app = create_test_app_with_tmpdir(&tmpdir);
1666 let result = run_skill(&mut app, Some("nonexistent"));
1667 assert!(result.message.is_some());
1668 let msg = result.message.unwrap();
1669 assert!(msg.contains("not found"));
1670 }
1671
1672 #[test]
1673 fn test_run_skill_activates() {
1674 let tmpdir = TempDir::new().unwrap();
1675 let _home = IsolatedHome::new(&tmpdir);
1676 create_skill_dir(
1677 &tmpdir,
1678 "test-skill",
1679 "---\nname: test-skill\ndescription: A test skill\n---\nDo something special",
1680 );
1681 let mut app = create_test_app_with_tmpdir(&tmpdir);
1682 let result = run_skill(&mut app, Some("test-skill"));
1683 assert!(result.message.is_some());
1684 let msg = result.message.unwrap();
1685 assert!(msg.contains("Skill 'test-skill' activated"));
1686 assert!(msg.contains("A test skill"));
1687 assert!(app.active_skill.is_some());
1688 assert!(!app.history.is_empty());
1689 }
1690 }
1691
1691 lines RUST