Skip to main content

ninmu_commands/
lib.rs

1use std::collections::BTreeMap;
2use std::env;
3use std::fmt;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use ninmu_plugins::{PluginError, PluginLoadFailure, PluginManager, PluginSummary};
8use ninmu_runtime::{
9    compact_session, CompactionConfig, ConfigLoader, ConfigSource, McpOAuthConfig, McpServerConfig,
10    ScopedMcpServerConfig, Session,
11};
12use serde_json::{json, Value};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct CommandManifestEntry {
16    pub name: String,
17    pub source: CommandSource,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum CommandSource {
22    Builtin,
23    InternalOnly,
24    FeatureGated,
25}
26
27#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct CommandRegistry {
29    entries: Vec<CommandManifestEntry>,
30}
31
32impl CommandRegistry {
33    #[must_use]
34    pub fn new(entries: Vec<CommandManifestEntry>) -> Self {
35        Self { entries }
36    }
37
38    #[must_use]
39    pub fn entries(&self) -> &[CommandManifestEntry] {
40        &self.entries
41    }
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct SlashCommandSpec {
46    pub name: &'static str,
47    pub aliases: &'static [&'static str],
48    pub summary: &'static str,
49    pub argument_hint: Option<&'static str>,
50    pub resume_supported: bool,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum SkillSlashDispatch {
55    Local,
56    Invoke(String),
57}
58
59const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
60    SlashCommandSpec {
61        name: "help",
62        aliases: &[],
63        summary: "Show available slash commands",
64        argument_hint: None,
65        resume_supported: true,
66    },
67    SlashCommandSpec {
68        name: "status",
69        aliases: &[],
70        summary: "Show current session status",
71        argument_hint: None,
72        resume_supported: true,
73    },
74    SlashCommandSpec {
75        name: "sandbox",
76        aliases: &[],
77        summary: "Show sandbox isolation status",
78        argument_hint: None,
79        resume_supported: true,
80    },
81    SlashCommandSpec {
82        name: "compact",
83        aliases: &[],
84        summary: "Compact local session history",
85        argument_hint: None,
86        resume_supported: true,
87    },
88    SlashCommandSpec {
89        name: "model",
90        aliases: &[],
91        summary: "Show or switch the active model",
92        argument_hint: Some("[model]"),
93        resume_supported: false,
94    },
95    SlashCommandSpec {
96        name: "permissions",
97        aliases: &[],
98        summary: "Show or switch the active permission mode",
99        argument_hint: Some("[read-only|workspace-write|danger-full-access]"),
100        resume_supported: false,
101    },
102    SlashCommandSpec {
103        name: "clear",
104        aliases: &[],
105        summary: "Start a fresh local session",
106        argument_hint: Some("[--confirm]"),
107        resume_supported: true,
108    },
109    SlashCommandSpec {
110        name: "cost",
111        aliases: &[],
112        summary: "Show cumulative token usage for this session",
113        argument_hint: None,
114        resume_supported: true,
115    },
116    SlashCommandSpec {
117        name: "resume",
118        aliases: &[],
119        summary: "Load a saved session into the REPL",
120        argument_hint: Some("<session-path>"),
121        resume_supported: false,
122    },
123    SlashCommandSpec {
124        name: "config",
125        aliases: &[],
126        summary: "Inspect Claude config files or merged sections",
127        argument_hint: Some("[env|hooks|model|plugins]"),
128        resume_supported: true,
129    },
130    SlashCommandSpec {
131        name: "mcp",
132        aliases: &[],
133        summary: "Inspect configured MCP servers",
134        argument_hint: Some("[list|show <server>|help]"),
135        resume_supported: true,
136    },
137    SlashCommandSpec {
138        name: "memory",
139        aliases: &[],
140        summary: "Inspect loaded Claude instruction memory files",
141        argument_hint: None,
142        resume_supported: true,
143    },
144    SlashCommandSpec {
145        name: "init",
146        aliases: &[],
147        summary: "Create a starter CLAUDE.md for this repo",
148        argument_hint: None,
149        resume_supported: true,
150    },
151    SlashCommandSpec {
152        name: "diff",
153        aliases: &[],
154        summary: "Show git diff for current workspace changes",
155        argument_hint: None,
156        resume_supported: true,
157    },
158    SlashCommandSpec {
159        name: "version",
160        aliases: &[],
161        summary: "Show CLI version and build information",
162        argument_hint: None,
163        resume_supported: true,
164    },
165    SlashCommandSpec {
166        name: "bughunter",
167        aliases: &[],
168        summary: "Inspect the codebase for likely bugs",
169        argument_hint: Some("[scope]"),
170        resume_supported: false,
171    },
172    SlashCommandSpec {
173        name: "commit",
174        aliases: &[],
175        summary: "Generate a commit message and create a git commit",
176        argument_hint: None,
177        resume_supported: false,
178    },
179    SlashCommandSpec {
180        name: "pr",
181        aliases: &[],
182        summary: "Draft or create a pull request from the conversation",
183        argument_hint: Some("[context]"),
184        resume_supported: false,
185    },
186    SlashCommandSpec {
187        name: "issue",
188        aliases: &[],
189        summary: "Draft or create a GitHub issue from the conversation",
190        argument_hint: Some("[context]"),
191        resume_supported: false,
192    },
193    SlashCommandSpec {
194        name: "ultraplan",
195        aliases: &[],
196        summary: "Run a deep planning prompt with multi-step reasoning",
197        argument_hint: Some("[task]"),
198        resume_supported: false,
199    },
200    SlashCommandSpec {
201        name: "teleport",
202        aliases: &[],
203        summary: "Jump to a file or symbol by searching the workspace",
204        argument_hint: Some("<symbol-or-path>"),
205        resume_supported: false,
206    },
207    SlashCommandSpec {
208        name: "debug-tool-call",
209        aliases: &[],
210        summary: "Replay the last tool call with debug details",
211        argument_hint: None,
212        resume_supported: false,
213    },
214    SlashCommandSpec {
215        name: "export",
216        aliases: &[],
217        summary: "Export the current conversation to a file",
218        argument_hint: Some("[file]"),
219        resume_supported: true,
220    },
221    SlashCommandSpec {
222        name: "session",
223        aliases: &[],
224        summary: "List, switch, fork, or delete managed local sessions",
225        argument_hint: Some(
226            "[list|switch <session-id>|fork [branch-name]|delete <session-id> [--force]]",
227        ),
228        resume_supported: false,
229    },
230    SlashCommandSpec {
231        name: "plugin",
232        aliases: &["plugins", "marketplace"],
233        summary: "Manage Claw Code plugins",
234        argument_hint: Some(
235            "[list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]",
236        ),
237        resume_supported: false,
238    },
239    SlashCommandSpec {
240        name: "agents",
241        aliases: &[],
242        summary: "List configured agents",
243        argument_hint: Some("[list|help]"),
244        resume_supported: true,
245    },
246    SlashCommandSpec {
247        name: "skills",
248        aliases: &["skill"],
249        summary: "List, install, or invoke available skills",
250        argument_hint: Some("[list|install <path>|help|<skill> [args]]"),
251        resume_supported: true,
252    },
253    SlashCommandSpec {
254        name: "doctor",
255        aliases: &[],
256        summary: "Diagnose setup issues and environment health",
257        argument_hint: None,
258        resume_supported: true,
259    },
260    SlashCommandSpec {
261        name: "plan",
262        aliases: &[],
263        summary: "Toggle or inspect planning mode",
264        argument_hint: Some("[on|off]"),
265        resume_supported: true,
266    },
267    SlashCommandSpec {
268        name: "review",
269        aliases: &[],
270        summary: "Run a code review on current changes",
271        argument_hint: Some("[scope]"),
272        resume_supported: false,
273    },
274    SlashCommandSpec {
275        name: "tasks",
276        aliases: &[],
277        summary: "List and manage background tasks",
278        argument_hint: Some("[list|get <id>|stop <id>]"),
279        resume_supported: true,
280    },
281    SlashCommandSpec {
282        name: "theme",
283        aliases: &[],
284        summary: "Switch the terminal color theme",
285        argument_hint: Some("[theme-name]"),
286        resume_supported: true,
287    },
288    SlashCommandSpec {
289        name: "vim",
290        aliases: &[],
291        summary: "Toggle vim keybinding mode",
292        argument_hint: None,
293        resume_supported: true,
294    },
295    SlashCommandSpec {
296        name: "voice",
297        aliases: &[],
298        summary: "Toggle voice input mode",
299        argument_hint: Some("[on|off]"),
300        resume_supported: false,
301    },
302    SlashCommandSpec {
303        name: "upgrade",
304        aliases: &[],
305        summary: "Check for and install CLI updates",
306        argument_hint: None,
307        resume_supported: false,
308    },
309    SlashCommandSpec {
310        name: "usage",
311        aliases: &[],
312        summary: "Show detailed API usage statistics",
313        argument_hint: None,
314        resume_supported: true,
315    },
316    SlashCommandSpec {
317        name: "stats",
318        aliases: &[],
319        summary: "Show workspace and session statistics",
320        argument_hint: None,
321        resume_supported: true,
322    },
323    SlashCommandSpec {
324        name: "rename",
325        aliases: &[],
326        summary: "Rename the current session",
327        argument_hint: Some("<name>"),
328        resume_supported: false,
329    },
330    SlashCommandSpec {
331        name: "copy",
332        aliases: &[],
333        summary: "Copy conversation or output to clipboard",
334        argument_hint: Some("[last|all]"),
335        resume_supported: true,
336    },
337    SlashCommandSpec {
338        name: "share",
339        aliases: &[],
340        summary: "Share the current conversation",
341        argument_hint: None,
342        resume_supported: false,
343    },
344    SlashCommandSpec {
345        name: "feedback",
346        aliases: &[],
347        summary: "Submit feedback about the current session",
348        argument_hint: None,
349        resume_supported: false,
350    },
351    SlashCommandSpec {
352        name: "hooks",
353        aliases: &[],
354        summary: "List and manage lifecycle hooks",
355        argument_hint: Some("[list|run <hook>]"),
356        resume_supported: true,
357    },
358    SlashCommandSpec {
359        name: "files",
360        aliases: &[],
361        summary: "List files in the current context window",
362        argument_hint: None,
363        resume_supported: true,
364    },
365    SlashCommandSpec {
366        name: "context",
367        aliases: &[],
368        summary: "Inspect or manage the conversation context",
369        argument_hint: Some("[show|clear]"),
370        resume_supported: true,
371    },
372    SlashCommandSpec {
373        name: "color",
374        aliases: &[],
375        summary: "Configure terminal color settings",
376        argument_hint: Some("[scheme]"),
377        resume_supported: true,
378    },
379    SlashCommandSpec {
380        name: "effort",
381        aliases: &[],
382        summary: "Set the effort level for responses",
383        argument_hint: Some("[low|medium|high]"),
384        resume_supported: true,
385    },
386    SlashCommandSpec {
387        name: "fast",
388        aliases: &[],
389        summary: "Toggle fast/concise response mode",
390        argument_hint: None,
391        resume_supported: true,
392    },
393    SlashCommandSpec {
394        name: "exit",
395        aliases: &[],
396        summary: "Exit the REPL session",
397        argument_hint: None,
398        resume_supported: false,
399    },
400    SlashCommandSpec {
401        name: "branch",
402        aliases: &[],
403        summary: "Create or switch git branches",
404        argument_hint: Some("[name]"),
405        resume_supported: false,
406    },
407    SlashCommandSpec {
408        name: "rewind",
409        aliases: &[],
410        summary: "Rewind the conversation to a previous state",
411        argument_hint: Some("[steps]"),
412        resume_supported: false,
413    },
414    SlashCommandSpec {
415        name: "summary",
416        aliases: &[],
417        summary: "Generate a summary of the conversation",
418        argument_hint: None,
419        resume_supported: true,
420    },
421    SlashCommandSpec {
422        name: "desktop",
423        aliases: &[],
424        summary: "Open or manage the desktop app integration",
425        argument_hint: None,
426        resume_supported: false,
427    },
428    SlashCommandSpec {
429        name: "ide",
430        aliases: &[],
431        summary: "Open or configure IDE integration",
432        argument_hint: Some("[vscode|cursor]"),
433        resume_supported: false,
434    },
435    SlashCommandSpec {
436        name: "tag",
437        aliases: &[],
438        summary: "Tag the current conversation point",
439        argument_hint: Some("[label]"),
440        resume_supported: true,
441    },
442    SlashCommandSpec {
443        name: "brief",
444        aliases: &[],
445        summary: "Toggle brief output mode",
446        argument_hint: None,
447        resume_supported: true,
448    },
449    SlashCommandSpec {
450        name: "advisor",
451        aliases: &[],
452        summary: "Toggle advisor mode for guidance-only responses",
453        argument_hint: None,
454        resume_supported: true,
455    },
456    SlashCommandSpec {
457        name: "stickers",
458        aliases: &[],
459        summary: "Browse and manage sticker packs",
460        argument_hint: None,
461        resume_supported: true,
462    },
463    SlashCommandSpec {
464        name: "insights",
465        aliases: &[],
466        summary: "Show AI-generated insights about the session",
467        argument_hint: None,
468        resume_supported: true,
469    },
470    SlashCommandSpec {
471        name: "thinkback",
472        aliases: &[],
473        summary: "Replay the thinking process of the last response",
474        argument_hint: None,
475        resume_supported: true,
476    },
477    SlashCommandSpec {
478        name: "release-notes",
479        aliases: &[],
480        summary: "Generate release notes from recent changes",
481        argument_hint: None,
482        resume_supported: false,
483    },
484    SlashCommandSpec {
485        name: "security-review",
486        aliases: &[],
487        summary: "Run a security review on the codebase",
488        argument_hint: Some("[scope]"),
489        resume_supported: false,
490    },
491    SlashCommandSpec {
492        name: "keybindings",
493        aliases: &[],
494        summary: "Show or configure keyboard shortcuts",
495        argument_hint: None,
496        resume_supported: true,
497    },
498    SlashCommandSpec {
499        name: "privacy-settings",
500        aliases: &[],
501        summary: "View or modify privacy settings",
502        argument_hint: None,
503        resume_supported: true,
504    },
505    SlashCommandSpec {
506        name: "output-style",
507        aliases: &[],
508        summary: "Switch output formatting style",
509        argument_hint: Some("[style]"),
510        resume_supported: true,
511    },
512    SlashCommandSpec {
513        name: "add-dir",
514        aliases: &[],
515        summary: "Add an additional directory to the context",
516        argument_hint: Some("<path>"),
517        resume_supported: false,
518    },
519    SlashCommandSpec {
520        name: "allowed-tools",
521        aliases: &[],
522        summary: "Show or modify the allowed tools list",
523        argument_hint: Some("[add|remove|list] [tool]"),
524        resume_supported: true,
525    },
526    SlashCommandSpec {
527        name: "api-key",
528        aliases: &[],
529        summary: "Show or set the Anthropic API key",
530        argument_hint: Some("[key]"),
531        resume_supported: false,
532    },
533    SlashCommandSpec {
534        name: "approve",
535        aliases: &["yes", "y"],
536        summary: "Approve a pending tool execution",
537        argument_hint: None,
538        resume_supported: false,
539    },
540    SlashCommandSpec {
541        name: "deny",
542        aliases: &["no", "n"],
543        summary: "Deny a pending tool execution",
544        argument_hint: None,
545        resume_supported: false,
546    },
547    SlashCommandSpec {
548        name: "undo",
549        aliases: &[],
550        summary: "Undo the last file write or edit",
551        argument_hint: None,
552        resume_supported: false,
553    },
554    SlashCommandSpec {
555        name: "stop",
556        aliases: &[],
557        summary: "Stop the current generation",
558        argument_hint: None,
559        resume_supported: false,
560    },
561    SlashCommandSpec {
562        name: "retry",
563        aliases: &[],
564        summary: "Retry the last failed message",
565        argument_hint: None,
566        resume_supported: false,
567    },
568    SlashCommandSpec {
569        name: "paste",
570        aliases: &[],
571        summary: "Paste clipboard content as input",
572        argument_hint: None,
573        resume_supported: false,
574    },
575    SlashCommandSpec {
576        name: "screenshot",
577        aliases: &[],
578        summary: "Take a screenshot and add to conversation",
579        argument_hint: None,
580        resume_supported: false,
581    },
582    SlashCommandSpec {
583        name: "image",
584        aliases: &[],
585        summary: "Add an image file to the conversation",
586        argument_hint: Some("<path>"),
587        resume_supported: false,
588    },
589    SlashCommandSpec {
590        name: "terminal-setup",
591        aliases: &[],
592        summary: "Configure terminal integration settings",
593        argument_hint: None,
594        resume_supported: true,
595    },
596    SlashCommandSpec {
597        name: "search",
598        aliases: &[],
599        summary: "Search files in the workspace",
600        argument_hint: Some("<query>"),
601        resume_supported: false,
602    },
603    SlashCommandSpec {
604        name: "listen",
605        aliases: &[],
606        summary: "Listen for voice input",
607        argument_hint: None,
608        resume_supported: false,
609    },
610    SlashCommandSpec {
611        name: "speak",
612        aliases: &[],
613        summary: "Read the last response aloud",
614        argument_hint: None,
615        resume_supported: false,
616    },
617    SlashCommandSpec {
618        name: "language",
619        aliases: &[],
620        summary: "Set the interface language",
621        argument_hint: Some("[language]"),
622        resume_supported: true,
623    },
624    SlashCommandSpec {
625        name: "profile",
626        aliases: &[],
627        summary: "Show or switch user profile",
628        argument_hint: Some("[name]"),
629        resume_supported: false,
630    },
631    SlashCommandSpec {
632        name: "max-tokens",
633        aliases: &[],
634        summary: "Show or set the max output tokens",
635        argument_hint: Some("[count]"),
636        resume_supported: true,
637    },
638    SlashCommandSpec {
639        name: "temperature",
640        aliases: &[],
641        summary: "Show or set the sampling temperature",
642        argument_hint: Some("[value]"),
643        resume_supported: true,
644    },
645    SlashCommandSpec {
646        name: "system-prompt",
647        aliases: &[],
648        summary: "Show the active system prompt",
649        argument_hint: None,
650        resume_supported: true,
651    },
652    SlashCommandSpec {
653        name: "tool-details",
654        aliases: &[],
655        summary: "Show detailed info about a specific tool",
656        argument_hint: Some("<tool-name>"),
657        resume_supported: true,
658    },
659    SlashCommandSpec {
660        name: "format",
661        aliases: &[],
662        summary: "Format the last response in a different style",
663        argument_hint: Some("[markdown|plain|json]"),
664        resume_supported: false,
665    },
666    SlashCommandSpec {
667        name: "pin",
668        aliases: &[],
669        summary: "Pin a message to persist across compaction",
670        argument_hint: Some("[message-index]"),
671        resume_supported: false,
672    },
673    SlashCommandSpec {
674        name: "unpin",
675        aliases: &[],
676        summary: "Unpin a previously pinned message",
677        argument_hint: Some("[message-index]"),
678        resume_supported: false,
679    },
680    SlashCommandSpec {
681        name: "bookmarks",
682        aliases: &[],
683        summary: "List or manage conversation bookmarks",
684        argument_hint: Some("[add|remove|list]"),
685        resume_supported: true,
686    },
687    SlashCommandSpec {
688        name: "workspace",
689        aliases: &["cwd"],
690        summary: "Show or change the working directory",
691        argument_hint: Some("[path]"),
692        resume_supported: true,
693    },
694    SlashCommandSpec {
695        name: "history",
696        aliases: &[],
697        summary: "Show conversation history summary",
698        argument_hint: Some("[count]"),
699        resume_supported: true,
700    },
701    SlashCommandSpec {
702        name: "tokens",
703        aliases: &[],
704        summary: "Show token count for the current conversation",
705        argument_hint: None,
706        resume_supported: true,
707    },
708    SlashCommandSpec {
709        name: "cache",
710        aliases: &[],
711        summary: "Show prompt cache statistics",
712        argument_hint: None,
713        resume_supported: true,
714    },
715    SlashCommandSpec {
716        name: "providers",
717        aliases: &[],
718        summary: "List available model providers",
719        argument_hint: None,
720        resume_supported: true,
721    },
722    SlashCommandSpec {
723        name: "notifications",
724        aliases: &[],
725        summary: "Show or configure notification settings",
726        argument_hint: Some("[on|off|status]"),
727        resume_supported: true,
728    },
729    SlashCommandSpec {
730        name: "changelog",
731        aliases: &[],
732        summary: "Show recent changes to the codebase",
733        argument_hint: Some("[count]"),
734        resume_supported: true,
735    },
736    SlashCommandSpec {
737        name: "test",
738        aliases: &[],
739        summary: "Run tests for the current project",
740        argument_hint: Some("[filter]"),
741        resume_supported: false,
742    },
743    SlashCommandSpec {
744        name: "lint",
745        aliases: &[],
746        summary: "Run linting for the current project",
747        argument_hint: Some("[filter]"),
748        resume_supported: false,
749    },
750    SlashCommandSpec {
751        name: "build",
752        aliases: &[],
753        summary: "Build the current project",
754        argument_hint: Some("[target]"),
755        resume_supported: false,
756    },
757    SlashCommandSpec {
758        name: "run",
759        aliases: &[],
760        summary: "Run a command in the project context",
761        argument_hint: Some("<command>"),
762        resume_supported: false,
763    },
764    SlashCommandSpec {
765        name: "git",
766        aliases: &[],
767        summary: "Run a git command in the workspace",
768        argument_hint: Some("<subcommand>"),
769        resume_supported: false,
770    },
771    SlashCommandSpec {
772        name: "stash",
773        aliases: &[],
774        summary: "Stash or unstash workspace changes",
775        argument_hint: Some("[pop|list|apply]"),
776        resume_supported: false,
777    },
778    SlashCommandSpec {
779        name: "blame",
780        aliases: &[],
781        summary: "Show git blame for a file",
782        argument_hint: Some("<file> [line]"),
783        resume_supported: true,
784    },
785    SlashCommandSpec {
786        name: "log",
787        aliases: &[],
788        summary: "Show git log for the workspace",
789        argument_hint: Some("[count]"),
790        resume_supported: true,
791    },
792    SlashCommandSpec {
793        name: "cron",
794        aliases: &[],
795        summary: "Manage scheduled tasks",
796        argument_hint: Some("[list|add|remove]"),
797        resume_supported: true,
798    },
799    SlashCommandSpec {
800        name: "team",
801        aliases: &[],
802        summary: "Manage agent teams",
803        argument_hint: Some("[list|create|delete]"),
804        resume_supported: true,
805    },
806    SlashCommandSpec {
807        name: "benchmark",
808        aliases: &[],
809        summary: "Run performance benchmarks",
810        argument_hint: Some("[suite]"),
811        resume_supported: false,
812    },
813    SlashCommandSpec {
814        name: "migrate",
815        aliases: &[],
816        summary: "Run pending data migrations",
817        argument_hint: None,
818        resume_supported: false,
819    },
820    SlashCommandSpec {
821        name: "reset",
822        aliases: &[],
823        summary: "Reset configuration to defaults",
824        argument_hint: Some("[section]"),
825        resume_supported: false,
826    },
827    SlashCommandSpec {
828        name: "telemetry",
829        aliases: &[],
830        summary: "Show or configure telemetry settings",
831        argument_hint: Some("[on|off|status]"),
832        resume_supported: true,
833    },
834    SlashCommandSpec {
835        name: "env",
836        aliases: &[],
837        summary: "Show environment variables visible to tools",
838        argument_hint: None,
839        resume_supported: true,
840    },
841    SlashCommandSpec {
842        name: "project",
843        aliases: &[],
844        summary: "Show project detection info",
845        argument_hint: None,
846        resume_supported: true,
847    },
848    SlashCommandSpec {
849        name: "templates",
850        aliases: &[],
851        summary: "List or apply prompt templates",
852        argument_hint: Some("[list|apply <name>]"),
853        resume_supported: false,
854    },
855    SlashCommandSpec {
856        name: "explain",
857        aliases: &[],
858        summary: "Explain a file or code snippet",
859        argument_hint: Some("<path> [line-range]"),
860        resume_supported: false,
861    },
862    SlashCommandSpec {
863        name: "refactor",
864        aliases: &[],
865        summary: "Suggest refactoring for a file or function",
866        argument_hint: Some("<path> [scope]"),
867        resume_supported: false,
868    },
869    SlashCommandSpec {
870        name: "docs",
871        aliases: &[],
872        summary: "Generate or show documentation",
873        argument_hint: Some("[path]"),
874        resume_supported: false,
875    },
876    SlashCommandSpec {
877        name: "fix",
878        aliases: &[],
879        summary: "Fix errors in a file or project",
880        argument_hint: Some("[path]"),
881        resume_supported: false,
882    },
883    SlashCommandSpec {
884        name: "perf",
885        aliases: &[],
886        summary: "Analyze performance of a function or file",
887        argument_hint: Some("<path>"),
888        resume_supported: false,
889    },
890    SlashCommandSpec {
891        name: "chat",
892        aliases: &[],
893        summary: "Switch to free-form chat mode",
894        argument_hint: None,
895        resume_supported: false,
896    },
897    SlashCommandSpec {
898        name: "focus",
899        aliases: &[],
900        summary: "Focus context on specific files or directories",
901        argument_hint: Some("<path> [path...]"),
902        resume_supported: false,
903    },
904    SlashCommandSpec {
905        name: "unfocus",
906        aliases: &[],
907        summary: "Remove focus from files or directories",
908        argument_hint: Some("[path...]"),
909        resume_supported: false,
910    },
911    SlashCommandSpec {
912        name: "web",
913        aliases: &[],
914        summary: "Fetch and summarize a web page",
915        argument_hint: Some("<url>"),
916        resume_supported: false,
917    },
918    SlashCommandSpec {
919        name: "map",
920        aliases: &[],
921        summary: "Show a visual map of the codebase structure",
922        argument_hint: Some("[depth]"),
923        resume_supported: true,
924    },
925    SlashCommandSpec {
926        name: "symbols",
927        aliases: &[],
928        summary: "List symbols (functions, classes, etc.) in a file",
929        argument_hint: Some("<path>"),
930        resume_supported: true,
931    },
932    SlashCommandSpec {
933        name: "references",
934        aliases: &[],
935        summary: "Find all references to a symbol",
936        argument_hint: Some("<symbol>"),
937        resume_supported: false,
938    },
939    SlashCommandSpec {
940        name: "definition",
941        aliases: &[],
942        summary: "Go to the definition of a symbol",
943        argument_hint: Some("<symbol>"),
944        resume_supported: false,
945    },
946    SlashCommandSpec {
947        name: "hover",
948        aliases: &[],
949        summary: "Show hover information for a symbol",
950        argument_hint: Some("<symbol>"),
951        resume_supported: true,
952    },
953    SlashCommandSpec {
954        name: "diagnostics",
955        aliases: &[],
956        summary: "Show LSP diagnostics for a file",
957        argument_hint: Some("[path]"),
958        resume_supported: true,
959    },
960    SlashCommandSpec {
961        name: "autofix",
962        aliases: &[],
963        summary: "Auto-fix all fixable diagnostics",
964        argument_hint: Some("[path]"),
965        resume_supported: false,
966    },
967    SlashCommandSpec {
968        name: "multi",
969        aliases: &[],
970        summary: "Execute multiple slash commands in sequence",
971        argument_hint: Some("<commands>"),
972        resume_supported: false,
973    },
974    SlashCommandSpec {
975        name: "macro",
976        aliases: &[],
977        summary: "Record or replay command macros",
978        argument_hint: Some("[record|stop|play <name>]"),
979        resume_supported: false,
980    },
981    SlashCommandSpec {
982        name: "alias",
983        aliases: &[],
984        summary: "Create a command alias",
985        argument_hint: Some("<name> <command>"),
986        resume_supported: true,
987    },
988    SlashCommandSpec {
989        name: "parallel",
990        aliases: &[],
991        summary: "Run commands in parallel subagents",
992        argument_hint: Some("<count> <prompt>"),
993        resume_supported: false,
994    },
995    SlashCommandSpec {
996        name: "agent",
997        aliases: &[],
998        summary: "Manage sub-agents and spawned sessions",
999        argument_hint: Some("[list|spawn|kill]"),
1000        resume_supported: true,
1001    },
1002    SlashCommandSpec {
1003        name: "subagent",
1004        aliases: &[],
1005        summary: "Control active subagent execution",
1006        argument_hint: Some("[list|steer <target> <msg>|kill <id>]"),
1007        resume_supported: true,
1008    },
1009    SlashCommandSpec {
1010        name: "reasoning",
1011        aliases: &[],
1012        summary: "Toggle extended reasoning mode",
1013        argument_hint: Some("[on|off|stream]"),
1014        resume_supported: true,
1015    },
1016    SlashCommandSpec {
1017        name: "budget",
1018        aliases: &[],
1019        summary: "Show or set token budget limits",
1020        argument_hint: Some("[show|set <limit>]"),
1021        resume_supported: true,
1022    },
1023    SlashCommandSpec {
1024        name: "rate-limit",
1025        aliases: &[],
1026        summary: "Configure API rate limiting",
1027        argument_hint: Some("[status|set <rpm>]"),
1028        resume_supported: true,
1029    },
1030    SlashCommandSpec {
1031        name: "metrics",
1032        aliases: &[],
1033        summary: "Show performance and usage metrics",
1034        argument_hint: None,
1035        resume_supported: true,
1036    },
1037];
1038
1039#[derive(Debug, Clone, PartialEq, Eq)]
1040pub enum SlashCommand {
1041    Help,
1042    Status,
1043    Sandbox,
1044    Compact,
1045    Bughunter {
1046        scope: Option<String>,
1047    },
1048    Commit,
1049    Pr {
1050        context: Option<String>,
1051    },
1052    Issue {
1053        context: Option<String>,
1054    },
1055    Ultraplan {
1056        task: Option<String>,
1057    },
1058    Teleport {
1059        target: Option<String>,
1060    },
1061    DebugToolCall,
1062    Model {
1063        model: Option<String>,
1064    },
1065    Permissions {
1066        mode: Option<String>,
1067    },
1068    Clear {
1069        confirm: bool,
1070    },
1071    Cost,
1072    Resume {
1073        session_path: Option<String>,
1074    },
1075    Config {
1076        section: Option<String>,
1077    },
1078    Mcp {
1079        action: Option<String>,
1080        target: Option<String>,
1081    },
1082    Memory,
1083    Init,
1084    Diff,
1085    Version,
1086    Export {
1087        path: Option<String>,
1088    },
1089    Session {
1090        action: Option<String>,
1091        target: Option<String>,
1092    },
1093    Plugins {
1094        action: Option<String>,
1095        target: Option<String>,
1096    },
1097    Agents {
1098        args: Option<String>,
1099    },
1100    Skills {
1101        args: Option<String>,
1102    },
1103    Doctor,
1104    Login,
1105    Logout,
1106    Vim,
1107    Upgrade,
1108    Stats,
1109    Share,
1110    Feedback,
1111    Files,
1112    Fast,
1113    Exit,
1114    Summary,
1115    Desktop,
1116    Brief,
1117    Advisor,
1118    Stickers,
1119    Insights,
1120    Thinkback,
1121    ReleaseNotes,
1122    SecurityReview,
1123    Keybindings,
1124    PrivacySettings,
1125    Plan {
1126        mode: Option<String>,
1127    },
1128    Review {
1129        scope: Option<String>,
1130    },
1131    Tasks {
1132        args: Option<String>,
1133    },
1134    Theme {
1135        name: Option<String>,
1136    },
1137    Voice {
1138        mode: Option<String>,
1139    },
1140    Usage {
1141        scope: Option<String>,
1142    },
1143    Rename {
1144        name: Option<String>,
1145    },
1146    Copy {
1147        target: Option<String>,
1148    },
1149    Hooks {
1150        args: Option<String>,
1151    },
1152    Context {
1153        action: Option<String>,
1154    },
1155    Color {
1156        scheme: Option<String>,
1157    },
1158    Effort {
1159        level: Option<String>,
1160    },
1161    Branch {
1162        name: Option<String>,
1163    },
1164    Rewind {
1165        steps: Option<String>,
1166    },
1167    Ide {
1168        target: Option<String>,
1169    },
1170    Tag {
1171        label: Option<String>,
1172    },
1173    OutputStyle {
1174        style: Option<String>,
1175    },
1176    AddDir {
1177        path: Option<String>,
1178    },
1179    History {
1180        count: Option<String>,
1181    },
1182    Unknown(String),
1183}
1184
1185#[derive(Debug, Clone, PartialEq, Eq)]
1186pub struct SlashCommandParseError {
1187    message: String,
1188}
1189
1190impl SlashCommandParseError {
1191    fn new(message: impl Into<String>) -> Self {
1192        Self {
1193            message: message.into(),
1194        }
1195    }
1196}
1197
1198impl fmt::Display for SlashCommandParseError {
1199    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1200        f.write_str(&self.message)
1201    }
1202}
1203
1204impl std::error::Error for SlashCommandParseError {}
1205
1206impl SlashCommand {
1207    pub fn parse(input: &str) -> Result<Option<Self>, SlashCommandParseError> {
1208        validate_slash_command_input(input)
1209    }
1210
1211    /// Returns the canonical slash-command name (e.g. `"/branch"`) for use in
1212    /// error messages and logging. Derived from the spec table so it always
1213    /// matches what the user would have typed.
1214    #[must_use]
1215    pub fn slash_name(&self) -> &'static str {
1216        match self {
1217            Self::Help => "/help",
1218            Self::Clear { .. } => "/clear",
1219            Self::Compact { .. } => "/compact",
1220            Self::Cost => "/cost",
1221            Self::Doctor => "/doctor",
1222            Self::Config { .. } => "/config",
1223            Self::Memory { .. } => "/memory",
1224            Self::History { .. } => "/history",
1225            Self::Diff => "/diff",
1226            Self::Status => "/status",
1227            Self::Stats => "/stats",
1228            Self::Version => "/version",
1229            Self::Commit { .. } => "/commit",
1230            Self::Pr { .. } => "/pr",
1231            Self::Issue { .. } => "/issue",
1232            Self::Init => "/init",
1233            Self::Bughunter { .. } => "/bughunter",
1234            Self::Ultraplan { .. } => "/ultraplan",
1235            Self::Teleport { .. } => "/teleport",
1236            Self::DebugToolCall { .. } => "/debug-tool-call",
1237            Self::Resume { .. } => "/resume",
1238            Self::Model { .. } => "/model",
1239            Self::Permissions { .. } => "/permissions",
1240            Self::Session { .. } => "/session",
1241            Self::Plugins { .. } => "/plugins",
1242            Self::Login => "/login",
1243            Self::Logout => "/logout",
1244            Self::Vim => "/vim",
1245            Self::Upgrade => "/upgrade",
1246            Self::Share => "/share",
1247            Self::Feedback => "/feedback",
1248            Self::Files => "/files",
1249            Self::Fast => "/fast",
1250            Self::Exit => "/exit",
1251            Self::Summary => "/summary",
1252            Self::Desktop => "/desktop",
1253            Self::Brief => "/brief",
1254            Self::Advisor => "/advisor",
1255            Self::Stickers => "/stickers",
1256            Self::Insights => "/insights",
1257            Self::Thinkback => "/thinkback",
1258            Self::ReleaseNotes => "/release-notes",
1259            Self::SecurityReview => "/security-review",
1260            Self::Keybindings => "/keybindings",
1261            Self::PrivacySettings => "/privacy-settings",
1262            Self::Plan { .. } => "/plan",
1263            Self::Review { .. } => "/review",
1264            Self::Tasks { .. } => "/tasks",
1265            Self::Theme { .. } => "/theme",
1266            Self::Voice { .. } => "/voice",
1267            Self::Usage { .. } => "/usage",
1268            Self::Rename { .. } => "/rename",
1269            Self::Copy { .. } => "/copy",
1270            Self::Hooks { .. } => "/hooks",
1271            Self::Context { .. } => "/context",
1272            Self::Color { .. } => "/color",
1273            Self::Effort { .. } => "/effort",
1274            Self::Branch { .. } => "/branch",
1275            Self::Rewind { .. } => "/rewind",
1276            Self::Ide { .. } => "/ide",
1277            Self::Tag { .. } => "/tag",
1278            Self::OutputStyle { .. } => "/output-style",
1279            Self::AddDir { .. } => "/add-dir",
1280            Self::Sandbox => "/sandbox",
1281            Self::Mcp { .. } => "/mcp",
1282            Self::Export { .. } => "/export",
1283            #[allow(unreachable_patterns)]
1284            _ => "/unknown",
1285        }
1286    }
1287}
1288
1289#[allow(clippy::too_many_lines)]
1290pub fn validate_slash_command_input(
1291    input: &str,
1292) -> Result<Option<SlashCommand>, SlashCommandParseError> {
1293    let trimmed = input.trim();
1294    if !trimmed.starts_with('/') {
1295        return Ok(None);
1296    }
1297
1298    let mut parts = trimmed.trim_start_matches('/').split_whitespace();
1299    let command = parts.next().unwrap_or_default();
1300    if command.is_empty() {
1301        return Err(SlashCommandParseError::new(
1302            "Slash command name is missing. Use /help to list available slash commands.",
1303        ));
1304    }
1305
1306    let args = parts.collect::<Vec<_>>();
1307    let remainder = remainder_after_command(trimmed, command);
1308
1309    Ok(Some(match command {
1310        "help" => {
1311            validate_no_args(command, &args)?;
1312            SlashCommand::Help
1313        }
1314        "status" => {
1315            validate_no_args(command, &args)?;
1316            SlashCommand::Status
1317        }
1318        "sandbox" => {
1319            validate_no_args(command, &args)?;
1320            SlashCommand::Sandbox
1321        }
1322        "compact" => {
1323            validate_no_args(command, &args)?;
1324            SlashCommand::Compact
1325        }
1326        "bughunter" => SlashCommand::Bughunter { scope: remainder },
1327        "commit" => {
1328            validate_no_args(command, &args)?;
1329            SlashCommand::Commit
1330        }
1331        "pr" => SlashCommand::Pr { context: remainder },
1332        "issue" => SlashCommand::Issue { context: remainder },
1333        "ultraplan" => SlashCommand::Ultraplan { task: remainder },
1334        "teleport" => SlashCommand::Teleport {
1335            target: Some(require_remainder(command, remainder, "<symbol-or-path>")?),
1336        },
1337        "debug-tool-call" => {
1338            validate_no_args(command, &args)?;
1339            SlashCommand::DebugToolCall
1340        }
1341        "model" => SlashCommand::Model {
1342            model: optional_single_arg(command, &args, "[model]")?,
1343        },
1344        "permissions" => SlashCommand::Permissions {
1345            mode: parse_permissions_mode(&args)?,
1346        },
1347        "clear" => SlashCommand::Clear {
1348            confirm: parse_clear_args(&args)?,
1349        },
1350        "cost" => {
1351            validate_no_args(command, &args)?;
1352            SlashCommand::Cost
1353        }
1354        "resume" => SlashCommand::Resume {
1355            session_path: Some(require_remainder(command, remainder, "<session-path>")?),
1356        },
1357        "config" => SlashCommand::Config {
1358            section: parse_config_section(&args)?,
1359        },
1360        "mcp" => parse_mcp_command(&args)?,
1361        "memory" => {
1362            validate_no_args(command, &args)?;
1363            SlashCommand::Memory
1364        }
1365        "init" => {
1366            validate_no_args(command, &args)?;
1367            SlashCommand::Init
1368        }
1369        "diff" => {
1370            validate_no_args(command, &args)?;
1371            SlashCommand::Diff
1372        }
1373        "version" => {
1374            validate_no_args(command, &args)?;
1375            SlashCommand::Version
1376        }
1377        "export" => SlashCommand::Export { path: remainder },
1378        "session" => parse_session_command(&args)?,
1379        "plugin" | "plugins" | "marketplace" => parse_plugin_command(&args)?,
1380        "agents" => SlashCommand::Agents {
1381            args: parse_list_or_help_args(command, remainder)?,
1382        },
1383        "skills" | "skill" => SlashCommand::Skills {
1384            args: parse_skills_args(remainder.as_deref())?,
1385        },
1386        "doctor" | "providers" => {
1387            validate_no_args(command, &args)?;
1388            SlashCommand::Doctor
1389        }
1390        "login" | "logout" => {
1391            return Err(command_error(
1392                "This auth flow was removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead.",
1393                command,
1394                "",
1395            ));
1396        }
1397        "vim" => {
1398            validate_no_args(command, &args)?;
1399            SlashCommand::Vim
1400        }
1401        "upgrade" => {
1402            validate_no_args(command, &args)?;
1403            SlashCommand::Upgrade
1404        }
1405        "stats" | "tokens" | "cache" => {
1406            validate_no_args(command, &args)?;
1407            SlashCommand::Stats
1408        }
1409        "share" => {
1410            validate_no_args(command, &args)?;
1411            SlashCommand::Share
1412        }
1413        "feedback" => {
1414            validate_no_args(command, &args)?;
1415            SlashCommand::Feedback
1416        }
1417        "files" => {
1418            validate_no_args(command, &args)?;
1419            SlashCommand::Files
1420        }
1421        "fast" => {
1422            validate_no_args(command, &args)?;
1423            SlashCommand::Fast
1424        }
1425        "exit" => {
1426            validate_no_args(command, &args)?;
1427            SlashCommand::Exit
1428        }
1429        "summary" => {
1430            validate_no_args(command, &args)?;
1431            SlashCommand::Summary
1432        }
1433        "desktop" => {
1434            validate_no_args(command, &args)?;
1435            SlashCommand::Desktop
1436        }
1437        "brief" => {
1438            validate_no_args(command, &args)?;
1439            SlashCommand::Brief
1440        }
1441        "advisor" => {
1442            validate_no_args(command, &args)?;
1443            SlashCommand::Advisor
1444        }
1445        "stickers" => {
1446            validate_no_args(command, &args)?;
1447            SlashCommand::Stickers
1448        }
1449        "insights" => {
1450            validate_no_args(command, &args)?;
1451            SlashCommand::Insights
1452        }
1453        "thinkback" => {
1454            validate_no_args(command, &args)?;
1455            SlashCommand::Thinkback
1456        }
1457        "release-notes" => {
1458            validate_no_args(command, &args)?;
1459            SlashCommand::ReleaseNotes
1460        }
1461        "security-review" => {
1462            validate_no_args(command, &args)?;
1463            SlashCommand::SecurityReview
1464        }
1465        "keybindings" => {
1466            validate_no_args(command, &args)?;
1467            SlashCommand::Keybindings
1468        }
1469        "privacy-settings" => {
1470            validate_no_args(command, &args)?;
1471            SlashCommand::PrivacySettings
1472        }
1473        "plan" => SlashCommand::Plan { mode: remainder },
1474        "review" => SlashCommand::Review { scope: remainder },
1475        "tasks" => SlashCommand::Tasks { args: remainder },
1476        "theme" => SlashCommand::Theme { name: remainder },
1477        "voice" => SlashCommand::Voice { mode: remainder },
1478        "usage" => SlashCommand::Usage { scope: remainder },
1479        "rename" => SlashCommand::Rename { name: remainder },
1480        "copy" => SlashCommand::Copy { target: remainder },
1481        "hooks" => SlashCommand::Hooks { args: remainder },
1482        "context" => SlashCommand::Context { action: remainder },
1483        "color" => SlashCommand::Color { scheme: remainder },
1484        "effort" => SlashCommand::Effort { level: remainder },
1485        "branch" => SlashCommand::Branch { name: remainder },
1486        "rewind" => SlashCommand::Rewind { steps: remainder },
1487        "ide" => SlashCommand::Ide { target: remainder },
1488        "tag" => SlashCommand::Tag { label: remainder },
1489        "output-style" => SlashCommand::OutputStyle { style: remainder },
1490        "add-dir" => SlashCommand::AddDir { path: remainder },
1491        "history" => SlashCommand::History {
1492            count: optional_single_arg(command, &args, "[count]")?,
1493        },
1494        other => SlashCommand::Unknown(other.to_string()),
1495    }))
1496}
1497fn validate_no_args(command: &str, args: &[&str]) -> Result<(), SlashCommandParseError> {
1498    if args.is_empty() {
1499        return Ok(());
1500    }
1501
1502    Err(command_error(
1503        &format!("Unexpected arguments for /{command}."),
1504        command,
1505        &format!("/{command}"),
1506    ))
1507}
1508
1509fn optional_single_arg(
1510    command: &str,
1511    args: &[&str],
1512    argument_hint: &str,
1513) -> Result<Option<String>, SlashCommandParseError> {
1514    match args {
1515        [] => Ok(None),
1516        [value] => Ok(Some((*value).to_string())),
1517        _ => Err(usage_error(command, argument_hint)),
1518    }
1519}
1520
1521fn require_remainder(
1522    command: &str,
1523    remainder: Option<String>,
1524    argument_hint: &str,
1525) -> Result<String, SlashCommandParseError> {
1526    remainder.ok_or_else(|| usage_error(command, argument_hint))
1527}
1528
1529fn parse_permissions_mode(args: &[&str]) -> Result<Option<String>, SlashCommandParseError> {
1530    let mode = optional_single_arg(
1531        "permissions",
1532        args,
1533        "[read-only|workspace-write|danger-full-access]",
1534    )?;
1535    if let Some(mode) = mode {
1536        if matches!(
1537            mode.as_str(),
1538            "read-only" | "workspace-write" | "danger-full-access"
1539        ) {
1540            return Ok(Some(mode));
1541        }
1542        return Err(command_error(
1543            &format!(
1544                "Unsupported /permissions mode '{mode}'. Use read-only, workspace-write, or danger-full-access."
1545            ),
1546            "permissions",
1547            "/permissions [read-only|workspace-write|danger-full-access]",
1548        ));
1549    }
1550
1551    Ok(None)
1552}
1553
1554fn parse_clear_args(args: &[&str]) -> Result<bool, SlashCommandParseError> {
1555    match args {
1556        [] => Ok(false),
1557        ["--confirm"] => Ok(true),
1558        [unexpected] => Err(command_error(
1559            &format!("Unsupported /clear argument '{unexpected}'. Use /clear or /clear --confirm."),
1560            "clear",
1561            "/clear [--confirm]",
1562        )),
1563        _ => Err(usage_error("clear", "[--confirm]")),
1564    }
1565}
1566
1567fn parse_config_section(args: &[&str]) -> Result<Option<String>, SlashCommandParseError> {
1568    let section = optional_single_arg("config", args, "[env|hooks|model|plugins]")?;
1569    if let Some(section) = section {
1570        if matches!(section.as_str(), "env" | "hooks" | "model" | "plugins") {
1571            return Ok(Some(section));
1572        }
1573        return Err(command_error(
1574            &format!("Unsupported /config section '{section}'. Use env, hooks, model, or plugins."),
1575            "config",
1576            "/config [env|hooks|model|plugins]",
1577        ));
1578    }
1579
1580    Ok(None)
1581}
1582
1583fn parse_session_command(args: &[&str]) -> Result<SlashCommand, SlashCommandParseError> {
1584    match args {
1585        [] => Ok(SlashCommand::Session {
1586            action: None,
1587            target: None,
1588        }),
1589        ["list"] => Ok(SlashCommand::Session {
1590            action: Some("list".to_string()),
1591            target: None,
1592        }),
1593        ["list", ..] => Err(usage_error("session", "[list|switch <session-id>|fork [branch-name]|delete <session-id> [--force]]")),
1594        ["switch"] => Err(usage_error("session switch", "<session-id>")),
1595        ["switch", target] => Ok(SlashCommand::Session {
1596            action: Some("switch".to_string()),
1597            target: Some((*target).to_string()),
1598        }),
1599        ["switch", ..] => Err(command_error(
1600            "Unexpected arguments for /session switch.",
1601            "session",
1602            "/session switch <session-id>",
1603        )),
1604        ["fork"] => Ok(SlashCommand::Session {
1605            action: Some("fork".to_string()),
1606            target: None,
1607        }),
1608        ["fork", target] => Ok(SlashCommand::Session {
1609            action: Some("fork".to_string()),
1610            target: Some((*target).to_string()),
1611        }),
1612        ["fork", ..] => Err(command_error(
1613            "Unexpected arguments for /session fork.",
1614            "session",
1615            "/session fork [branch-name]",
1616        )),
1617        ["delete"] => Err(usage_error("session delete", "<session-id> [--force]")),
1618        ["delete", target] => Ok(SlashCommand::Session {
1619            action: Some("delete".to_string()),
1620            target: Some((*target).to_string()),
1621        }),
1622        ["delete", target, "--force"] => Ok(SlashCommand::Session {
1623            action: Some("delete-force".to_string()),
1624            target: Some((*target).to_string()),
1625        }),
1626        ["delete", _target, unexpected] => Err(command_error(
1627            &format!(
1628                "Unsupported /session delete flag '{unexpected}'. Use --force to skip confirmation."
1629            ),
1630            "session",
1631            "/session delete <session-id> [--force]",
1632        )),
1633        ["delete", ..] => Err(command_error(
1634            "Unexpected arguments for /session delete.",
1635            "session",
1636            "/session delete <session-id> [--force]",
1637        )),
1638        [action, ..] => Err(command_error(
1639            &format!(
1640                "Unknown /session action '{action}'. Use list, switch <session-id>, fork [branch-name], or delete <session-id> [--force]."
1641            ),
1642            "session",
1643            "/session [list|switch <session-id>|fork [branch-name]|delete <session-id> [--force]]",
1644        )),
1645    }
1646}
1647
1648fn parse_mcp_command(args: &[&str]) -> Result<SlashCommand, SlashCommandParseError> {
1649    match args {
1650        [] => Ok(SlashCommand::Mcp {
1651            action: None,
1652            target: None,
1653        }),
1654        ["list"] => Ok(SlashCommand::Mcp {
1655            action: Some("list".to_string()),
1656            target: None,
1657        }),
1658        ["list", ..] => Err(usage_error("mcp list", "")),
1659        ["show"] => Err(usage_error("mcp show", "<server>")),
1660        ["show", target] => Ok(SlashCommand::Mcp {
1661            action: Some("show".to_string()),
1662            target: Some((*target).to_string()),
1663        }),
1664        ["show", ..] => Err(command_error(
1665            "Unexpected arguments for /mcp show.",
1666            "mcp",
1667            "/mcp show <server>",
1668        )),
1669        ["help" | "-h" | "--help"] => Ok(SlashCommand::Mcp {
1670            action: Some("help".to_string()),
1671            target: None,
1672        }),
1673        [action, ..] => Err(command_error(
1674            &format!("Unknown /mcp action '{action}'. Use list, show <server>, or help."),
1675            "mcp",
1676            "/mcp [list|show <server>|help]",
1677        )),
1678    }
1679}
1680
1681fn parse_plugin_command(args: &[&str]) -> Result<SlashCommand, SlashCommandParseError> {
1682    match args {
1683        [] => Ok(SlashCommand::Plugins {
1684            action: None,
1685            target: None,
1686        }),
1687        ["list"] => Ok(SlashCommand::Plugins {
1688            action: Some("list".to_string()),
1689            target: None,
1690        }),
1691        ["list", ..] => Err(usage_error("plugin list", "")),
1692        ["install"] => Err(usage_error("plugin install", "<path>")),
1693        ["install", target @ ..] => Ok(SlashCommand::Plugins {
1694            action: Some("install".to_string()),
1695            target: Some(target.join(" ")),
1696        }),
1697        ["enable"] => Err(usage_error("plugin enable", "<name>")),
1698        ["enable", target] => Ok(SlashCommand::Plugins {
1699            action: Some("enable".to_string()),
1700            target: Some((*target).to_string()),
1701        }),
1702        ["enable", ..] => Err(command_error(
1703            "Unexpected arguments for /plugin enable.",
1704            "plugin",
1705            "/plugin enable <name>",
1706        )),
1707        ["disable"] => Err(usage_error("plugin disable", "<name>")),
1708        ["disable", target] => Ok(SlashCommand::Plugins {
1709            action: Some("disable".to_string()),
1710            target: Some((*target).to_string()),
1711        }),
1712        ["disable", ..] => Err(command_error(
1713            "Unexpected arguments for /plugin disable.",
1714            "plugin",
1715            "/plugin disable <name>",
1716        )),
1717        ["uninstall"] => Err(usage_error("plugin uninstall", "<id>")),
1718        ["uninstall", target] => Ok(SlashCommand::Plugins {
1719            action: Some("uninstall".to_string()),
1720            target: Some((*target).to_string()),
1721        }),
1722        ["uninstall", ..] => Err(command_error(
1723            "Unexpected arguments for /plugin uninstall.",
1724            "plugin",
1725            "/plugin uninstall <id>",
1726        )),
1727        ["update"] => Err(usage_error("plugin update", "<id>")),
1728        ["update", target] => Ok(SlashCommand::Plugins {
1729            action: Some("update".to_string()),
1730            target: Some((*target).to_string()),
1731        }),
1732        ["update", ..] => Err(command_error(
1733            "Unexpected arguments for /plugin update.",
1734            "plugin",
1735            "/plugin update <id>",
1736        )),
1737        [action, ..] => Err(command_error(
1738            &format!(
1739                "Unknown /plugin action '{action}'. Use list, install <path>, enable <name>, disable <name>, uninstall <id>, or update <id>."
1740            ),
1741            "plugin",
1742            "/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]",
1743        )),
1744    }
1745}
1746
1747fn parse_list_or_help_args(
1748    command: &str,
1749    args: Option<String>,
1750) -> Result<Option<String>, SlashCommandParseError> {
1751    match normalize_optional_args(args.as_deref()) {
1752        None | Some("list" | "help" | "-h" | "--help") => Ok(args),
1753        Some(unexpected) => Err(command_error(
1754            &format!(
1755                "Unexpected arguments for /{command}: {unexpected}. Use /{command}, /{command} list, or /{command} help."
1756            ),
1757            command,
1758            &format!("/{command} [list|help]"),
1759        )),
1760    }
1761}
1762
1763fn parse_skills_args(args: Option<&str>) -> Result<Option<String>, SlashCommandParseError> {
1764    let Some(args) = normalize_optional_args(args) else {
1765        return Ok(None);
1766    };
1767
1768    if matches!(args, "list" | "help" | "-h" | "--help") {
1769        return Ok(Some(args.to_string()));
1770    }
1771
1772    if args == "install" {
1773        return Err(command_error(
1774            "Usage: /skills install <path>",
1775            "skills",
1776            "/skills install <path>",
1777        ));
1778    }
1779
1780    if let Some(target) = args.strip_prefix("install").map(str::trim) {
1781        if !target.is_empty() {
1782            return Ok(Some(format!("install {target}")));
1783        }
1784    }
1785
1786    Ok(Some(args.to_string()))
1787}
1788
1789fn usage_error(command: &str, argument_hint: &str) -> SlashCommandParseError {
1790    let usage = format!("/{command} {argument_hint}");
1791    let usage = usage.trim_end().to_string();
1792    command_error(
1793        &format!("Usage: {usage}"),
1794        command_root_name(command),
1795        &usage,
1796    )
1797}
1798
1799fn command_error(message: &str, command: &str, usage: &str) -> SlashCommandParseError {
1800    let detail = render_slash_command_help_detail(command)
1801        .map(|detail| format!("\n\n{detail}"))
1802        .unwrap_or_default();
1803    SlashCommandParseError::new(format!("{message}\n  Usage            {usage}{detail}"))
1804}
1805
1806fn remainder_after_command(input: &str, command: &str) -> Option<String> {
1807    input
1808        .trim()
1809        .strip_prefix(&format!("/{command}"))
1810        .map(str::trim)
1811        .filter(|value| !value.is_empty())
1812        .map(ToOwned::to_owned)
1813}
1814
1815fn find_slash_command_spec(name: &str) -> Option<&'static SlashCommandSpec> {
1816    slash_command_specs().iter().find(|spec| {
1817        spec.name.eq_ignore_ascii_case(name)
1818            || spec
1819                .aliases
1820                .iter()
1821                .any(|alias| alias.eq_ignore_ascii_case(name))
1822    })
1823}
1824
1825fn command_root_name(command: &str) -> &str {
1826    command.split_whitespace().next().unwrap_or(command)
1827}
1828
1829fn slash_command_usage(spec: &SlashCommandSpec) -> String {
1830    match spec.argument_hint {
1831        Some(argument_hint) => format!("/{} {argument_hint}", spec.name),
1832        None => format!("/{}", spec.name),
1833    }
1834}
1835
1836fn slash_command_detail_lines(spec: &SlashCommandSpec) -> Vec<String> {
1837    let mut lines = vec![format!("/{}", spec.name)];
1838    lines.push(format!("  Summary          {}", spec.summary));
1839    lines.push(format!("  Usage            {}", slash_command_usage(spec)));
1840    lines.push(format!(
1841        "  Category         {}",
1842        slash_command_category(spec.name)
1843    ));
1844    if !spec.aliases.is_empty() {
1845        lines.push(format!(
1846            "  Aliases          {}",
1847            spec.aliases
1848                .iter()
1849                .map(|alias| format!("/{alias}"))
1850                .collect::<Vec<_>>()
1851                .join(", ")
1852        ));
1853    }
1854    if spec.resume_supported {
1855        lines.push("  Resume           Supported with --resume SESSION.jsonl".to_string());
1856    }
1857    lines
1858}
1859
1860#[must_use]
1861pub fn render_slash_command_help_detail(name: &str) -> Option<String> {
1862    find_slash_command_spec(name).map(|spec| slash_command_detail_lines(spec).join("\n"))
1863}
1864
1865#[must_use]
1866pub fn slash_command_specs() -> &'static [SlashCommandSpec] {
1867    SLASH_COMMAND_SPECS
1868}
1869
1870#[must_use]
1871pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> {
1872    slash_command_specs()
1873        .iter()
1874        .filter(|spec| spec.resume_supported)
1875        .collect()
1876}
1877
1878fn slash_command_category(name: &str) -> &'static str {
1879    match name {
1880        "help" | "status" | "cost" | "resume" | "session" | "version" | "usage" | "stats"
1881        | "rename" | "clear" | "compact" | "history" | "tokens" | "cache" | "exit" | "summary"
1882        | "tag" | "thinkback" | "copy" | "share" | "feedback" | "rewind" | "pin" | "unpin"
1883        | "bookmarks" | "context" | "files" | "focus" | "unfocus" | "retry" | "stop" | "undo" => {
1884            "Session"
1885        }
1886        "model" | "permissions" | "config" | "memory" | "theme" | "vim" | "voice" | "color"
1887        | "effort" | "fast" | "brief" | "output-style" | "keybindings" | "privacy-settings"
1888        | "stickers" | "language" | "profile" | "max-tokens" | "temperature" | "system-prompt"
1889        | "api-key" | "terminal-setup" | "notifications" | "telemetry" | "providers" | "env"
1890        | "project" | "reasoning" | "budget" | "rate-limit" | "workspace" | "reset" | "ide"
1891        | "desktop" | "upgrade" => "Config",
1892        "debug-tool-call" | "doctor" | "sandbox" | "diagnostics" | "tool-details" | "changelog"
1893        | "metrics" => "Debug",
1894        _ => "Tools",
1895    }
1896}
1897
1898fn format_slash_command_help_line(spec: &SlashCommandSpec) -> String {
1899    let name = slash_command_usage(spec);
1900    let alias_suffix = if spec.aliases.is_empty() {
1901        String::new()
1902    } else {
1903        format!(
1904            " (aliases: {})",
1905            spec.aliases
1906                .iter()
1907                .map(|alias| format!("/{alias}"))
1908                .collect::<Vec<_>>()
1909                .join(", ")
1910        )
1911    };
1912    let resume = if spec.resume_supported {
1913        " [resume]"
1914    } else {
1915        ""
1916    };
1917    format!("  {name:<66} {}{alias_suffix}{resume}", spec.summary)
1918}
1919
1920fn levenshtein_distance(left: &str, right: &str) -> usize {
1921    if left == right {
1922        return 0;
1923    }
1924    if left.is_empty() {
1925        return right.chars().count();
1926    }
1927    if right.is_empty() {
1928        return left.chars().count();
1929    }
1930
1931    let right_chars = right.chars().collect::<Vec<_>>();
1932    let mut previous = (0..=right_chars.len()).collect::<Vec<_>>();
1933    let mut current = vec![0; right_chars.len() + 1];
1934
1935    for (left_index, left_char) in left.chars().enumerate() {
1936        current[0] = left_index + 1;
1937        for (right_index, right_char) in right_chars.iter().enumerate() {
1938            let substitution_cost = usize::from(left_char != *right_char);
1939            current[right_index + 1] = (current[right_index] + 1)
1940                .min(previous[right_index + 1] + 1)
1941                .min(previous[right_index] + substitution_cost);
1942        }
1943        previous.clone_from(&current);
1944    }
1945
1946    previous[right_chars.len()]
1947}
1948
1949#[must_use]
1950pub fn suggest_slash_commands(input: &str, limit: usize) -> Vec<String> {
1951    let query = input.trim().trim_start_matches('/').to_ascii_lowercase();
1952    if query.is_empty() || limit == 0 {
1953        return Vec::new();
1954    }
1955
1956    let mut suggestions = slash_command_specs()
1957        .iter()
1958        .filter_map(|spec| {
1959            let best = std::iter::once(spec.name)
1960                .chain(spec.aliases.iter().copied())
1961                .map(str::to_ascii_lowercase)
1962                .map(|candidate| {
1963                    let prefix_rank =
1964                        if candidate.starts_with(&query) || query.starts_with(&candidate) {
1965                            0
1966                        } else if candidate.contains(&query) || query.contains(&candidate) {
1967                            1
1968                        } else {
1969                            2
1970                        };
1971                    let distance = levenshtein_distance(&candidate, &query);
1972                    (prefix_rank, distance)
1973                })
1974                .min();
1975
1976            best.and_then(|(prefix_rank, distance)| {
1977                if prefix_rank <= 1 || distance <= 2 {
1978                    Some((prefix_rank, distance, spec.name.len(), spec.name))
1979                } else {
1980                    None
1981                }
1982            })
1983        })
1984        .collect::<Vec<_>>();
1985
1986    suggestions.sort_unstable();
1987    suggestions
1988        .into_iter()
1989        .map(|(_, _, _, name)| format!("/{name}"))
1990        .take(limit)
1991        .collect()
1992}
1993
1994#[must_use]
1995/// Render the slash-command help section, optionally excluding stub commands
1996/// (commands that are registered in the spec list but not yet implemented).
1997/// Pass an empty slice to include all commands.
1998pub fn render_slash_command_help_filtered(exclude: &[&str]) -> String {
1999    let mut lines = vec![
2000        "Slash commands".to_string(),
2001        "  Start here        /status, /diff, /agents, /skills, /commit".to_string(),
2002        "  [resume]          also works with --resume SESSION.jsonl".to_string(),
2003        String::new(),
2004    ];
2005
2006    let categories = ["Session", "Tools", "Config", "Debug"];
2007
2008    for category in categories {
2009        lines.push(category.to_string());
2010        for spec in slash_command_specs()
2011            .iter()
2012            .filter(|spec| slash_command_category(spec.name) == category)
2013            .filter(|spec| !exclude.contains(&spec.name))
2014        {
2015            lines.push(format_slash_command_help_line(spec));
2016        }
2017        lines.push(String::new());
2018    }
2019
2020    lines
2021        .into_iter()
2022        .rev()
2023        .skip_while(String::is_empty)
2024        .collect::<Vec<_>>()
2025        .into_iter()
2026        .rev()
2027        .collect::<Vec<_>>()
2028        .join("\n")
2029}
2030
2031pub fn render_slash_command_help() -> String {
2032    let mut lines = vec![
2033        "Slash commands".to_string(),
2034        "  Start here        /status, /diff, /agents, /skills, /commit".to_string(),
2035        "  [resume]          also works with --resume SESSION.jsonl".to_string(),
2036        String::new(),
2037    ];
2038
2039    let categories = ["Session", "Tools", "Config", "Debug"];
2040
2041    for category in categories {
2042        lines.push(category.to_string());
2043        for spec in slash_command_specs()
2044            .iter()
2045            .filter(|spec| slash_command_category(spec.name) == category)
2046        {
2047            lines.push(format_slash_command_help_line(spec));
2048        }
2049        lines.push(String::new());
2050    }
2051
2052    lines.push("Keyboard shortcuts".to_string());
2053    lines.push("  Up/Down              Navigate prompt history".to_string());
2054    lines.push("  Tab                  Complete commands, modes, and recent sessions".to_string());
2055    lines.push("  Ctrl-C               Clear input (or exit on empty prompt)".to_string());
2056    lines.push("  Shift+Enter/Ctrl+J   Insert a newline".to_string());
2057
2058    lines
2059        .into_iter()
2060        .rev()
2061        .skip_while(String::is_empty)
2062        .collect::<Vec<_>>()
2063        .into_iter()
2064        .rev()
2065        .collect::<Vec<_>>()
2066        .join("\n")
2067}
2068
2069#[derive(Debug, Clone, PartialEq, Eq)]
2070pub struct SlashCommandResult {
2071    pub message: String,
2072    pub session: Session,
2073}
2074
2075#[derive(Debug, Clone, PartialEq, Eq)]
2076pub struct PluginsCommandResult {
2077    pub message: String,
2078    pub reload_runtime: bool,
2079}
2080
2081#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
2082enum DefinitionSource {
2083    ProjectClaw,
2084    ProjectCodex,
2085    ProjectClaude,
2086    UserClawConfigHome,
2087    UserCodexHome,
2088    UserClaw,
2089    UserCodex,
2090    UserClaude,
2091}
2092
2093#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
2094enum DefinitionScope {
2095    Project,
2096    UserConfigHome,
2097    UserHome,
2098}
2099
2100impl DefinitionScope {
2101    fn label(self) -> &'static str {
2102        match self {
2103            Self::Project => "Project roots",
2104            Self::UserConfigHome => "User config roots",
2105            Self::UserHome => "User home roots",
2106        }
2107    }
2108}
2109
2110impl DefinitionSource {
2111    fn report_scope(self) -> DefinitionScope {
2112        match self {
2113            Self::ProjectClaw | Self::ProjectCodex | Self::ProjectClaude => {
2114                DefinitionScope::Project
2115            }
2116            Self::UserClawConfigHome | Self::UserCodexHome => DefinitionScope::UserConfigHome,
2117            Self::UserClaw | Self::UserCodex | Self::UserClaude => DefinitionScope::UserHome,
2118        }
2119    }
2120
2121    fn label(self) -> &'static str {
2122        self.report_scope().label()
2123    }
2124}
2125
2126#[derive(Debug, Clone, PartialEq, Eq)]
2127struct AgentSummary {
2128    name: String,
2129    description: Option<String>,
2130    model: Option<String>,
2131    reasoning_effort: Option<String>,
2132    source: DefinitionSource,
2133    shadowed_by: Option<DefinitionSource>,
2134}
2135
2136#[derive(Debug, Clone, PartialEq, Eq)]
2137struct SkillSummary {
2138    name: String,
2139    description: Option<String>,
2140    source: DefinitionSource,
2141    shadowed_by: Option<DefinitionSource>,
2142    origin: SkillOrigin,
2143}
2144
2145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2146enum SkillOrigin {
2147    SkillsDir,
2148    LegacyCommandsDir,
2149}
2150
2151impl SkillOrigin {
2152    fn detail_label(self) -> Option<&'static str> {
2153        match self {
2154            Self::SkillsDir => None,
2155            Self::LegacyCommandsDir => Some("legacy /commands"),
2156        }
2157    }
2158}
2159
2160#[derive(Debug, Clone, PartialEq, Eq)]
2161struct SkillRoot {
2162    source: DefinitionSource,
2163    path: PathBuf,
2164    origin: SkillOrigin,
2165}
2166
2167#[derive(Debug, Clone, PartialEq, Eq)]
2168struct InstalledSkill {
2169    invocation_name: String,
2170    display_name: Option<String>,
2171    source: PathBuf,
2172    registry_root: PathBuf,
2173    installed_path: PathBuf,
2174}
2175
2176#[derive(Debug, Clone, PartialEq, Eq)]
2177enum SkillInstallSource {
2178    Directory { root: PathBuf, prompt_path: PathBuf },
2179    MarkdownFile { path: PathBuf },
2180}
2181
2182#[allow(clippy::too_many_lines)]
2183pub fn handle_plugins_slash_command(
2184    action: Option<&str>,
2185    target: Option<&str>,
2186    manager: &mut PluginManager,
2187) -> Result<PluginsCommandResult, PluginError> {
2188    match action {
2189        None | Some("list") => {
2190            let report = manager.installed_plugin_registry_report()?;
2191            let plugins = report.summaries();
2192            let failures = report.failures();
2193            Ok(PluginsCommandResult {
2194                message: render_plugins_report_with_failures(&plugins, failures),
2195                reload_runtime: false,
2196            })
2197        }
2198        Some("install") => {
2199            let Some(target) = target else {
2200                return Ok(PluginsCommandResult {
2201                    message: "Usage: /plugins install <path>".to_string(),
2202                    reload_runtime: false,
2203                });
2204            };
2205            let install = manager.install(target)?;
2206            let plugin = manager
2207                .list_installed_plugins()?
2208                .into_iter()
2209                .find(|plugin| plugin.metadata.id == install.plugin_id);
2210            Ok(PluginsCommandResult {
2211                message: render_plugin_install_report(&install.plugin_id, plugin.as_ref()),
2212                reload_runtime: true,
2213            })
2214        }
2215        Some("enable") => {
2216            let Some(target) = target else {
2217                return Ok(PluginsCommandResult {
2218                    message: "Usage: /plugins enable <name>".to_string(),
2219                    reload_runtime: false,
2220                });
2221            };
2222            let plugin = resolve_plugin_target(manager, target)?;
2223            manager.enable(&plugin.metadata.id)?;
2224            Ok(PluginsCommandResult {
2225                message: format!(
2226                    "Plugins\n  Result           enabled {}\n  Name             {}\n  Version          {}\n  Status           enabled",
2227                    plugin.metadata.id, plugin.metadata.name, plugin.metadata.version
2228                ),
2229                reload_runtime: true,
2230            })
2231        }
2232        Some("disable") => {
2233            let Some(target) = target else {
2234                return Ok(PluginsCommandResult {
2235                    message: "Usage: /plugins disable <name>".to_string(),
2236                    reload_runtime: false,
2237                });
2238            };
2239            let plugin = resolve_plugin_target(manager, target)?;
2240            manager.disable(&plugin.metadata.id)?;
2241            Ok(PluginsCommandResult {
2242                message: format!(
2243                    "Plugins\n  Result           disabled {}\n  Name             {}\n  Version          {}\n  Status           disabled",
2244                    plugin.metadata.id, plugin.metadata.name, plugin.metadata.version
2245                ),
2246                reload_runtime: true,
2247            })
2248        }
2249        Some("uninstall") => {
2250            let Some(target) = target else {
2251                return Ok(PluginsCommandResult {
2252                    message: "Usage: /plugins uninstall <plugin-id>".to_string(),
2253                    reload_runtime: false,
2254                });
2255            };
2256            manager.uninstall(target)?;
2257            Ok(PluginsCommandResult {
2258                message: format!("Plugins\n  Result           uninstalled {target}"),
2259                reload_runtime: true,
2260            })
2261        }
2262        Some("update") => {
2263            let Some(target) = target else {
2264                return Ok(PluginsCommandResult {
2265                    message: "Usage: /plugins update <plugin-id>".to_string(),
2266                    reload_runtime: false,
2267                });
2268            };
2269            let update = manager.update(target)?;
2270            let plugin = manager
2271                .list_installed_plugins()?
2272                .into_iter()
2273                .find(|plugin| plugin.metadata.id == update.plugin_id);
2274            Ok(PluginsCommandResult {
2275                message: format!(
2276                    "Plugins\n  Result           updated {}\n  Name             {}\n  Old version      {}\n  New version      {}\n  Status           {}",
2277                    update.plugin_id,
2278                    plugin
2279                        .as_ref()
2280                        .map_or_else(|| update.plugin_id.clone(), |plugin| plugin.metadata.name.clone()),
2281                    update.old_version,
2282                    update.new_version,
2283                    plugin
2284                        .as_ref()
2285                        .map_or("unknown", |plugin| if plugin.enabled { "enabled" } else { "disabled" }),
2286                ),
2287                reload_runtime: true,
2288            })
2289        }
2290        Some(other) => Ok(PluginsCommandResult {
2291            message: format!(
2292                "Unknown /plugins action '{other}'. Use list, install, enable, disable, uninstall, or update."
2293            ),
2294            reload_runtime: false,
2295        }),
2296    }
2297}
2298
2299pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
2300    if let Some(args) = normalize_optional_args(args) {
2301        if let Some(help_path) = help_path_from_args(args) {
2302            return Ok(match help_path.as_slice() {
2303                [] => render_agents_usage(None),
2304                _ => render_agents_usage(Some(&help_path.join(" "))),
2305            });
2306        }
2307    }
2308
2309    match normalize_optional_args(args) {
2310        None | Some("list") => {
2311            let roots = discover_definition_roots(cwd, "agents");
2312            let agents = load_agents_from_roots(&roots)?;
2313            Ok(render_agents_report(&agents))
2314        }
2315        Some(args) if is_help_arg(args) => Ok(render_agents_usage(None)),
2316        Some(args) => Ok(render_agents_usage(Some(args))),
2317    }
2318}
2319
2320pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::io::Result<Value> {
2321    if let Some(args) = normalize_optional_args(args) {
2322        if let Some(help_path) = help_path_from_args(args) {
2323            return Ok(match help_path.as_slice() {
2324                [] => render_agents_usage_json(None),
2325                _ => render_agents_usage_json(Some(&help_path.join(" "))),
2326            });
2327        }
2328    }
2329
2330    match normalize_optional_args(args) {
2331        None | Some("list") => {
2332            let roots = discover_definition_roots(cwd, "agents");
2333            let agents = load_agents_from_roots(&roots)?;
2334            Ok(render_agents_report_json(cwd, &agents))
2335        }
2336        Some(args) if is_help_arg(args) => Ok(render_agents_usage_json(None)),
2337        Some(args) => Ok(render_agents_usage_json(Some(args))),
2338    }
2339}
2340
2341pub fn handle_mcp_slash_command(
2342    args: Option<&str>,
2343    cwd: &Path,
2344) -> Result<String, ninmu_runtime::ConfigError> {
2345    let loader = ConfigLoader::default_for(cwd);
2346    render_mcp_report_for(&loader, cwd, args)
2347}
2348
2349pub fn handle_mcp_slash_command_json(
2350    args: Option<&str>,
2351    cwd: &Path,
2352) -> Result<Value, ninmu_runtime::ConfigError> {
2353    let loader = ConfigLoader::default_for(cwd);
2354    render_mcp_report_json_for(&loader, cwd, args)
2355}
2356
2357pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
2358    if let Some(args) = normalize_optional_args(args) {
2359        if let Some(help_path) = help_path_from_args(args) {
2360            return Ok(match help_path.as_slice() {
2361                [] => render_skills_usage(None),
2362                ["install", ..] => render_skills_usage(Some("install")),
2363                _ => render_skills_usage(Some(&help_path.join(" "))),
2364            });
2365        }
2366    }
2367
2368    match normalize_optional_args(args) {
2369        None | Some("list") => {
2370            let roots = discover_skill_roots(cwd);
2371            let skills = load_skills_from_roots(&roots)?;
2372            Ok(render_skills_report(&skills))
2373        }
2374        Some("install") => Ok(render_skills_usage(Some("install"))),
2375        Some(args) if args.starts_with("install ") => {
2376            let target = args["install ".len()..].trim();
2377            if target.is_empty() {
2378                return Ok(render_skills_usage(Some("install")));
2379            }
2380            let install = install_skill(target, cwd)?;
2381            Ok(render_skill_install_report(&install))
2382        }
2383        Some(args) if is_help_arg(args) => Ok(render_skills_usage(None)),
2384        Some(args) => Ok(render_skills_usage(Some(args))),
2385    }
2386}
2387
2388pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::io::Result<Value> {
2389    if let Some(args) = normalize_optional_args(args) {
2390        if let Some(help_path) = help_path_from_args(args) {
2391            return Ok(match help_path.as_slice() {
2392                [] => render_skills_usage_json(None),
2393                ["install", ..] => render_skills_usage_json(Some("install")),
2394                _ => render_skills_usage_json(Some(&help_path.join(" "))),
2395            });
2396        }
2397    }
2398
2399    match normalize_optional_args(args) {
2400        None | Some("list") => {
2401            let roots = discover_skill_roots(cwd);
2402            let skills = load_skills_from_roots(&roots)?;
2403            Ok(render_skills_report_json(&skills))
2404        }
2405        Some("install") => Ok(render_skills_usage_json(Some("install"))),
2406        Some(args) if args.starts_with("install ") => {
2407            let target = args["install ".len()..].trim();
2408            if target.is_empty() {
2409                return Ok(render_skills_usage_json(Some("install")));
2410            }
2411            let install = install_skill(target, cwd)?;
2412            Ok(render_skill_install_report_json(&install))
2413        }
2414        Some(args) if is_help_arg(args) => Ok(render_skills_usage_json(None)),
2415        Some(args) => Ok(render_skills_usage_json(Some(args))),
2416    }
2417}
2418
2419#[must_use]
2420pub fn classify_skills_slash_command(args: Option<&str>) -> SkillSlashDispatch {
2421    match normalize_optional_args(args) {
2422        None | Some("list" | "help" | "-h" | "--help") => SkillSlashDispatch::Local,
2423        Some(args) if args == "install" || args.starts_with("install ") => {
2424            SkillSlashDispatch::Local
2425        }
2426        Some(args) => SkillSlashDispatch::Invoke(format!("${}", args.trim_start_matches('/'))),
2427    }
2428}
2429
2430/// Resolve a skill invocation by validating the skill exists on disk before
2431/// returning the dispatch.  When the skill is not found, returns `Err` with a
2432/// human-readable message that lists nearby skill names.
2433pub fn resolve_skill_invocation(
2434    cwd: &Path,
2435    args: Option<&str>,
2436) -> Result<SkillSlashDispatch, String> {
2437    let dispatch = classify_skills_slash_command(args);
2438    if let SkillSlashDispatch::Invoke(ref prompt) = dispatch {
2439        // Extract the skill name from the "$skill [args]" prompt.
2440        let skill_token = prompt
2441            .trim_start_matches('$')
2442            .split_whitespace()
2443            .next()
2444            .unwrap_or_default();
2445        if !skill_token.is_empty() {
2446            if let Err(error) = resolve_skill_path(cwd, skill_token) {
2447                let mut message = format!("Unknown skill: {skill_token} ({error})");
2448                let roots = discover_skill_roots(cwd);
2449                if let Ok(available) = load_skills_from_roots(&roots) {
2450                    let names: Vec<String> = available
2451                        .iter()
2452                        .filter(|s| s.shadowed_by.is_none())
2453                        .map(|s| s.name.clone())
2454                        .collect();
2455                    if !names.is_empty() {
2456                        message.push_str("\n  Available skills: ");
2457                        message.push_str(&names.join(", "));
2458                    }
2459                }
2460                message.push_str("\n  Usage: /skills [list|install <path>|help|<skill> [args]]");
2461                return Err(message);
2462            }
2463        }
2464    }
2465    Ok(dispatch)
2466}
2467
2468pub fn resolve_skill_path(cwd: &Path, skill: &str) -> std::io::Result<PathBuf> {
2469    let requested = skill.trim().trim_start_matches('/').trim_start_matches('$');
2470    if requested.is_empty() {
2471        return Err(std::io::Error::new(
2472            std::io::ErrorKind::InvalidInput,
2473            "skill must not be empty",
2474        ));
2475    }
2476
2477    let roots = discover_skill_roots(cwd);
2478    for root in &roots {
2479        let mut entries = Vec::new();
2480        for entry in fs::read_dir(&root.path)? {
2481            let entry = entry?;
2482            match root.origin {
2483                SkillOrigin::SkillsDir => {
2484                    if !entry.path().is_dir() {
2485                        continue;
2486                    }
2487                    let skill_path = entry.path().join("SKILL.md");
2488                    if !skill_path.is_file() {
2489                        continue;
2490                    }
2491                    let contents = fs::read_to_string(&skill_path)?;
2492                    let (name, _) = parse_skill_frontmatter(&contents);
2493                    entries.push((
2494                        name.unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()),
2495                        skill_path,
2496                    ));
2497                }
2498                SkillOrigin::LegacyCommandsDir => {
2499                    let path = entry.path();
2500                    let markdown_path = if path.is_dir() {
2501                        let skill_path = path.join("SKILL.md");
2502                        if !skill_path.is_file() {
2503                            continue;
2504                        }
2505                        skill_path
2506                    } else if path
2507                        .extension()
2508                        .is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md"))
2509                    {
2510                        path
2511                    } else {
2512                        continue;
2513                    };
2514
2515                    let contents = fs::read_to_string(&markdown_path)?;
2516                    let fallback_name = markdown_path.file_stem().map_or_else(
2517                        || entry.file_name().to_string_lossy().to_string(),
2518                        |stem| stem.to_string_lossy().to_string(),
2519                    );
2520                    let (name, _) = parse_skill_frontmatter(&contents);
2521                    entries.push((name.unwrap_or(fallback_name), markdown_path));
2522                }
2523            }
2524        }
2525        entries.sort_by(|left, right| left.0.cmp(&right.0));
2526        if let Some((_, path)) = entries
2527            .into_iter()
2528            .find(|(name, _)| name.eq_ignore_ascii_case(requested))
2529        {
2530            return Ok(path);
2531        }
2532    }
2533
2534    Err(std::io::Error::new(
2535        std::io::ErrorKind::NotFound,
2536        format!("unknown skill: {requested}"),
2537    ))
2538}
2539
2540fn render_mcp_report_for(
2541    loader: &ConfigLoader,
2542    cwd: &Path,
2543    args: Option<&str>,
2544) -> Result<String, ninmu_runtime::ConfigError> {
2545    if let Some(args) = normalize_optional_args(args) {
2546        if let Some(help_path) = help_path_from_args(args) {
2547            return Ok(match help_path.as_slice() {
2548                [] => render_mcp_usage(None),
2549                ["show", ..] => render_mcp_usage(Some("show")),
2550                _ => render_mcp_usage(Some(&help_path.join(" "))),
2551            });
2552        }
2553    }
2554
2555    match normalize_optional_args(args) {
2556        None | Some("list") => {
2557            // #144: degrade gracefully on config parse failure (same contract
2558            // as #143 for `status`). Text mode prepends a "Config load error"
2559            // block before the MCP list; the list falls back to empty.
2560            match loader.load() {
2561                Ok(runtime_config) => Ok(render_mcp_summary_report(
2562                    cwd,
2563                    runtime_config.mcp().servers(),
2564                )),
2565                Err(err) => {
2566                    let empty = std::collections::BTreeMap::new();
2567                    Ok(format!(
2568                        "Config load error\n  Status           fail\n  Summary          runtime config failed to load; reporting partial MCP view\n  Details          {err}\n  Hint             `claw doctor` classifies config parse errors; fix the listed field and rerun\n\n{}",
2569                        render_mcp_summary_report(cwd, &empty)
2570                    ))
2571                }
2572            }
2573        }
2574        Some(args) if is_help_arg(args) => Ok(render_mcp_usage(None)),
2575        Some("show") => Ok(render_mcp_usage(Some("show"))),
2576        Some(args) if args.split_whitespace().next() == Some("show") => {
2577            let mut parts = args.split_whitespace();
2578            let _ = parts.next();
2579            let Some(server_name) = parts.next() else {
2580                return Ok(render_mcp_usage(Some("show")));
2581            };
2582            if parts.next().is_some() {
2583                return Ok(render_mcp_usage(Some(args)));
2584            }
2585            // #144: same degradation for `mcp show`; if config won't parse,
2586            // the specific server lookup can't succeed, so report the parse
2587            // error with context.
2588            match loader.load() {
2589                Ok(runtime_config) => Ok(render_mcp_server_report(
2590                    cwd,
2591                    server_name,
2592                    runtime_config.mcp().get(server_name),
2593                )),
2594                Err(err) => Ok(format!(
2595                    "Config load error\n  Status           fail\n  Summary          runtime config failed to load; cannot resolve `{server_name}`\n  Details          {err}\n  Hint             `claw doctor` classifies config parse errors; fix the listed field and rerun"
2596                )),
2597            }
2598        }
2599        Some(args) => Ok(render_mcp_usage(Some(args))),
2600    }
2601}
2602
2603fn render_mcp_report_json_for(
2604    loader: &ConfigLoader,
2605    cwd: &Path,
2606    args: Option<&str>,
2607) -> Result<Value, ninmu_runtime::ConfigError> {
2608    if let Some(args) = normalize_optional_args(args) {
2609        if let Some(help_path) = help_path_from_args(args) {
2610            return Ok(match help_path.as_slice() {
2611                [] => render_mcp_usage_json(None),
2612                ["show", ..] => render_mcp_usage_json(Some("show")),
2613                _ => render_mcp_usage_json(Some(&help_path.join(" "))),
2614            });
2615        }
2616    }
2617
2618    match normalize_optional_args(args) {
2619        None | Some("list") => {
2620            // #144: match #143's degraded envelope contract. On config parse
2621            // failure, emit top-level `status: "degraded"` with
2622            // `config_load_error`, empty servers[], and exit 0. On clean
2623            // runs, the existing serializer adds `status: "ok"` below.
2624            match loader.load() {
2625                Ok(runtime_config) => {
2626                    let mut value =
2627                        render_mcp_summary_report_json(cwd, runtime_config.mcp().servers());
2628                    if let Some(map) = value.as_object_mut() {
2629                        map.insert("status".to_string(), Value::String("ok".to_string()));
2630                        map.insert("config_load_error".to_string(), Value::Null);
2631                    }
2632                    Ok(value)
2633                }
2634                Err(err) => {
2635                    let empty = std::collections::BTreeMap::new();
2636                    let mut value = render_mcp_summary_report_json(cwd, &empty);
2637                    if let Some(map) = value.as_object_mut() {
2638                        map.insert("status".to_string(), Value::String("degraded".to_string()));
2639                        map.insert(
2640                            "config_load_error".to_string(),
2641                            Value::String(err.to_string()),
2642                        );
2643                    }
2644                    Ok(value)
2645                }
2646            }
2647        }
2648        Some(args) if is_help_arg(args) => Ok(render_mcp_usage_json(None)),
2649        Some("show") => Ok(render_mcp_usage_json(Some("show"))),
2650        Some(args) if args.split_whitespace().next() == Some("show") => {
2651            let mut parts = args.split_whitespace();
2652            let _ = parts.next();
2653            let Some(server_name) = parts.next() else {
2654                return Ok(render_mcp_usage_json(Some("show")));
2655            };
2656            if parts.next().is_some() {
2657                return Ok(render_mcp_usage_json(Some(args)));
2658            }
2659            // #144: same degradation pattern for show action.
2660            match loader.load() {
2661                Ok(runtime_config) => {
2662                    let mut value = render_mcp_server_report_json(
2663                        cwd,
2664                        server_name,
2665                        runtime_config.mcp().get(server_name),
2666                    );
2667                    if let Some(map) = value.as_object_mut() {
2668                        map.insert("status".to_string(), Value::String("ok".to_string()));
2669                        map.insert("config_load_error".to_string(), Value::Null);
2670                    }
2671                    Ok(value)
2672                }
2673                Err(err) => Ok(serde_json::json!({
2674                    "kind": "mcp",
2675                    "action": "show",
2676                    "server": server_name,
2677                    "status": "degraded",
2678                    "config_load_error": err.to_string(),
2679                    "working_directory": cwd.display().to_string(),
2680                })),
2681            }
2682        }
2683        Some(args) => Ok(render_mcp_usage_json(Some(args))),
2684    }
2685}
2686
2687#[must_use]
2688pub fn render_plugins_report(plugins: &[PluginSummary]) -> String {
2689    let mut lines = vec!["Plugins".to_string()];
2690    if plugins.is_empty() {
2691        lines.push("  No plugins installed.".to_string());
2692        return lines.join("\n");
2693    }
2694    for plugin in plugins {
2695        let enabled = if plugin.enabled {
2696            "enabled"
2697        } else {
2698            "disabled"
2699        };
2700        lines.push(format!(
2701            "  {name:<20} v{version:<10} {enabled}",
2702            name = plugin.metadata.name,
2703            version = plugin.metadata.version,
2704        ));
2705    }
2706    lines.join("\n")
2707}
2708
2709#[must_use]
2710pub fn render_plugins_report_with_failures(
2711    plugins: &[PluginSummary],
2712    failures: &[PluginLoadFailure],
2713) -> String {
2714    let mut lines = vec!["Plugins".to_string()];
2715
2716    // Show successfully loaded plugins
2717    if plugins.is_empty() {
2718        lines.push("  No plugins installed.".to_string());
2719    } else {
2720        for plugin in plugins {
2721            let enabled = if plugin.enabled {
2722                "enabled"
2723            } else {
2724                "disabled"
2725            };
2726            lines.push(format!(
2727                "  {name:<20} v{version:<10} {enabled}",
2728                name = plugin.metadata.name,
2729                version = plugin.metadata.version,
2730            ));
2731        }
2732    }
2733
2734    // Show warnings for broken plugins
2735    if !failures.is_empty() {
2736        lines.push(String::new());
2737        lines.push("Warnings:".to_string());
2738        for failure in failures {
2739            lines.push(format!(
2740                "  ⚠️  Failed to load {} plugin from `{}`",
2741                failure.kind,
2742                failure.plugin_root.display()
2743            ));
2744            lines.push(format!("      Error: {}", failure.error()));
2745        }
2746    }
2747
2748    lines.join("\n")
2749}
2750
2751fn render_plugin_install_report(plugin_id: &str, plugin: Option<&PluginSummary>) -> String {
2752    let name = plugin.map_or(plugin_id, |plugin| plugin.metadata.name.as_str());
2753    let version = plugin.map_or("unknown", |plugin| plugin.metadata.version.as_str());
2754    let enabled = plugin.is_some_and(|plugin| plugin.enabled);
2755    format!(
2756        "Plugins\n  Result           installed {plugin_id}\n  Name             {name}\n  Version          {version}\n  Status           {}",
2757        if enabled { "enabled" } else { "disabled" }
2758    )
2759}
2760
2761fn resolve_plugin_target(
2762    manager: &PluginManager,
2763    target: &str,
2764) -> Result<PluginSummary, PluginError> {
2765    let mut matches = manager
2766        .list_installed_plugins()?
2767        .into_iter()
2768        .filter(|plugin| plugin.metadata.id == target || plugin.metadata.name == target)
2769        .collect::<Vec<_>>();
2770    match matches.len() {
2771        1 => Ok(matches.remove(0)),
2772        0 => Err(PluginError::NotFound(format!(
2773            "plugin `{target}` is not installed or discoverable"
2774        ))),
2775        _ => Err(PluginError::InvalidManifest(format!(
2776            "plugin name `{target}` is ambiguous; use the full plugin id"
2777        ))),
2778    }
2779}
2780
2781fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, PathBuf)> {
2782    let mut roots = Vec::new();
2783
2784    for ancestor in cwd.ancestors() {
2785        push_unique_root(
2786            &mut roots,
2787            DefinitionSource::ProjectClaw,
2788            ancestor.join(".claw").join(leaf),
2789        );
2790        push_unique_root(
2791            &mut roots,
2792            DefinitionSource::ProjectCodex,
2793            ancestor.join(".codex").join(leaf),
2794        );
2795        push_unique_root(
2796            &mut roots,
2797            DefinitionSource::ProjectClaude,
2798            ancestor.join(".claude").join(leaf),
2799        );
2800    }
2801
2802    if let Ok(claw_config_home) = env::var("CLAW_CONFIG_HOME") {
2803        push_unique_root(
2804            &mut roots,
2805            DefinitionSource::UserClawConfigHome,
2806            PathBuf::from(claw_config_home).join(leaf),
2807        );
2808    }
2809
2810    if let Ok(codex_home) = env::var("CODEX_HOME") {
2811        push_unique_root(
2812            &mut roots,
2813            DefinitionSource::UserCodexHome,
2814            PathBuf::from(codex_home).join(leaf),
2815        );
2816    }
2817
2818    if let Ok(claude_config_dir) = env::var("CLAUDE_CONFIG_DIR") {
2819        push_unique_root(
2820            &mut roots,
2821            DefinitionSource::UserClaude,
2822            PathBuf::from(claude_config_dir).join(leaf),
2823        );
2824    }
2825
2826    if let Some(home) = env::var_os("HOME") {
2827        let home = PathBuf::from(home);
2828        push_unique_root(
2829            &mut roots,
2830            DefinitionSource::UserClaw,
2831            home.join(".claw").join(leaf),
2832        );
2833        push_unique_root(
2834            &mut roots,
2835            DefinitionSource::UserCodex,
2836            home.join(".codex").join(leaf),
2837        );
2838        push_unique_root(
2839            &mut roots,
2840            DefinitionSource::UserClaude,
2841            home.join(".claude").join(leaf),
2842        );
2843    }
2844
2845    roots
2846}
2847
2848#[allow(clippy::too_many_lines)]
2849fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
2850    let mut roots = Vec::new();
2851
2852    for ancestor in cwd.ancestors() {
2853        push_unique_skill_root(
2854            &mut roots,
2855            DefinitionSource::ProjectClaw,
2856            ancestor.join(".claw").join("skills"),
2857            SkillOrigin::SkillsDir,
2858        );
2859        push_unique_skill_root(
2860            &mut roots,
2861            DefinitionSource::ProjectClaw,
2862            ancestor.join(".omc").join("skills"),
2863            SkillOrigin::SkillsDir,
2864        );
2865        push_unique_skill_root(
2866            &mut roots,
2867            DefinitionSource::ProjectClaw,
2868            ancestor.join(".agents").join("skills"),
2869            SkillOrigin::SkillsDir,
2870        );
2871        push_unique_skill_root(
2872            &mut roots,
2873            DefinitionSource::ProjectCodex,
2874            ancestor.join(".codex").join("skills"),
2875            SkillOrigin::SkillsDir,
2876        );
2877        push_unique_skill_root(
2878            &mut roots,
2879            DefinitionSource::ProjectClaude,
2880            ancestor.join(".claude").join("skills"),
2881            SkillOrigin::SkillsDir,
2882        );
2883        push_unique_skill_root(
2884            &mut roots,
2885            DefinitionSource::ProjectClaw,
2886            ancestor.join(".claw").join("commands"),
2887            SkillOrigin::LegacyCommandsDir,
2888        );
2889        push_unique_skill_root(
2890            &mut roots,
2891            DefinitionSource::ProjectCodex,
2892            ancestor.join(".codex").join("commands"),
2893            SkillOrigin::LegacyCommandsDir,
2894        );
2895        push_unique_skill_root(
2896            &mut roots,
2897            DefinitionSource::ProjectClaude,
2898            ancestor.join(".claude").join("commands"),
2899            SkillOrigin::LegacyCommandsDir,
2900        );
2901    }
2902
2903    if let Ok(claw_config_home) = env::var("CLAW_CONFIG_HOME") {
2904        let claw_config_home = PathBuf::from(claw_config_home);
2905        push_unique_skill_root(
2906            &mut roots,
2907            DefinitionSource::UserClawConfigHome,
2908            claw_config_home.join("skills"),
2909            SkillOrigin::SkillsDir,
2910        );
2911        push_unique_skill_root(
2912            &mut roots,
2913            DefinitionSource::UserClawConfigHome,
2914            claw_config_home.join("commands"),
2915            SkillOrigin::LegacyCommandsDir,
2916        );
2917    }
2918
2919    if let Ok(codex_home) = env::var("CODEX_HOME") {
2920        let codex_home = PathBuf::from(codex_home);
2921        push_unique_skill_root(
2922            &mut roots,
2923            DefinitionSource::UserCodexHome,
2924            codex_home.join("skills"),
2925            SkillOrigin::SkillsDir,
2926        );
2927        push_unique_skill_root(
2928            &mut roots,
2929            DefinitionSource::UserCodexHome,
2930            codex_home.join("commands"),
2931            SkillOrigin::LegacyCommandsDir,
2932        );
2933    }
2934
2935    if let Some(home) = env::var_os("HOME") {
2936        let home = PathBuf::from(home);
2937        push_unique_skill_root(
2938            &mut roots,
2939            DefinitionSource::UserClaw,
2940            home.join(".claw").join("skills"),
2941            SkillOrigin::SkillsDir,
2942        );
2943        push_unique_skill_root(
2944            &mut roots,
2945            DefinitionSource::UserClaw,
2946            home.join(".omc").join("skills"),
2947            SkillOrigin::SkillsDir,
2948        );
2949        push_unique_skill_root(
2950            &mut roots,
2951            DefinitionSource::UserClaw,
2952            home.join(".claw").join("commands"),
2953            SkillOrigin::LegacyCommandsDir,
2954        );
2955        push_unique_skill_root(
2956            &mut roots,
2957            DefinitionSource::UserCodex,
2958            home.join(".codex").join("skills"),
2959            SkillOrigin::SkillsDir,
2960        );
2961        push_unique_skill_root(
2962            &mut roots,
2963            DefinitionSource::UserCodex,
2964            home.join(".codex").join("commands"),
2965            SkillOrigin::LegacyCommandsDir,
2966        );
2967        push_unique_skill_root(
2968            &mut roots,
2969            DefinitionSource::UserClaude,
2970            home.join(".claude").join("skills"),
2971            SkillOrigin::SkillsDir,
2972        );
2973        push_unique_skill_root(
2974            &mut roots,
2975            DefinitionSource::UserClaude,
2976            home.join(".claude").join("skills").join("omc-learned"),
2977            SkillOrigin::SkillsDir,
2978        );
2979        push_unique_skill_root(
2980            &mut roots,
2981            DefinitionSource::UserClaude,
2982            home.join(".claude").join("commands"),
2983            SkillOrigin::LegacyCommandsDir,
2984        );
2985    }
2986
2987    if let Ok(claude_config_dir) = env::var("CLAUDE_CONFIG_DIR") {
2988        let claude_config_dir = PathBuf::from(claude_config_dir);
2989        let skills_dir = claude_config_dir.join("skills");
2990        push_unique_skill_root(
2991            &mut roots,
2992            DefinitionSource::UserClaude,
2993            skills_dir.clone(),
2994            SkillOrigin::SkillsDir,
2995        );
2996        push_unique_skill_root(
2997            &mut roots,
2998            DefinitionSource::UserClaude,
2999            skills_dir.join("omc-learned"),
3000            SkillOrigin::SkillsDir,
3001        );
3002        push_unique_skill_root(
3003            &mut roots,
3004            DefinitionSource::UserClaude,
3005            claude_config_dir.join("commands"),
3006            SkillOrigin::LegacyCommandsDir,
3007        );
3008    }
3009
3010    roots
3011}
3012
3013fn install_skill(source: &str, cwd: &Path) -> std::io::Result<InstalledSkill> {
3014    let registry_root = default_skill_install_root()?;
3015    install_skill_into(source, cwd, &registry_root)
3016}
3017
3018fn install_skill_into(
3019    source: &str,
3020    cwd: &Path,
3021    registry_root: &Path,
3022) -> std::io::Result<InstalledSkill> {
3023    let source = resolve_skill_install_source(source, cwd)?;
3024    let prompt_path = source.prompt_path();
3025    let contents = fs::read_to_string(prompt_path)?;
3026    let display_name = parse_skill_frontmatter(&contents).0;
3027    let invocation_name = derive_skill_install_name(&source, display_name.as_deref())?;
3028    let installed_path = registry_root.join(&invocation_name);
3029
3030    if installed_path.exists() {
3031        return Err(std::io::Error::new(
3032            std::io::ErrorKind::AlreadyExists,
3033            format!(
3034                "skill '{invocation_name}' is already installed at {}",
3035                installed_path.display()
3036            ),
3037        ));
3038    }
3039
3040    fs::create_dir_all(&installed_path)?;
3041    let install_result = match &source {
3042        SkillInstallSource::Directory { root, .. } => {
3043            copy_directory_contents(root, &installed_path)
3044        }
3045        SkillInstallSource::MarkdownFile { path } => {
3046            fs::copy(path, installed_path.join("SKILL.md")).map(|_| ())
3047        }
3048    };
3049    if let Err(error) = install_result {
3050        let _ = fs::remove_dir_all(&installed_path);
3051        return Err(error);
3052    }
3053
3054    Ok(InstalledSkill {
3055        invocation_name,
3056        display_name,
3057        source: source.report_path().to_path_buf(),
3058        registry_root: registry_root.to_path_buf(),
3059        installed_path,
3060    })
3061}
3062
3063fn default_skill_install_root() -> std::io::Result<PathBuf> {
3064    if let Ok(claw_config_home) = env::var("CLAW_CONFIG_HOME") {
3065        return Ok(PathBuf::from(claw_config_home).join("skills"));
3066    }
3067    if let Ok(codex_home) = env::var("CODEX_HOME") {
3068        return Ok(PathBuf::from(codex_home).join("skills"));
3069    }
3070    if let Some(home) = env::var_os("HOME") {
3071        return Ok(PathBuf::from(home).join(".claw").join("skills"));
3072    }
3073    Err(std::io::Error::new(
3074        std::io::ErrorKind::NotFound,
3075        "unable to resolve a skills install root; set CLAW_CONFIG_HOME or HOME",
3076    ))
3077}
3078
3079fn resolve_skill_install_source(source: &str, cwd: &Path) -> std::io::Result<SkillInstallSource> {
3080    let candidate = PathBuf::from(source);
3081    let source = if candidate.is_absolute() {
3082        candidate
3083    } else {
3084        cwd.join(candidate)
3085    };
3086    let source = fs::canonicalize(&source)?;
3087
3088    if source.is_dir() {
3089        let prompt_path = source.join("SKILL.md");
3090        if !prompt_path.is_file() {
3091            return Err(std::io::Error::new(
3092                std::io::ErrorKind::InvalidInput,
3093                format!(
3094                    "skill directory '{}' must contain SKILL.md",
3095                    source.display()
3096                ),
3097            ));
3098        }
3099        return Ok(SkillInstallSource::Directory {
3100            root: source,
3101            prompt_path,
3102        });
3103    }
3104
3105    if source
3106        .extension()
3107        .is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md"))
3108    {
3109        return Ok(SkillInstallSource::MarkdownFile { path: source });
3110    }
3111
3112    Err(std::io::Error::new(
3113        std::io::ErrorKind::InvalidInput,
3114        format!(
3115            "skill source '{}' must be a directory with SKILL.md or a markdown file",
3116            source.display()
3117        ),
3118    ))
3119}
3120
3121fn derive_skill_install_name(
3122    source: &SkillInstallSource,
3123    declared_name: Option<&str>,
3124) -> std::io::Result<String> {
3125    for candidate in [declared_name, source.fallback_name().as_deref()] {
3126        if let Some(candidate) = candidate.and_then(sanitize_skill_invocation_name) {
3127            return Ok(candidate);
3128        }
3129    }
3130
3131    Err(std::io::Error::new(
3132        std::io::ErrorKind::InvalidInput,
3133        format!(
3134            "unable to derive an installable invocation name from '{}'",
3135            source.report_path().display()
3136        ),
3137    ))
3138}
3139
3140fn sanitize_skill_invocation_name(candidate: &str) -> Option<String> {
3141    let trimmed = candidate
3142        .trim()
3143        .trim_start_matches('/')
3144        .trim_start_matches('$');
3145    if trimmed.is_empty() {
3146        return None;
3147    }
3148
3149    let mut sanitized = String::new();
3150    let mut last_was_separator = false;
3151    for ch in trimmed.chars() {
3152        if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
3153            sanitized.push(ch.to_ascii_lowercase());
3154            last_was_separator = false;
3155        } else if (ch.is_whitespace() || matches!(ch, '/' | '\\'))
3156            && !last_was_separator
3157            && !sanitized.is_empty()
3158        {
3159            sanitized.push('-');
3160            last_was_separator = true;
3161        }
3162    }
3163
3164    let sanitized = sanitized
3165        .trim_matches(|ch| matches!(ch, '-' | '_' | '.'))
3166        .to_string();
3167    (!sanitized.is_empty()).then_some(sanitized)
3168}
3169
3170fn copy_directory_contents(source: &Path, destination: &Path) -> std::io::Result<()> {
3171    for entry in fs::read_dir(source)? {
3172        let entry = entry?;
3173        let entry_type = entry.file_type()?;
3174        let destination_path = destination.join(entry.file_name());
3175        if entry_type.is_dir() {
3176            fs::create_dir_all(&destination_path)?;
3177            copy_directory_contents(&entry.path(), &destination_path)?;
3178        } else {
3179            fs::copy(entry.path(), destination_path)?;
3180        }
3181    }
3182    Ok(())
3183}
3184
3185impl SkillInstallSource {
3186    fn prompt_path(&self) -> &Path {
3187        match self {
3188            Self::Directory { prompt_path, .. } => prompt_path,
3189            Self::MarkdownFile { path } => path,
3190        }
3191    }
3192
3193    fn fallback_name(&self) -> Option<String> {
3194        match self {
3195            Self::Directory { root, .. } => root
3196                .file_name()
3197                .map(|name| name.to_string_lossy().to_string()),
3198            Self::MarkdownFile { path } => path
3199                .file_stem()
3200                .map(|name| name.to_string_lossy().to_string()),
3201        }
3202    }
3203
3204    fn report_path(&self) -> &Path {
3205        match self {
3206            Self::Directory { root, .. } => root,
3207            Self::MarkdownFile { path } => path,
3208        }
3209    }
3210}
3211
3212fn push_unique_root(
3213    roots: &mut Vec<(DefinitionSource, PathBuf)>,
3214    source: DefinitionSource,
3215    path: PathBuf,
3216) {
3217    if path.is_dir() && !roots.iter().any(|(_, existing)| existing == &path) {
3218        roots.push((source, path));
3219    }
3220}
3221
3222fn push_unique_skill_root(
3223    roots: &mut Vec<SkillRoot>,
3224    source: DefinitionSource,
3225    path: PathBuf,
3226    origin: SkillOrigin,
3227) {
3228    if path.is_dir() && !roots.iter().any(|existing| existing.path == path) {
3229        roots.push(SkillRoot {
3230            source,
3231            path,
3232            origin,
3233        });
3234    }
3235}
3236
3237fn load_agents_from_roots(
3238    roots: &[(DefinitionSource, PathBuf)],
3239) -> std::io::Result<Vec<AgentSummary>> {
3240    let mut agents = Vec::new();
3241    let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
3242
3243    for (source, root) in roots {
3244        let mut root_agents = Vec::new();
3245        for entry in fs::read_dir(root)? {
3246            let entry = entry?;
3247            if entry.path().extension().is_none_or(|ext| ext != "toml") {
3248                continue;
3249            }
3250            let contents = fs::read_to_string(entry.path())?;
3251            let fallback_name = entry.path().file_stem().map_or_else(
3252                || entry.file_name().to_string_lossy().to_string(),
3253                |stem| stem.to_string_lossy().to_string(),
3254            );
3255            root_agents.push(AgentSummary {
3256                name: parse_toml_string(&contents, "name").unwrap_or(fallback_name),
3257                description: parse_toml_string(&contents, "description"),
3258                model: parse_toml_string(&contents, "model"),
3259                reasoning_effort: parse_toml_string(&contents, "model_reasoning_effort"),
3260                source: *source,
3261                shadowed_by: None,
3262            });
3263        }
3264        root_agents.sort_by(|left, right| left.name.cmp(&right.name));
3265
3266        for mut agent in root_agents {
3267            let key = agent.name.to_ascii_lowercase();
3268            if let Some(existing) = active_sources.get(&key) {
3269                agent.shadowed_by = Some(*existing);
3270            } else {
3271                active_sources.insert(key, agent.source);
3272            }
3273            agents.push(agent);
3274        }
3275    }
3276
3277    Ok(agents)
3278}
3279
3280fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSummary>> {
3281    let mut skills = Vec::new();
3282    let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
3283
3284    for root in roots {
3285        let mut root_skills = Vec::new();
3286        for entry in fs::read_dir(&root.path)? {
3287            let entry = entry?;
3288            match root.origin {
3289                SkillOrigin::SkillsDir => {
3290                    if !entry.path().is_dir() {
3291                        continue;
3292                    }
3293                    let skill_path = entry.path().join("SKILL.md");
3294                    if !skill_path.is_file() {
3295                        continue;
3296                    }
3297                    let contents = fs::read_to_string(skill_path)?;
3298                    let (name, description) = parse_skill_frontmatter(&contents);
3299                    root_skills.push(SkillSummary {
3300                        name: name
3301                            .unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()),
3302                        description,
3303                        source: root.source,
3304                        shadowed_by: None,
3305                        origin: root.origin,
3306                    });
3307                }
3308                SkillOrigin::LegacyCommandsDir => {
3309                    let path = entry.path();
3310                    let markdown_path = if path.is_dir() {
3311                        let skill_path = path.join("SKILL.md");
3312                        if !skill_path.is_file() {
3313                            continue;
3314                        }
3315                        skill_path
3316                    } else if path
3317                        .extension()
3318                        .is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md"))
3319                    {
3320                        path
3321                    } else {
3322                        continue;
3323                    };
3324
3325                    let contents = fs::read_to_string(&markdown_path)?;
3326                    let fallback_name = markdown_path.file_stem().map_or_else(
3327                        || entry.file_name().to_string_lossy().to_string(),
3328                        |stem| stem.to_string_lossy().to_string(),
3329                    );
3330                    let (name, description) = parse_skill_frontmatter(&contents);
3331                    root_skills.push(SkillSummary {
3332                        name: name.unwrap_or(fallback_name),
3333                        description,
3334                        source: root.source,
3335                        shadowed_by: None,
3336                        origin: root.origin,
3337                    });
3338                }
3339            }
3340        }
3341        root_skills.sort_by(|left, right| left.name.cmp(&right.name));
3342
3343        for mut skill in root_skills {
3344            let key = skill.name.to_ascii_lowercase();
3345            if let Some(existing) = active_sources.get(&key) {
3346                skill.shadowed_by = Some(*existing);
3347            } else {
3348                active_sources.insert(key, skill.source);
3349            }
3350            skills.push(skill);
3351        }
3352    }
3353
3354    Ok(skills)
3355}
3356
3357fn parse_toml_string(contents: &str, key: &str) -> Option<String> {
3358    let prefix = format!("{key} =");
3359    for line in contents.lines() {
3360        let trimmed = line.trim();
3361        if trimmed.starts_with('#') {
3362            continue;
3363        }
3364        let Some(value) = trimmed.strip_prefix(&prefix) else {
3365            continue;
3366        };
3367        let value = value.trim();
3368        let Some(value) = value
3369            .strip_prefix('"')
3370            .and_then(|value| value.strip_suffix('"'))
3371        else {
3372            continue;
3373        };
3374        if !value.is_empty() {
3375            return Some(value.to_string());
3376        }
3377    }
3378    None
3379}
3380
3381fn parse_skill_frontmatter(contents: &str) -> (Option<String>, Option<String>) {
3382    let mut lines = contents.lines();
3383    if lines.next().map(str::trim) != Some("---") {
3384        return (None, None);
3385    }
3386
3387    let mut name = None;
3388    let mut description = None;
3389    for line in lines {
3390        let trimmed = line.trim();
3391        if trimmed == "---" {
3392            break;
3393        }
3394        if let Some(value) = trimmed.strip_prefix("name:") {
3395            let value = unquote_frontmatter_value(value.trim());
3396            if !value.is_empty() {
3397                name = Some(value);
3398            }
3399            continue;
3400        }
3401        if let Some(value) = trimmed.strip_prefix("description:") {
3402            let value = unquote_frontmatter_value(value.trim());
3403            if !value.is_empty() {
3404                description = Some(value);
3405            }
3406        }
3407    }
3408
3409    (name, description)
3410}
3411
3412fn unquote_frontmatter_value(value: &str) -> String {
3413    value
3414        .strip_prefix('"')
3415        .and_then(|trimmed| trimmed.strip_suffix('"'))
3416        .or_else(|| {
3417            value
3418                .strip_prefix('\'')
3419                .and_then(|trimmed| trimmed.strip_suffix('\''))
3420        })
3421        .unwrap_or(value)
3422        .trim()
3423        .to_string()
3424}
3425
3426fn render_agents_report(agents: &[AgentSummary]) -> String {
3427    if agents.is_empty() {
3428        return "No agents found.".to_string();
3429    }
3430
3431    let total_active = agents
3432        .iter()
3433        .filter(|agent| agent.shadowed_by.is_none())
3434        .count();
3435    let mut lines = vec![
3436        "Agents".to_string(),
3437        format!("  {total_active} active agents"),
3438        String::new(),
3439    ];
3440
3441    for scope in [
3442        DefinitionScope::Project,
3443        DefinitionScope::UserConfigHome,
3444        DefinitionScope::UserHome,
3445    ] {
3446        let group = agents
3447            .iter()
3448            .filter(|agent| agent.source.report_scope() == scope)
3449            .collect::<Vec<_>>();
3450        if group.is_empty() {
3451            continue;
3452        }
3453
3454        lines.push(format!("{}:", scope.label()));
3455        for agent in group {
3456            let detail = agent_detail(agent);
3457            match agent.shadowed_by {
3458                Some(winner) => lines.push(format!("  (shadowed by {}) {detail}", winner.label())),
3459                None => lines.push(format!("  {detail}")),
3460            }
3461        }
3462        lines.push(String::new());
3463    }
3464
3465    lines.join("\n").trim_end().to_string()
3466}
3467
3468fn render_agents_report_json(cwd: &Path, agents: &[AgentSummary]) -> Value {
3469    let active = agents
3470        .iter()
3471        .filter(|agent| agent.shadowed_by.is_none())
3472        .count();
3473    json!({
3474        "kind": "agents",
3475        "action": "list",
3476        "working_directory": cwd.display().to_string(),
3477        "count": agents.len(),
3478        "summary": {
3479            "total": agents.len(),
3480            "active": active,
3481            "shadowed": agents.len().saturating_sub(active),
3482        },
3483        "agents": agents.iter().map(agent_summary_json).collect::<Vec<_>>(),
3484    })
3485}
3486
3487fn agent_detail(agent: &AgentSummary) -> String {
3488    let mut parts = vec![agent.name.clone()];
3489    if let Some(description) = &agent.description {
3490        parts.push(description.clone());
3491    }
3492    if let Some(model) = &agent.model {
3493        parts.push(model.clone());
3494    }
3495    if let Some(reasoning) = &agent.reasoning_effort {
3496        parts.push(reasoning.clone());
3497    }
3498    parts.join(" · ")
3499}
3500
3501fn render_skills_report(skills: &[SkillSummary]) -> String {
3502    if skills.is_empty() {
3503        return "No skills found.".to_string();
3504    }
3505
3506    let total_active = skills
3507        .iter()
3508        .filter(|skill| skill.shadowed_by.is_none())
3509        .count();
3510    let mut lines = vec![
3511        "Skills".to_string(),
3512        format!("  {total_active} available skills"),
3513        String::new(),
3514    ];
3515
3516    for scope in [
3517        DefinitionScope::Project,
3518        DefinitionScope::UserConfigHome,
3519        DefinitionScope::UserHome,
3520    ] {
3521        let group = skills
3522            .iter()
3523            .filter(|skill| skill.source.report_scope() == scope)
3524            .collect::<Vec<_>>();
3525        if group.is_empty() {
3526            continue;
3527        }
3528
3529        lines.push(format!("{}:", scope.label()));
3530        for skill in group {
3531            let mut parts = vec![skill.name.clone()];
3532            if let Some(description) = &skill.description {
3533                parts.push(description.clone());
3534            }
3535            if let Some(detail) = skill.origin.detail_label() {
3536                parts.push(detail.to_string());
3537            }
3538            let detail = parts.join(" · ");
3539            match skill.shadowed_by {
3540                Some(winner) => lines.push(format!("  (shadowed by {}) {detail}", winner.label())),
3541                None => lines.push(format!("  {detail}")),
3542            }
3543        }
3544        lines.push(String::new());
3545    }
3546
3547    lines.join("\n").trim_end().to_string()
3548}
3549
3550fn render_skills_report_json(skills: &[SkillSummary]) -> Value {
3551    let active = skills
3552        .iter()
3553        .filter(|skill| skill.shadowed_by.is_none())
3554        .count();
3555    json!({
3556        "kind": "skills",
3557        "action": "list",
3558        "summary": {
3559            "total": skills.len(),
3560            "active": active,
3561            "shadowed": skills.len().saturating_sub(active),
3562        },
3563        "skills": skills.iter().map(skill_summary_json).collect::<Vec<_>>(),
3564    })
3565}
3566
3567fn render_skill_install_report(skill: &InstalledSkill) -> String {
3568    let mut lines = vec![
3569        "Skills".to_string(),
3570        format!("  Result           installed {}", skill.invocation_name),
3571        format!("  Invoke as        ${}", skill.invocation_name),
3572    ];
3573    if let Some(display_name) = &skill.display_name {
3574        lines.push(format!("  Display name     {display_name}"));
3575    }
3576    lines.push(format!("  Source           {}", skill.source.display()));
3577    lines.push(format!(
3578        "  Registry         {}",
3579        skill.registry_root.display()
3580    ));
3581    lines.push(format!(
3582        "  Installed path   {}",
3583        skill.installed_path.display()
3584    ));
3585    lines.join("\n")
3586}
3587
3588fn render_skill_install_report_json(skill: &InstalledSkill) -> Value {
3589    json!({
3590        "kind": "skills",
3591        "action": "install",
3592        "result": "installed",
3593        "invocation_name": &skill.invocation_name,
3594        "invoke_as": format!("${}", skill.invocation_name),
3595        "display_name": &skill.display_name,
3596        "source": skill.source.display().to_string(),
3597        "registry_root": skill.registry_root.display().to_string(),
3598        "installed_path": skill.installed_path.display().to_string(),
3599    })
3600}
3601
3602fn render_mcp_summary_report(
3603    cwd: &Path,
3604    servers: &BTreeMap<String, ScopedMcpServerConfig>,
3605) -> String {
3606    let mut lines = vec![
3607        "MCP".to_string(),
3608        format!("  Working directory {}", cwd.display()),
3609        format!("  Configured servers {}", servers.len()),
3610    ];
3611    if servers.is_empty() {
3612        lines.push("  No MCP servers configured.".to_string());
3613        return lines.join("\n");
3614    }
3615
3616    lines.push(String::new());
3617    for (name, server) in servers {
3618        lines.push(format!(
3619            "  {name:<16} {transport:<13} {scope:<7} {summary}",
3620            transport = mcp_transport_label(&server.config),
3621            scope = config_source_label(server.scope),
3622            summary = mcp_server_summary(&server.config)
3623        ));
3624    }
3625
3626    lines.join("\n")
3627}
3628
3629fn render_mcp_summary_report_json(
3630    cwd: &Path,
3631    servers: &BTreeMap<String, ScopedMcpServerConfig>,
3632) -> Value {
3633    json!({
3634        "kind": "mcp",
3635        "action": "list",
3636        "working_directory": cwd.display().to_string(),
3637        "configured_servers": servers.len(),
3638        "servers": servers
3639            .iter()
3640            .map(|(name, server)| mcp_server_json(name, server))
3641            .collect::<Vec<_>>(),
3642    })
3643}
3644
3645fn render_mcp_server_report(
3646    cwd: &Path,
3647    server_name: &str,
3648    server: Option<&ScopedMcpServerConfig>,
3649) -> String {
3650    let Some(server) = server else {
3651        return format!(
3652            "MCP\n  Working directory {}\n  Result            server `{server_name}` is not configured",
3653            cwd.display()
3654        );
3655    };
3656
3657    let mut lines = vec![
3658        "MCP".to_string(),
3659        format!("  Working directory {}", cwd.display()),
3660        format!("  Name              {server_name}"),
3661        format!("  Scope             {}", config_source_label(server.scope)),
3662        format!(
3663            "  Transport         {}",
3664            mcp_transport_label(&server.config)
3665        ),
3666    ];
3667
3668    match &server.config {
3669        McpServerConfig::Stdio(config) => {
3670            lines.push(format!("  Command           {}", config.command));
3671            lines.push(format!(
3672                "  Args              {}",
3673                format_optional_list(&config.args)
3674            ));
3675            lines.push(format!(
3676                "  Env keys          {}",
3677                format_optional_keys(config.env.keys().cloned().collect())
3678            ));
3679            lines.push(format!(
3680                "  Tool timeout      {}",
3681                config
3682                    .tool_call_timeout_ms
3683                    .map_or_else(|| "<default>".to_string(), |value| format!("{value} ms"))
3684            ));
3685        }
3686        McpServerConfig::Sse(config) | McpServerConfig::Http(config) => {
3687            lines.push(format!("  URL               {}", config.url));
3688            lines.push(format!(
3689                "  Header keys       {}",
3690                format_optional_keys(config.headers.keys().cloned().collect())
3691            ));
3692            lines.push(format!(
3693                "  Header helper     {}",
3694                config.headers_helper.as_deref().unwrap_or("<none>")
3695            ));
3696            lines.push(format!(
3697                "  OAuth             {}",
3698                format_mcp_oauth(config.oauth.as_ref())
3699            ));
3700        }
3701        McpServerConfig::Ws(config) => {
3702            lines.push(format!("  URL               {}", config.url));
3703            lines.push(format!(
3704                "  Header keys       {}",
3705                format_optional_keys(config.headers.keys().cloned().collect())
3706            ));
3707            lines.push(format!(
3708                "  Header helper     {}",
3709                config.headers_helper.as_deref().unwrap_or("<none>")
3710            ));
3711        }
3712        McpServerConfig::Sdk(config) => {
3713            lines.push(format!("  SDK name          {}", config.name));
3714        }
3715        McpServerConfig::ManagedProxy(config) => {
3716            lines.push(format!("  URL               {}", config.url));
3717            lines.push(format!("  Proxy id          {}", config.id));
3718        }
3719    }
3720
3721    lines.join("\n")
3722}
3723
3724fn render_mcp_server_report_json(
3725    cwd: &Path,
3726    server_name: &str,
3727    server: Option<&ScopedMcpServerConfig>,
3728) -> Value {
3729    match server {
3730        Some(server) => json!({
3731            "kind": "mcp",
3732            "action": "show",
3733            "working_directory": cwd.display().to_string(),
3734            "found": true,
3735            "server": mcp_server_json(server_name, server),
3736        }),
3737        None => json!({
3738            "kind": "mcp",
3739            "action": "show",
3740            "working_directory": cwd.display().to_string(),
3741            "found": false,
3742            "server_name": server_name,
3743            "message": format!("server `{server_name}` is not configured"),
3744        }),
3745    }
3746}
3747
3748fn normalize_optional_args(args: Option<&str>) -> Option<&str> {
3749    args.map(str::trim).filter(|value| !value.is_empty())
3750}
3751
3752fn is_help_arg(arg: &str) -> bool {
3753    matches!(arg, "help" | "-h" | "--help")
3754}
3755
3756fn help_path_from_args(args: &str) -> Option<Vec<&str>> {
3757    let parts = args.split_whitespace().collect::<Vec<_>>();
3758    let help_index = parts.iter().position(|part| is_help_arg(part))?;
3759    Some(parts[..help_index].to_vec())
3760}
3761
3762fn render_agents_usage(unexpected: Option<&str>) -> String {
3763    let mut lines = vec![
3764        "Agents".to_string(),
3765        "  Usage            /agents [list|help]".to_string(),
3766        "  Direct CLI       claw agents".to_string(),
3767        "  Sources          .claw/agents, ~/.claw/agents, $CLAW_CONFIG_HOME/agents".to_string(),
3768    ];
3769    if let Some(args) = unexpected {
3770        lines.push(format!("  Unexpected       {args}"));
3771    }
3772    lines.join("\n")
3773}
3774
3775fn render_agents_usage_json(unexpected: Option<&str>) -> Value {
3776    json!({
3777        "kind": "agents",
3778        "action": "help",
3779        "usage": {
3780            "slash_command": "/agents [list|help]",
3781            "direct_cli": "claw agents [list|help]",
3782            "sources": [".claw/agents", "~/.claw/agents", "$CLAW_CONFIG_HOME/agents"],
3783        },
3784        "unexpected": unexpected,
3785    })
3786}
3787
3788fn render_skills_usage(unexpected: Option<&str>) -> String {
3789    let mut lines = vec![
3790        "Skills".to_string(),
3791        "  Usage            /skills [list|install <path>|help|<skill> [args]]".to_string(),
3792        "  Alias            /skill".to_string(),
3793        "  Direct CLI       claw skills [list|install <path>|help|<skill> [args]]".to_string(),
3794        "  Invoke           /skills help overview -> $help overview".to_string(),
3795        "  Install root     $CLAW_CONFIG_HOME/skills or ~/.claw/skills".to_string(),
3796        "  Sources          .claw/skills, .omc/skills, .agents/skills, .codex/skills, .claude/skills, ~/.claw/skills, ~/.omc/skills, ~/.claude/skills/omc-learned, ~/.codex/skills, ~/.claude/skills, legacy /commands".to_string(),
3797    ];
3798    if let Some(args) = unexpected {
3799        lines.push(format!("  Unexpected       {args}"));
3800    }
3801    lines.join("\n")
3802}
3803
3804fn render_skills_usage_json(unexpected: Option<&str>) -> Value {
3805    json!({
3806        "kind": "skills",
3807        "action": "help",
3808        "usage": {
3809            "slash_command": "/skills [list|install <path>|help|<skill> [args]]",
3810            "aliases": ["/skill"],
3811            "direct_cli": "claw skills [list|install <path>|help|<skill> [args]]",
3812            "invoke": "/skills help overview -> $help overview",
3813            "install_root": "$CLAW_CONFIG_HOME/skills or ~/.claw/skills",
3814            "sources": [
3815                ".claw/skills",
3816                ".omc/skills",
3817                ".agents/skills",
3818                ".codex/skills",
3819                ".claude/skills",
3820                "~/.claw/skills",
3821                "~/.omc/skills",
3822                "~/.claude/skills/omc-learned",
3823                "~/.codex/skills",
3824                "~/.claude/skills",
3825                "legacy /commands",
3826                "legacy fallback dirs still load automatically"
3827            ],
3828        },
3829        "unexpected": unexpected,
3830    })
3831}
3832
3833fn render_mcp_usage(unexpected: Option<&str>) -> String {
3834    let mut lines = vec![
3835        "MCP".to_string(),
3836        "  Usage            /mcp [list|show <server>|help]".to_string(),
3837        "  Direct CLI       claw mcp [list|show <server>|help]".to_string(),
3838        "  Sources          .claw/settings.json, .claw/settings.local.json".to_string(),
3839    ];
3840    if let Some(args) = unexpected {
3841        lines.push(format!("  Unexpected       {args}"));
3842    }
3843    lines.join("\n")
3844}
3845
3846fn render_mcp_usage_json(unexpected: Option<&str>) -> Value {
3847    json!({
3848        "kind": "mcp",
3849        "action": "help",
3850        "usage": {
3851            "slash_command": "/mcp [list|show <server>|help]",
3852            "direct_cli": "claw mcp [list|show <server>|help]",
3853            "sources": [".claw/settings.json", ".claw/settings.local.json"],
3854        },
3855        "unexpected": unexpected,
3856    })
3857}
3858
3859fn config_source_label(source: ConfigSource) -> &'static str {
3860    match source {
3861        ConfigSource::User => "user",
3862        ConfigSource::Project => "project",
3863        ConfigSource::Local => "local",
3864    }
3865}
3866
3867fn mcp_transport_label(config: &McpServerConfig) -> &'static str {
3868    match config {
3869        McpServerConfig::Stdio(_) => "stdio",
3870        McpServerConfig::Sse(_) => "sse",
3871        McpServerConfig::Http(_) => "http",
3872        McpServerConfig::Ws(_) => "ws",
3873        McpServerConfig::Sdk(_) => "sdk",
3874        McpServerConfig::ManagedProxy(_) => "managed-proxy",
3875    }
3876}
3877
3878fn mcp_server_summary(config: &McpServerConfig) -> String {
3879    match config {
3880        McpServerConfig::Stdio(config) => {
3881            if config.args.is_empty() {
3882                config.command.clone()
3883            } else {
3884                format!("{} {}", config.command, config.args.join(" "))
3885            }
3886        }
3887        McpServerConfig::Sse(config) | McpServerConfig::Http(config) => config.url.clone(),
3888        McpServerConfig::Ws(config) => config.url.clone(),
3889        McpServerConfig::Sdk(config) => config.name.clone(),
3890        McpServerConfig::ManagedProxy(config) => format!("{} ({})", config.id, config.url),
3891    }
3892}
3893
3894fn format_optional_list(values: &[String]) -> String {
3895    if values.is_empty() {
3896        "<none>".to_string()
3897    } else {
3898        values.join(" ")
3899    }
3900}
3901
3902fn format_optional_keys(mut keys: Vec<String>) -> String {
3903    if keys.is_empty() {
3904        return "<none>".to_string();
3905    }
3906    keys.sort();
3907    keys.join(", ")
3908}
3909
3910fn format_mcp_oauth(oauth: Option<&McpOAuthConfig>) -> String {
3911    let Some(oauth) = oauth else {
3912        return "<none>".to_string();
3913    };
3914
3915    let mut parts = Vec::new();
3916    if let Some(client_id) = &oauth.client_id {
3917        parts.push(format!("client_id={client_id}"));
3918    }
3919    if let Some(port) = oauth.callback_port {
3920        parts.push(format!("callback_port={port}"));
3921    }
3922    if let Some(url) = &oauth.auth_server_metadata_url {
3923        parts.push(format!("metadata_url={url}"));
3924    }
3925    if let Some(xaa) = oauth.xaa {
3926        parts.push(format!("xaa={xaa}"));
3927    }
3928    if parts.is_empty() {
3929        "enabled".to_string()
3930    } else {
3931        parts.join(", ")
3932    }
3933}
3934
3935fn definition_source_id(source: DefinitionSource) -> &'static str {
3936    match source {
3937        DefinitionSource::ProjectClaw
3938        | DefinitionSource::ProjectCodex
3939        | DefinitionSource::ProjectClaude => "project_claw",
3940        DefinitionSource::UserClawConfigHome | DefinitionSource::UserCodexHome => {
3941            "user_claw_config_home"
3942        }
3943        DefinitionSource::UserClaw | DefinitionSource::UserCodex | DefinitionSource::UserClaude => {
3944            "user_claw"
3945        }
3946    }
3947}
3948
3949fn definition_source_json(source: DefinitionSource) -> Value {
3950    json!({
3951        "id": definition_source_id(source),
3952        "label": source.label(),
3953    })
3954}
3955
3956fn agent_summary_json(agent: &AgentSummary) -> Value {
3957    json!({
3958        "name": &agent.name,
3959        "description": &agent.description,
3960        "model": &agent.model,
3961        "reasoning_effort": &agent.reasoning_effort,
3962        "source": definition_source_json(agent.source),
3963        "active": agent.shadowed_by.is_none(),
3964        "shadowed_by": agent.shadowed_by.map(definition_source_json),
3965    })
3966}
3967
3968fn skill_origin_id(origin: SkillOrigin) -> &'static str {
3969    match origin {
3970        SkillOrigin::SkillsDir => "skills_dir",
3971        SkillOrigin::LegacyCommandsDir => "legacy_commands_dir",
3972    }
3973}
3974
3975fn skill_origin_json(origin: SkillOrigin) -> Value {
3976    json!({
3977        "id": skill_origin_id(origin),
3978        "detail_label": origin.detail_label(),
3979    })
3980}
3981
3982fn skill_summary_json(skill: &SkillSummary) -> Value {
3983    json!({
3984        "name": &skill.name,
3985        "description": &skill.description,
3986        "source": definition_source_json(skill.source),
3987        "origin": skill_origin_json(skill.origin),
3988        "active": skill.shadowed_by.is_none(),
3989        "shadowed_by": skill.shadowed_by.map(definition_source_json),
3990    })
3991}
3992
3993fn config_source_id(source: ConfigSource) -> &'static str {
3994    match source {
3995        ConfigSource::User => "user",
3996        ConfigSource::Project => "project",
3997        ConfigSource::Local => "local",
3998    }
3999}
4000
4001fn config_source_json(source: ConfigSource) -> Value {
4002    json!({
4003        "id": config_source_id(source),
4004        "label": config_source_label(source),
4005    })
4006}
4007
4008fn mcp_transport_json(config: &McpServerConfig) -> Value {
4009    let label = mcp_transport_label(config);
4010    json!({
4011        "id": label,
4012        "label": label,
4013    })
4014}
4015
4016fn mcp_oauth_json(oauth: Option<&McpOAuthConfig>) -> Value {
4017    let Some(oauth) = oauth else {
4018        return Value::Null;
4019    };
4020    json!({
4021        "client_id": &oauth.client_id,
4022        "callback_port": oauth.callback_port,
4023        "auth_server_metadata_url": &oauth.auth_server_metadata_url,
4024        "xaa": oauth.xaa,
4025    })
4026}
4027
4028fn mcp_server_details_json(config: &McpServerConfig) -> Value {
4029    match config {
4030        McpServerConfig::Stdio(config) => json!({
4031            "command": &config.command,
4032            "args": &config.args,
4033            "env_keys": config.env.keys().cloned().collect::<Vec<_>>(),
4034            "tool_call_timeout_ms": config.tool_call_timeout_ms,
4035        }),
4036        McpServerConfig::Sse(config) | McpServerConfig::Http(config) => json!({
4037            "url": &config.url,
4038            "header_keys": config.headers.keys().cloned().collect::<Vec<_>>(),
4039            "headers_helper": &config.headers_helper,
4040            "oauth": mcp_oauth_json(config.oauth.as_ref()),
4041        }),
4042        McpServerConfig::Ws(config) => json!({
4043            "url": &config.url,
4044            "header_keys": config.headers.keys().cloned().collect::<Vec<_>>(),
4045            "headers_helper": &config.headers_helper,
4046        }),
4047        McpServerConfig::Sdk(config) => json!({
4048            "name": &config.name,
4049        }),
4050        McpServerConfig::ManagedProxy(config) => json!({
4051            "url": &config.url,
4052            "id": &config.id,
4053        }),
4054    }
4055}
4056
4057fn mcp_server_json(name: &str, server: &ScopedMcpServerConfig) -> Value {
4058    json!({
4059        "name": name,
4060        "scope": config_source_json(server.scope),
4061        "transport": mcp_transport_json(&server.config),
4062        "summary": mcp_server_summary(&server.config),
4063        "details": mcp_server_details_json(&server.config),
4064    })
4065}
4066
4067#[must_use]
4068pub fn handle_slash_command(
4069    input: &str,
4070    session: &Session,
4071    compaction: CompactionConfig,
4072) -> Option<SlashCommandResult> {
4073    let command = match SlashCommand::parse(input) {
4074        Ok(Some(command)) => command,
4075        Ok(None) => return None,
4076        Err(error) => {
4077            return Some(SlashCommandResult {
4078                message: error.to_string(),
4079                session: session.clone(),
4080            });
4081        }
4082    };
4083
4084    match command {
4085        SlashCommand::Compact => {
4086            let result = compact_session(session, compaction);
4087            let message = if result.removed_message_count == 0 {
4088                "Compaction skipped: session is below the compaction threshold.".to_string()
4089            } else {
4090                format!(
4091                    "Compacted {} messages into a resumable system summary.",
4092                    result.removed_message_count
4093                )
4094            };
4095            Some(SlashCommandResult {
4096                message,
4097                session: result.compacted_session,
4098            })
4099        }
4100        SlashCommand::Help => Some(SlashCommandResult {
4101            message: render_slash_command_help(),
4102            session: session.clone(),
4103        }),
4104        SlashCommand::Status
4105        | SlashCommand::Bughunter { .. }
4106        | SlashCommand::Commit
4107        | SlashCommand::Pr { .. }
4108        | SlashCommand::Issue { .. }
4109        | SlashCommand::Ultraplan { .. }
4110        | SlashCommand::Teleport { .. }
4111        | SlashCommand::DebugToolCall
4112        | SlashCommand::Sandbox
4113        | SlashCommand::Model { .. }
4114        | SlashCommand::Permissions { .. }
4115        | SlashCommand::Clear { .. }
4116        | SlashCommand::Cost
4117        | SlashCommand::Resume { .. }
4118        | SlashCommand::Config { .. }
4119        | SlashCommand::Mcp { .. }
4120        | SlashCommand::Memory
4121        | SlashCommand::Init
4122        | SlashCommand::Diff
4123        | SlashCommand::Version
4124        | SlashCommand::Export { .. }
4125        | SlashCommand::Session { .. }
4126        | SlashCommand::Plugins { .. }
4127        | SlashCommand::Agents { .. }
4128        | SlashCommand::Skills { .. }
4129        | SlashCommand::Doctor
4130        | SlashCommand::Login
4131        | SlashCommand::Logout
4132        | SlashCommand::Vim
4133        | SlashCommand::Upgrade
4134        | SlashCommand::Stats
4135        | SlashCommand::Share
4136        | SlashCommand::Feedback
4137        | SlashCommand::Files
4138        | SlashCommand::Fast
4139        | SlashCommand::Exit
4140        | SlashCommand::Summary
4141        | SlashCommand::Desktop
4142        | SlashCommand::Brief
4143        | SlashCommand::Advisor
4144        | SlashCommand::Stickers
4145        | SlashCommand::Insights
4146        | SlashCommand::Thinkback
4147        | SlashCommand::ReleaseNotes
4148        | SlashCommand::SecurityReview
4149        | SlashCommand::Keybindings
4150        | SlashCommand::PrivacySettings
4151        | SlashCommand::Plan { .. }
4152        | SlashCommand::Review { .. }
4153        | SlashCommand::Tasks { .. }
4154        | SlashCommand::Theme { .. }
4155        | SlashCommand::Voice { .. }
4156        | SlashCommand::Usage { .. }
4157        | SlashCommand::Rename { .. }
4158        | SlashCommand::Copy { .. }
4159        | SlashCommand::Hooks { .. }
4160        | SlashCommand::Context { .. }
4161        | SlashCommand::Color { .. }
4162        | SlashCommand::Effort { .. }
4163        | SlashCommand::Branch { .. }
4164        | SlashCommand::Rewind { .. }
4165        | SlashCommand::Ide { .. }
4166        | SlashCommand::Tag { .. }
4167        | SlashCommand::OutputStyle { .. }
4168        | SlashCommand::AddDir { .. }
4169        | SlashCommand::History { .. }
4170        | SlashCommand::Unknown(_) => None,
4171    }
4172}
4173
4174#[cfg(test)]
4175mod tests {
4176    use super::{
4177        classify_skills_slash_command, handle_agents_slash_command_json,
4178        handle_plugins_slash_command, handle_skills_slash_command_json, handle_slash_command,
4179        load_agents_from_roots, load_skills_from_roots, render_agents_report,
4180        render_agents_report_json, render_mcp_report_json_for, render_plugins_report,
4181        render_plugins_report_with_failures, render_skills_report, render_slash_command_help,
4182        render_slash_command_help_detail, resolve_skill_path, resume_supported_slash_commands,
4183        slash_command_specs, suggest_slash_commands, validate_slash_command_input,
4184        DefinitionSource, SkillOrigin, SkillRoot, SkillSlashDispatch, SlashCommand,
4185    };
4186    use ninmu_plugins::{
4187        PluginError, PluginKind, PluginLoadFailure, PluginManager, PluginManagerConfig,
4188        PluginMetadata, PluginSummary,
4189    };
4190    use ninmu_runtime::{
4191        CompactionConfig, ConfigLoader, ContentBlock, ConversationMessage, MessageRole, Session,
4192    };
4193    use std::ffi::OsString;
4194    use std::fs;
4195    use std::path::{Path, PathBuf};
4196    use std::sync::{Mutex, OnceLock};
4197    use std::time::{SystemTime, UNIX_EPOCH};
4198
4199    fn temp_dir(label: &str) -> PathBuf {
4200        let nanos = SystemTime::now()
4201            .duration_since(UNIX_EPOCH)
4202            .expect("time should be after epoch")
4203            .as_nanos();
4204        std::env::temp_dir().join(format!("commands-plugin-{label}-{nanos}"))
4205    }
4206
4207    fn env_lock() -> &'static Mutex<()> {
4208        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
4209        LOCK.get_or_init(|| Mutex::new(()))
4210    }
4211
4212    fn env_guard() -> std::sync::MutexGuard<'static, ()> {
4213        env_lock()
4214            .lock()
4215            .unwrap_or_else(std::sync::PoisonError::into_inner)
4216    }
4217
4218    #[test]
4219    fn env_guard_recovers_after_poisoning() {
4220        let poisoned = std::thread::spawn(|| {
4221            let _guard = env_guard();
4222            panic!("poison env lock");
4223        })
4224        .join();
4225        assert!(poisoned.is_err(), "poisoning thread should panic");
4226
4227        let _guard = env_guard();
4228    }
4229
4230    fn restore_env_var(key: &str, original: Option<OsString>) {
4231        match original {
4232            Some(value) => std::env::set_var(key, value),
4233            None => std::env::remove_var(key),
4234        }
4235    }
4236
4237    fn write_external_plugin(root: &Path, name: &str, version: &str) {
4238        fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
4239        fs::write(
4240            root.join(".claude-plugin").join("plugin.json"),
4241            format!(
4242                "{{\n  \"name\": \"{name}\",\n  \"version\": \"{version}\",\n  \"description\": \"commands plugin\"\n}}"
4243            ),
4244        )
4245        .expect("write manifest");
4246    }
4247
4248    fn write_bundled_plugin(root: &Path, name: &str, version: &str, default_enabled: bool) {
4249        fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
4250        fs::write(
4251            root.join(".claude-plugin").join("plugin.json"),
4252            format!(
4253                "{{\n  \"name\": \"{name}\",\n  \"version\": \"{version}\",\n  \"description\": \"bundled commands plugin\",\n  \"defaultEnabled\": {}\n}}",
4254                if default_enabled { "true" } else { "false" }
4255            ),
4256        )
4257        .expect("write bundled manifest");
4258    }
4259
4260    fn write_agent(root: &Path, name: &str, description: &str, model: &str, reasoning: &str) {
4261        fs::create_dir_all(root).expect("agent root");
4262        fs::write(
4263            root.join(format!("{name}.toml")),
4264            format!(
4265                "name = \"{name}\"\ndescription = \"{description}\"\nmodel = \"{model}\"\nmodel_reasoning_effort = \"{reasoning}\"\n"
4266            ),
4267        )
4268        .expect("write agent");
4269    }
4270
4271    fn write_skill(root: &Path, name: &str, description: &str) {
4272        let skill_root = root.join(name);
4273        fs::create_dir_all(&skill_root).expect("skill root");
4274        fs::write(
4275            skill_root.join("SKILL.md"),
4276            format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
4277        )
4278        .expect("write skill");
4279    }
4280
4281    fn write_legacy_command(root: &Path, name: &str, description: &str) {
4282        fs::create_dir_all(root).expect("commands root");
4283        fs::write(
4284            root.join(format!("{name}.md")),
4285            format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
4286        )
4287        .expect("write command");
4288    }
4289
4290    fn parse_error_message(input: &str) -> String {
4291        SlashCommand::parse(input)
4292            .expect_err("slash command should be rejected")
4293            .to_string()
4294    }
4295
4296    #[allow(clippy::too_many_lines)]
4297    #[test]
4298    fn parses_supported_slash_commands() {
4299        assert_eq!(SlashCommand::parse("/help"), Ok(Some(SlashCommand::Help)));
4300        assert_eq!(
4301            SlashCommand::parse(" /status "),
4302            Ok(Some(SlashCommand::Status))
4303        );
4304        assert_eq!(
4305            SlashCommand::parse("/sandbox"),
4306            Ok(Some(SlashCommand::Sandbox))
4307        );
4308        assert_eq!(
4309            SlashCommand::parse("/bughunter runtime"),
4310            Ok(Some(SlashCommand::Bughunter {
4311                scope: Some("runtime".to_string())
4312            }))
4313        );
4314        assert_eq!(
4315            SlashCommand::parse("/commit"),
4316            Ok(Some(SlashCommand::Commit))
4317        );
4318        assert_eq!(
4319            SlashCommand::parse("/pr ready for review"),
4320            Ok(Some(SlashCommand::Pr {
4321                context: Some("ready for review".to_string())
4322            }))
4323        );
4324        assert_eq!(
4325            SlashCommand::parse("/issue flaky test"),
4326            Ok(Some(SlashCommand::Issue {
4327                context: Some("flaky test".to_string())
4328            }))
4329        );
4330        assert_eq!(
4331            SlashCommand::parse("/ultraplan ship both features"),
4332            Ok(Some(SlashCommand::Ultraplan {
4333                task: Some("ship both features".to_string())
4334            }))
4335        );
4336        assert_eq!(
4337            SlashCommand::parse("/teleport conversation.rs"),
4338            Ok(Some(SlashCommand::Teleport {
4339                target: Some("conversation.rs".to_string())
4340            }))
4341        );
4342        assert_eq!(
4343            SlashCommand::parse("/debug-tool-call"),
4344            Ok(Some(SlashCommand::DebugToolCall))
4345        );
4346        assert_eq!(
4347            SlashCommand::parse("/bughunter runtime"),
4348            Ok(Some(SlashCommand::Bughunter {
4349                scope: Some("runtime".to_string())
4350            }))
4351        );
4352        assert_eq!(
4353            SlashCommand::parse("/commit"),
4354            Ok(Some(SlashCommand::Commit))
4355        );
4356        assert_eq!(
4357            SlashCommand::parse("/pr ready for review"),
4358            Ok(Some(SlashCommand::Pr {
4359                context: Some("ready for review".to_string())
4360            }))
4361        );
4362        assert_eq!(
4363            SlashCommand::parse("/issue flaky test"),
4364            Ok(Some(SlashCommand::Issue {
4365                context: Some("flaky test".to_string())
4366            }))
4367        );
4368        assert_eq!(
4369            SlashCommand::parse("/ultraplan ship both features"),
4370            Ok(Some(SlashCommand::Ultraplan {
4371                task: Some("ship both features".to_string())
4372            }))
4373        );
4374        assert_eq!(
4375            SlashCommand::parse("/teleport conversation.rs"),
4376            Ok(Some(SlashCommand::Teleport {
4377                target: Some("conversation.rs".to_string())
4378            }))
4379        );
4380        assert_eq!(
4381            SlashCommand::parse("/debug-tool-call"),
4382            Ok(Some(SlashCommand::DebugToolCall))
4383        );
4384        assert_eq!(
4385            SlashCommand::parse("/model claude-opus"),
4386            Ok(Some(SlashCommand::Model {
4387                model: Some("claude-opus".to_string()),
4388            }))
4389        );
4390        assert_eq!(
4391            SlashCommand::parse("/model"),
4392            Ok(Some(SlashCommand::Model { model: None }))
4393        );
4394        assert_eq!(
4395            SlashCommand::parse("/permissions read-only"),
4396            Ok(Some(SlashCommand::Permissions {
4397                mode: Some("read-only".to_string()),
4398            }))
4399        );
4400        assert_eq!(
4401            SlashCommand::parse("/clear"),
4402            Ok(Some(SlashCommand::Clear { confirm: false }))
4403        );
4404        assert_eq!(
4405            SlashCommand::parse("/clear --confirm"),
4406            Ok(Some(SlashCommand::Clear { confirm: true }))
4407        );
4408        assert_eq!(SlashCommand::parse("/cost"), Ok(Some(SlashCommand::Cost)));
4409        assert_eq!(
4410            SlashCommand::parse("/resume session.json"),
4411            Ok(Some(SlashCommand::Resume {
4412                session_path: Some("session.json".to_string()),
4413            }))
4414        );
4415        assert_eq!(
4416            SlashCommand::parse("/config"),
4417            Ok(Some(SlashCommand::Config { section: None }))
4418        );
4419        assert_eq!(
4420            SlashCommand::parse("/config env"),
4421            Ok(Some(SlashCommand::Config {
4422                section: Some("env".to_string())
4423            }))
4424        );
4425        assert_eq!(
4426            SlashCommand::parse("/mcp"),
4427            Ok(Some(SlashCommand::Mcp {
4428                action: None,
4429                target: None
4430            }))
4431        );
4432        assert_eq!(
4433            SlashCommand::parse("/mcp show remote"),
4434            Ok(Some(SlashCommand::Mcp {
4435                action: Some("show".to_string()),
4436                target: Some("remote".to_string())
4437            }))
4438        );
4439        assert_eq!(
4440            SlashCommand::parse("/memory"),
4441            Ok(Some(SlashCommand::Memory))
4442        );
4443        assert_eq!(SlashCommand::parse("/init"), Ok(Some(SlashCommand::Init)));
4444        assert_eq!(SlashCommand::parse("/diff"), Ok(Some(SlashCommand::Diff)));
4445        assert_eq!(
4446            SlashCommand::parse("/version"),
4447            Ok(Some(SlashCommand::Version))
4448        );
4449        assert_eq!(
4450            SlashCommand::parse("/export notes.txt"),
4451            Ok(Some(SlashCommand::Export {
4452                path: Some("notes.txt".to_string())
4453            }))
4454        );
4455        assert_eq!(
4456            SlashCommand::parse("/session switch abc123"),
4457            Ok(Some(SlashCommand::Session {
4458                action: Some("switch".to_string()),
4459                target: Some("abc123".to_string())
4460            }))
4461        );
4462        assert_eq!(
4463            SlashCommand::parse("/plugins install demo"),
4464            Ok(Some(SlashCommand::Plugins {
4465                action: Some("install".to_string()),
4466                target: Some("demo".to_string())
4467            }))
4468        );
4469        assert_eq!(
4470            SlashCommand::parse("/plugins list"),
4471            Ok(Some(SlashCommand::Plugins {
4472                action: Some("list".to_string()),
4473                target: None
4474            }))
4475        );
4476        assert_eq!(
4477            SlashCommand::parse("/plugins enable demo"),
4478            Ok(Some(SlashCommand::Plugins {
4479                action: Some("enable".to_string()),
4480                target: Some("demo".to_string())
4481            }))
4482        );
4483        assert_eq!(
4484            SlashCommand::parse("/skills install ./fixtures/help-skill"),
4485            Ok(Some(SlashCommand::Skills {
4486                args: Some("install ./fixtures/help-skill".to_string())
4487            }))
4488        );
4489        assert_eq!(
4490            SlashCommand::parse("/plugins disable demo"),
4491            Ok(Some(SlashCommand::Plugins {
4492                action: Some("disable".to_string()),
4493                target: Some("demo".to_string())
4494            }))
4495        );
4496        assert_eq!(
4497            SlashCommand::parse("/session fork incident-review"),
4498            Ok(Some(SlashCommand::Session {
4499                action: Some("fork".to_string()),
4500                target: Some("incident-review".to_string())
4501            }))
4502        );
4503    }
4504
4505    #[test]
4506    fn parses_history_command_without_count() {
4507        // given
4508        let input = "/history";
4509
4510        // when
4511        let parsed = SlashCommand::parse(input);
4512
4513        // then
4514        assert_eq!(parsed, Ok(Some(SlashCommand::History { count: None })));
4515    }
4516
4517    #[test]
4518    fn parses_history_command_with_numeric_count() {
4519        // given
4520        let input = "/history 25";
4521
4522        // when
4523        let parsed = SlashCommand::parse(input);
4524
4525        // then
4526        assert_eq!(
4527            parsed,
4528            Ok(Some(SlashCommand::History {
4529                count: Some("25".to_string())
4530            }))
4531        );
4532    }
4533
4534    #[test]
4535    fn rejects_history_with_extra_arguments() {
4536        // given
4537        let input = "/history 25 extra";
4538
4539        // when
4540        let error = parse_error_message(input);
4541
4542        // then
4543        assert!(error.contains("Usage: /history [count]"));
4544    }
4545
4546    #[test]
4547    fn rejects_unexpected_arguments_for_no_arg_commands() {
4548        // given
4549        let input = "/compact now";
4550
4551        // when
4552        let error = parse_error_message(input);
4553
4554        // then
4555        assert!(error.contains("Unexpected arguments for /compact."));
4556        assert!(error.contains("  Usage            /compact"));
4557        assert!(error.contains("  Summary          Compact local session history"));
4558    }
4559
4560    #[test]
4561    fn rejects_invalid_argument_values() {
4562        // given
4563        let input = "/permissions admin";
4564
4565        // when
4566        let error = parse_error_message(input);
4567
4568        // then
4569        assert!(error.contains(
4570            "Unsupported /permissions mode 'admin'. Use read-only, workspace-write, or danger-full-access."
4571        ));
4572        assert!(error.contains(
4573            "  Usage            /permissions [read-only|workspace-write|danger-full-access]"
4574        ));
4575    }
4576
4577    #[test]
4578    fn rejects_missing_required_arguments() {
4579        // given
4580        let input = "/teleport";
4581
4582        // when
4583        let error = parse_error_message(input);
4584
4585        // then
4586        assert!(error.contains("Usage: /teleport <symbol-or-path>"));
4587        assert!(error.contains("  Category         Tools"));
4588    }
4589
4590    #[test]
4591    fn rejects_invalid_session_and_plugin_shapes() {
4592        // given
4593        let session_input = "/session switch";
4594        let plugin_input = "/plugins list extra";
4595
4596        // when
4597        let session_error = parse_error_message(session_input);
4598        let plugin_error = parse_error_message(plugin_input);
4599
4600        // then
4601        assert!(session_error.contains("Usage: /session switch <session-id>"));
4602        assert!(session_error.contains("/session"));
4603        assert!(plugin_error.contains("Usage: /plugin list"));
4604        assert!(plugin_error.contains("Aliases          /plugins, /marketplace"));
4605    }
4606
4607    #[test]
4608    fn rejects_invalid_agents_arguments() {
4609        // given
4610        let agents_input = "/agents show planner";
4611
4612        // when
4613        let agents_error = parse_error_message(agents_input);
4614
4615        // then
4616        assert!(agents_error.contains(
4617            "Unexpected arguments for /agents: show planner. Use /agents, /agents list, or /agents help."
4618        ));
4619        assert!(agents_error.contains("  Usage            /agents [list|help]"));
4620    }
4621
4622    #[test]
4623    fn accepts_skills_invocation_arguments_for_prompt_dispatch() {
4624        assert_eq!(
4625            SlashCommand::parse("/skills help overview"),
4626            Ok(Some(SlashCommand::Skills {
4627                args: Some("help overview".to_string()),
4628            }))
4629        );
4630        assert_eq!(
4631            classify_skills_slash_command(Some("help overview")),
4632            SkillSlashDispatch::Invoke("$help overview".to_string())
4633        );
4634        assert_eq!(
4635            classify_skills_slash_command(Some("/test")),
4636            SkillSlashDispatch::Invoke("$test".to_string())
4637        );
4638        assert_eq!(
4639            classify_skills_slash_command(Some("install ./skill-pack")),
4640            SkillSlashDispatch::Local
4641        );
4642    }
4643
4644    #[test]
4645    fn rejects_invalid_mcp_arguments() {
4646        let show_error = parse_error_message("/mcp show alpha beta");
4647        assert!(show_error.contains("Unexpected arguments for /mcp show."));
4648        assert!(show_error.contains("  Usage            /mcp show <server>"));
4649
4650        let action_error = parse_error_message("/mcp inspect alpha");
4651        assert!(action_error
4652            .contains("Unknown /mcp action 'inspect'. Use list, show <server>, or help."));
4653        assert!(action_error.contains("  Usage            /mcp [list|show <server>|help]"));
4654    }
4655
4656    #[test]
4657    fn removed_login_and_logout_commands_report_env_auth_guidance() {
4658        let login_error = parse_error_message("/login");
4659        assert!(login_error.contains("ANTHROPIC_API_KEY"));
4660        let logout_error = parse_error_message("/logout");
4661        assert!(logout_error.contains("ANTHROPIC_AUTH_TOKEN"));
4662    }
4663
4664    #[test]
4665    fn renders_help_from_shared_specs() {
4666        let help = render_slash_command_help();
4667        assert!(help.contains("Start here        /status, /diff, /agents, /skills, /commit"));
4668        assert!(help.contains("[resume]          also works with --resume SESSION.jsonl"));
4669        assert!(help.contains("Session"));
4670        assert!(help.contains("Tools"));
4671        assert!(help.contains("Config"));
4672        assert!(help.contains("Debug"));
4673        assert!(help.contains("/help"));
4674        assert!(help.contains("/status"));
4675        assert!(help.contains("/sandbox"));
4676        assert!(help.contains("/compact"));
4677        assert!(help.contains("/bughunter [scope]"));
4678        assert!(help.contains("/commit"));
4679        assert!(help.contains("/pr [context]"));
4680        assert!(help.contains("/issue [context]"));
4681        assert!(help.contains("/ultraplan [task]"));
4682        assert!(help.contains("/teleport <symbol-or-path>"));
4683        assert!(help.contains("/debug-tool-call"));
4684        assert!(help.contains("/model [model]"));
4685        assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
4686        assert!(help.contains("/clear [--confirm]"));
4687        assert!(help.contains("/cost"));
4688        assert!(help.contains("/resume <session-path>"));
4689        assert!(help.contains("/config [env|hooks|model|plugins]"));
4690        assert!(help.contains("/mcp [list|show <server>|help]"));
4691        assert!(help.contains("/memory"));
4692        assert!(help.contains("/init"));
4693        assert!(help.contains("/diff"));
4694        assert!(help.contains("/version"));
4695        assert!(help.contains("/export [file]"));
4696        assert!(help.contains("/session"), "help must mention /session");
4697        assert!(help.contains("/sandbox"));
4698        assert!(help.contains(
4699            "/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
4700        ));
4701        assert!(help.contains("aliases: /plugins, /marketplace"));
4702        assert!(help.contains("/agents [list|help]"));
4703        assert!(help.contains("/skills [list|install <path>|help|<skill> [args]]"));
4704        assert!(help.contains("aliases: /skill"));
4705        assert!(!help.contains("/login"));
4706        assert!(!help.contains("/logout"));
4707        assert_eq!(slash_command_specs().len(), 139);
4708        assert!(resume_supported_slash_commands().len() >= 39);
4709    }
4710
4711    #[test]
4712    fn renders_help_with_grouped_categories_and_keyboard_shortcuts() {
4713        // given
4714        let categories = ["Session", "Tools", "Config", "Debug"];
4715
4716        // when
4717        let help = render_slash_command_help();
4718
4719        // then
4720        for category in categories {
4721            assert!(
4722                help.contains(category),
4723                "expected help to contain category {category}"
4724            );
4725        }
4726        let session_index = help.find("Session").expect("Session header should exist");
4727        let tools_index = help.find("Tools").expect("Tools header should exist");
4728        let config_index = help.find("Config").expect("Config header should exist");
4729        let debug_index = help.find("Debug").expect("Debug header should exist");
4730        assert!(session_index < tools_index);
4731        assert!(tools_index < config_index);
4732        assert!(config_index < debug_index);
4733
4734        assert!(help.contains("Keyboard shortcuts"));
4735        assert!(help.contains("Up/Down              Navigate prompt history"));
4736        assert!(help.contains("Tab                  Complete commands, modes, and recent sessions"));
4737        assert!(help.contains("Ctrl-C               Clear input (or exit on empty prompt)"));
4738        assert!(help.contains("Shift+Enter/Ctrl+J   Insert a newline"));
4739
4740        // every command should still render with a summary line
4741        for spec in slash_command_specs() {
4742            let usage = match spec.argument_hint {
4743                Some(hint) => format!("/{} {hint}", spec.name),
4744                None => format!("/{}", spec.name),
4745            };
4746            assert!(
4747                help.contains(&usage),
4748                "expected help to contain command {usage}"
4749            );
4750            assert!(
4751                help.contains(spec.summary),
4752                "expected help to contain summary for /{}",
4753                spec.name
4754            );
4755        }
4756    }
4757
4758    #[test]
4759    fn renders_per_command_help_detail() {
4760        // given
4761        let command = "plugins";
4762
4763        // when
4764        let help = render_slash_command_help_detail(command).expect("detail help should exist");
4765
4766        // then
4767        assert!(help.contains("/plugin"));
4768        assert!(help.contains("Summary          Manage Claw Code plugins"));
4769        assert!(help.contains("Aliases          /plugins, /marketplace"));
4770        assert!(help.contains("Category         Tools"));
4771    }
4772
4773    #[test]
4774    fn renders_per_command_help_detail_for_mcp() {
4775        let help = render_slash_command_help_detail("mcp").expect("detail help should exist");
4776        assert!(help.contains("/mcp"));
4777        assert!(help.contains("Summary          Inspect configured MCP servers"));
4778        assert!(help.contains("Category         Tools"));
4779        assert!(help.contains("Resume           Supported with --resume SESSION.jsonl"));
4780    }
4781
4782    #[test]
4783    fn validate_slash_command_input_rejects_extra_single_value_arguments() {
4784        // given
4785        let session_input = "/session switch current next";
4786        let plugin_input = "/plugin enable demo extra";
4787
4788        // when
4789        let session_error = validate_slash_command_input(session_input)
4790            .expect_err("session input should be rejected")
4791            .to_string();
4792        let plugin_error = validate_slash_command_input(plugin_input)
4793            .expect_err("plugin input should be rejected")
4794            .to_string();
4795
4796        // then
4797        assert!(session_error.contains("Unexpected arguments for /session switch."));
4798        assert!(session_error.contains("  Usage            /session switch <session-id>"));
4799        assert!(plugin_error.contains("Unexpected arguments for /plugin enable."));
4800        assert!(plugin_error.contains("  Usage            /plugin enable <name>"));
4801    }
4802
4803    #[test]
4804    fn suggests_closest_slash_commands_for_typos_and_aliases() {
4805        let suggestions = suggest_slash_commands("stats", 3);
4806        assert!(suggestions.contains(&"/stats".to_string()));
4807        assert!(suggestions.contains(&"/status".to_string()));
4808        assert!(suggestions.len() <= 3);
4809        let plugin_suggestions = suggest_slash_commands("/plugns", 3);
4810        assert!(plugin_suggestions.contains(&"/plugin".to_string()));
4811        assert_eq!(suggest_slash_commands("zzz", 3), Vec::<String>::new());
4812    }
4813
4814    #[test]
4815    fn compacts_sessions_via_slash_command() {
4816        let mut session = Session::new();
4817        session.messages = vec![
4818            ConversationMessage::user_text("a ".repeat(200)),
4819            ConversationMessage::assistant(vec![ContentBlock::Text {
4820                text: "b ".repeat(200),
4821            }]),
4822            ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
4823            ConversationMessage::assistant(vec![ContentBlock::Text {
4824                text: "recent".to_string(),
4825            }]),
4826        ];
4827
4828        let result = handle_slash_command(
4829            "/compact",
4830            &session,
4831            CompactionConfig {
4832                preserve_recent_messages: 2,
4833                max_estimated_tokens: 1,
4834            },
4835        )
4836        .expect("slash command should be handled");
4837
4838        // With the tool-use/tool-result boundary guard the compaction may
4839        // preserve one extra message, so 1 or 2 messages may be removed.
4840        assert!(
4841            result.message.contains("Compacted 1 messages")
4842                || result.message.contains("Compacted 2 messages"),
4843            "unexpected compaction message: {}",
4844            result.message
4845        );
4846        assert_eq!(result.session.messages[0].role, MessageRole::System);
4847    }
4848
4849    #[test]
4850    fn help_command_is_non_mutating() {
4851        let session = Session::new();
4852        let result = handle_slash_command("/help", &session, CompactionConfig::default())
4853            .expect("help command should be handled");
4854        assert_eq!(result.session, session);
4855        assert!(result.message.contains("Slash commands"));
4856    }
4857
4858    #[test]
4859    fn ignores_unknown_or_runtime_bound_slash_commands() {
4860        let session = Session::new();
4861        assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
4862        assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
4863        assert!(handle_slash_command("/sandbox", &session, CompactionConfig::default()).is_none());
4864        assert!(
4865            handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none()
4866        );
4867        assert!(handle_slash_command("/commit", &session, CompactionConfig::default()).is_none());
4868        assert!(handle_slash_command("/pr", &session, CompactionConfig::default()).is_none());
4869        assert!(handle_slash_command("/issue", &session, CompactionConfig::default()).is_none());
4870        assert!(
4871            handle_slash_command("/ultraplan", &session, CompactionConfig::default()).is_none()
4872        );
4873        assert!(
4874            handle_slash_command("/teleport foo", &session, CompactionConfig::default()).is_none()
4875        );
4876        assert!(
4877            handle_slash_command("/debug-tool-call", &session, CompactionConfig::default())
4878                .is_none()
4879        );
4880        assert!(
4881            handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
4882        );
4883        assert!(handle_slash_command(
4884            "/permissions read-only",
4885            &session,
4886            CompactionConfig::default()
4887        )
4888        .is_none());
4889        assert!(handle_slash_command("/clear", &session, CompactionConfig::default()).is_none());
4890        assert!(
4891            handle_slash_command("/clear --confirm", &session, CompactionConfig::default())
4892                .is_none()
4893        );
4894        assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none());
4895        assert!(handle_slash_command(
4896            "/resume session.json",
4897            &session,
4898            CompactionConfig::default()
4899        )
4900        .is_none());
4901        assert!(handle_slash_command(
4902            "/resume session.jsonl",
4903            &session,
4904            CompactionConfig::default()
4905        )
4906        .is_none());
4907        assert!(handle_slash_command("/config", &session, CompactionConfig::default()).is_none());
4908        assert!(
4909            handle_slash_command("/config env", &session, CompactionConfig::default()).is_none()
4910        );
4911        assert!(handle_slash_command("/mcp list", &session, CompactionConfig::default()).is_none());
4912        assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
4913        assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
4914        assert!(
4915            handle_slash_command("/export note.txt", &session, CompactionConfig::default())
4916                .is_none()
4917        );
4918        assert!(
4919            handle_slash_command("/session list", &session, CompactionConfig::default()).is_none()
4920        );
4921        assert!(
4922            handle_slash_command("/plugins list", &session, CompactionConfig::default()).is_none()
4923        );
4924    }
4925
4926    #[test]
4927    fn renders_plugins_report_with_name_version_and_status() {
4928        let rendered = render_plugins_report(&[
4929            PluginSummary {
4930                metadata: PluginMetadata {
4931                    id: "demo@external".to_string(),
4932                    name: "demo".to_string(),
4933                    version: "1.2.3".to_string(),
4934                    description: "demo plugin".to_string(),
4935                    kind: PluginKind::External,
4936                    source: "demo".to_string(),
4937                    default_enabled: false,
4938                    root: None,
4939                },
4940                enabled: true,
4941            },
4942            PluginSummary {
4943                metadata: PluginMetadata {
4944                    id: "sample@external".to_string(),
4945                    name: "sample".to_string(),
4946                    version: "0.9.0".to_string(),
4947                    description: "sample plugin".to_string(),
4948                    kind: PluginKind::External,
4949                    source: "sample".to_string(),
4950                    default_enabled: false,
4951                    root: None,
4952                },
4953                enabled: false,
4954            },
4955        ]);
4956
4957        assert!(rendered.contains("demo"));
4958        assert!(rendered.contains("v1.2.3"));
4959        assert!(rendered.contains("enabled"));
4960        assert!(rendered.contains("sample"));
4961        assert!(rendered.contains("v0.9.0"));
4962        assert!(rendered.contains("disabled"));
4963    }
4964
4965    #[test]
4966    fn renders_plugins_report_with_broken_plugin_warnings() {
4967        let rendered = render_plugins_report_with_failures(
4968            &[PluginSummary {
4969                metadata: PluginMetadata {
4970                    id: "demo@external".to_string(),
4971                    name: "demo".to_string(),
4972                    version: "1.2.3".to_string(),
4973                    description: "demo plugin".to_string(),
4974                    kind: PluginKind::External,
4975                    source: "demo".to_string(),
4976                    default_enabled: false,
4977                    root: None,
4978                },
4979                enabled: true,
4980            }],
4981            &[PluginLoadFailure::new(
4982                PathBuf::from("/tmp/broken-plugin"),
4983                PluginKind::External,
4984                "broken".to_string(),
4985                PluginError::InvalidManifest("hook path `hooks/pre.sh` does not exist".to_string()),
4986            )],
4987        );
4988
4989        assert!(rendered.contains("Warnings:"));
4990        assert!(rendered.contains("Failed to load external plugin"));
4991        assert!(rendered.contains("/tmp/broken-plugin"));
4992        assert!(rendered.contains("does not exist"));
4993    }
4994
4995    #[test]
4996    fn lists_agents_from_project_and_user_roots() {
4997        let workspace = temp_dir("agents-workspace");
4998        let project_agents = workspace.join(".codex").join("agents");
4999        let user_home = temp_dir("agents-home");
5000        let user_agents = user_home.join(".claude").join("agents");
5001
5002        write_agent(
5003            &project_agents,
5004            "planner",
5005            "Project planner",
5006            "gpt-5.4",
5007            "medium",
5008        );
5009        write_agent(
5010            &user_agents,
5011            "planner",
5012            "User planner",
5013            "gpt-5.4-mini",
5014            "high",
5015        );
5016        write_agent(
5017            &user_agents,
5018            "verifier",
5019            "Verification agent",
5020            "gpt-5.4-mini",
5021            "high",
5022        );
5023
5024        let roots = vec![
5025            (DefinitionSource::ProjectCodex, project_agents),
5026            (DefinitionSource::UserCodex, user_agents),
5027        ];
5028        let report =
5029            render_agents_report(&load_agents_from_roots(&roots).expect("agent roots should load"));
5030
5031        assert!(report.contains("Agents"));
5032        assert!(report.contains("2 active agents"));
5033        assert!(report.contains("Project roots:"));
5034        assert!(report.contains("planner · Project planner · gpt-5.4 · medium"));
5035        assert!(report.contains("User home roots:"));
5036        assert!(report.contains("(shadowed by Project roots) planner · User planner"));
5037        assert!(report.contains("verifier · Verification agent · gpt-5.4-mini · high"));
5038
5039        let _ = fs::remove_dir_all(workspace);
5040        let _ = fs::remove_dir_all(user_home);
5041    }
5042
5043    #[test]
5044    fn renders_agents_reports_as_json() {
5045        let workspace = temp_dir("agents-json-workspace");
5046        let project_agents = workspace.join(".codex").join("agents");
5047        let user_home = temp_dir("agents-json-home");
5048        let user_agents = user_home.join(".codex").join("agents");
5049
5050        write_agent(
5051            &project_agents,
5052            "planner",
5053            "Project planner",
5054            "gpt-5.4",
5055            "medium",
5056        );
5057        write_agent(
5058            &project_agents,
5059            "verifier",
5060            "Verification agent",
5061            "gpt-5.4-mini",
5062            "high",
5063        );
5064        write_agent(
5065            &user_agents,
5066            "planner",
5067            "User planner",
5068            "gpt-5.4-mini",
5069            "high",
5070        );
5071
5072        let roots = vec![
5073            (DefinitionSource::ProjectCodex, project_agents),
5074            (DefinitionSource::UserCodex, user_agents),
5075        ];
5076        let report = render_agents_report_json(
5077            &workspace,
5078            &load_agents_from_roots(&roots).expect("agent roots should load"),
5079        );
5080
5081        assert_eq!(report["kind"], "agents");
5082        assert_eq!(report["action"], "list");
5083        assert_eq!(report["working_directory"], workspace.display().to_string());
5084        assert_eq!(report["count"], 3);
5085        assert_eq!(report["summary"]["active"], 2);
5086        assert_eq!(report["summary"]["shadowed"], 1);
5087        assert_eq!(report["agents"][0]["name"], "planner");
5088        assert_eq!(report["agents"][0]["model"], "gpt-5.4");
5089        assert_eq!(report["agents"][0]["active"], true);
5090        assert_eq!(report["agents"][1]["name"], "verifier");
5091        assert_eq!(report["agents"][2]["name"], "planner");
5092        assert_eq!(report["agents"][2]["active"], false);
5093        assert_eq!(report["agents"][2]["shadowed_by"]["id"], "project_claw");
5094
5095        let help = handle_agents_slash_command_json(Some("help"), &workspace).expect("agents help");
5096        assert_eq!(help["kind"], "agents");
5097        assert_eq!(help["action"], "help");
5098        assert_eq!(help["usage"]["direct_cli"], "claw agents [list|help]");
5099
5100        let unexpected = handle_agents_slash_command_json(Some("show planner"), &workspace)
5101            .expect("agents usage");
5102        assert_eq!(unexpected["action"], "help");
5103        assert_eq!(unexpected["unexpected"], "show planner");
5104
5105        let _ = fs::remove_dir_all(workspace);
5106        let _ = fs::remove_dir_all(user_home);
5107    }
5108
5109    #[test]
5110    fn lists_skills_from_project_and_user_roots() {
5111        let workspace = temp_dir("skills-workspace");
5112        let project_skills = workspace.join(".codex").join("skills");
5113        let project_commands = workspace.join(".claude").join("commands");
5114        let user_home = temp_dir("skills-home");
5115        let user_skills = user_home.join(".codex").join("skills");
5116
5117        write_skill(&project_skills, "plan", "Project planning guidance");
5118        write_legacy_command(&project_commands, "deploy", "Legacy deployment guidance");
5119        write_skill(&user_skills, "plan", "User planning guidance");
5120        write_skill(&user_skills, "help", "Help guidance");
5121
5122        let roots = vec![
5123            SkillRoot {
5124                source: DefinitionSource::ProjectCodex,
5125                path: project_skills,
5126                origin: SkillOrigin::SkillsDir,
5127            },
5128            SkillRoot {
5129                source: DefinitionSource::ProjectClaude,
5130                path: project_commands,
5131                origin: SkillOrigin::LegacyCommandsDir,
5132            },
5133            SkillRoot {
5134                source: DefinitionSource::UserCodex,
5135                path: user_skills,
5136                origin: SkillOrigin::SkillsDir,
5137            },
5138        ];
5139        let report =
5140            render_skills_report(&load_skills_from_roots(&roots).expect("skill roots should load"));
5141
5142        assert!(report.contains("Skills"));
5143        assert!(report.contains("3 available skills"));
5144        assert!(report.contains("Project roots:"));
5145        assert!(report.contains("plan · Project planning guidance"));
5146        assert!(report.contains("deploy · Legacy deployment guidance · legacy /commands"));
5147        assert!(report.contains("User home roots:"));
5148        assert!(report.contains("(shadowed by Project roots) plan · User planning guidance"));
5149        assert!(report.contains("help · Help guidance"));
5150
5151        let _ = fs::remove_dir_all(workspace);
5152        let _ = fs::remove_dir_all(user_home);
5153    }
5154
5155    #[test]
5156    fn resolves_project_skills_and_legacy_commands_from_shared_registry() {
5157        let workspace = temp_dir("resolve-project-skills");
5158        let project_skills = workspace.join(".claw").join("skills");
5159        let legacy_commands = workspace.join(".claw").join("commands");
5160
5161        write_skill(&project_skills, "plan", "Project planning guidance");
5162        write_legacy_command(&legacy_commands, "handoff", "Legacy handoff guidance");
5163
5164        assert_eq!(
5165            resolve_skill_path(&workspace, "$plan").expect("project skill should resolve"),
5166            project_skills.join("plan").join("SKILL.md")
5167        );
5168        assert_eq!(
5169            resolve_skill_path(&workspace, "/handoff").expect("legacy command should resolve"),
5170            legacy_commands.join("handoff.md")
5171        );
5172    }
5173
5174    #[test]
5175    fn renders_skills_reports_as_json() {
5176        let workspace = temp_dir("skills-json-workspace");
5177        let project_skills = workspace.join(".codex").join("skills");
5178        let project_commands = workspace.join(".claude").join("commands");
5179        let user_home = temp_dir("skills-json-home");
5180        let user_skills = user_home.join(".codex").join("skills");
5181
5182        write_skill(&project_skills, "plan", "Project planning guidance");
5183        write_legacy_command(&project_commands, "deploy", "Legacy deployment guidance");
5184        write_skill(&user_skills, "plan", "User planning guidance");
5185        write_skill(&user_skills, "help", "Help guidance");
5186
5187        let roots = vec![
5188            SkillRoot {
5189                source: DefinitionSource::ProjectCodex,
5190                path: project_skills,
5191                origin: SkillOrigin::SkillsDir,
5192            },
5193            SkillRoot {
5194                source: DefinitionSource::ProjectClaude,
5195                path: project_commands,
5196                origin: SkillOrigin::LegacyCommandsDir,
5197            },
5198            SkillRoot {
5199                source: DefinitionSource::UserCodex,
5200                path: user_skills,
5201                origin: SkillOrigin::SkillsDir,
5202            },
5203        ];
5204        let report = super::render_skills_report_json(
5205            &load_skills_from_roots(&roots).expect("skills should load"),
5206        );
5207        assert_eq!(report["kind"], "skills");
5208        assert_eq!(report["action"], "list");
5209        assert_eq!(report["summary"]["active"], 3);
5210        assert_eq!(report["summary"]["shadowed"], 1);
5211        assert_eq!(report["skills"][0]["name"], "plan");
5212        assert_eq!(report["skills"][0]["source"]["id"], "project_claw");
5213        assert_eq!(report["skills"][1]["name"], "deploy");
5214        assert_eq!(report["skills"][1]["origin"]["id"], "legacy_commands_dir");
5215        assert_eq!(report["skills"][3]["shadowed_by"]["id"], "project_claw");
5216
5217        let help = handle_skills_slash_command_json(Some("help"), &workspace).expect("skills help");
5218        assert_eq!(help["kind"], "skills");
5219        assert_eq!(help["action"], "help");
5220        assert_eq!(help["usage"]["aliases"][0], "/skill");
5221        assert_eq!(
5222            help["usage"]["direct_cli"],
5223            "claw skills [list|install <path>|help|<skill> [args]]"
5224        );
5225
5226        let _ = fs::remove_dir_all(workspace);
5227        let _ = fs::remove_dir_all(user_home);
5228    }
5229
5230    #[test]
5231    fn agents_and_skills_usage_support_help_and_unexpected_args() {
5232        let cwd = temp_dir("slash-usage");
5233
5234        let agents_help =
5235            super::handle_agents_slash_command(Some("help"), &cwd).expect("agents help");
5236        assert!(agents_help.contains("Usage            /agents [list|help]"));
5237        assert!(agents_help.contains("Direct CLI       claw agents"));
5238        assert!(agents_help
5239            .contains("Sources          .claw/agents, ~/.claw/agents, $CLAW_CONFIG_HOME/agents"));
5240
5241        let agents_unexpected =
5242            super::handle_agents_slash_command(Some("show planner"), &cwd).expect("agents usage");
5243        assert!(agents_unexpected.contains("Unexpected       show planner"));
5244
5245        let skills_help =
5246            super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help");
5247        assert!(skills_help
5248            .contains("Usage            /skills [list|install <path>|help|<skill> [args]]"));
5249        assert!(skills_help.contains("Alias            /skill"));
5250        assert!(skills_help.contains("Invoke           /skills help overview -> $help overview"));
5251        assert!(skills_help.contains("Install root     $CLAW_CONFIG_HOME/skills or ~/.claw/skills"));
5252        assert!(skills_help.contains(".omc/skills"));
5253        assert!(skills_help.contains(".agents/skills"));
5254        assert!(skills_help.contains("~/.claude/skills/omc-learned"));
5255        assert!(skills_help.contains("legacy /commands"));
5256
5257        let skills_unexpected =
5258            super::handle_skills_slash_command(Some("show help"), &cwd).expect("skills usage");
5259        assert!(skills_unexpected.contains("Unexpected       show"));
5260
5261        let skills_install_help = super::handle_skills_slash_command(Some("install --help"), &cwd)
5262            .expect("nested skills help");
5263        assert!(skills_install_help
5264            .contains("Usage            /skills [list|install <path>|help|<skill> [args]]"));
5265        assert!(skills_install_help.contains("Alias            /skill"));
5266        assert!(skills_install_help.contains("Unexpected       install"));
5267
5268        let skills_unknown_help =
5269            super::handle_skills_slash_command(Some("show --help"), &cwd).expect("skills help");
5270        assert!(skills_unknown_help
5271            .contains("Usage            /skills [list|install <path>|help|<skill> [args]]"));
5272        assert!(skills_unknown_help.contains("Unexpected       show"));
5273
5274        let skills_help_json =
5275            super::handle_skills_slash_command_json(Some("help"), &cwd).expect("skills help json");
5276        let sources = skills_help_json["usage"]["sources"]
5277            .as_array()
5278            .expect("skills help sources");
5279        assert_eq!(skills_help_json["usage"]["aliases"][0], "/skill");
5280        assert!(sources.iter().any(|value| value == ".omc/skills"));
5281        assert!(sources.iter().any(|value| value == ".agents/skills"));
5282        assert!(sources.iter().any(|value| value == "~/.omc/skills"));
5283        assert!(sources
5284            .iter()
5285            .any(|value| value == "~/.claude/skills/omc-learned"));
5286
5287        let _ = fs::remove_dir_all(cwd);
5288    }
5289
5290    #[test]
5291    fn discovers_omc_skills_from_project_and_user_compatibility_roots() {
5292        let _guard = env_guard();
5293        let workspace = temp_dir("skills-omc-workspace");
5294        let user_home = temp_dir("skills-omc-home");
5295        let claude_config_dir = temp_dir("skills-omc-claude-config");
5296        let project_omc_skills = workspace.join(".omc").join("skills");
5297        let project_agents_skills = workspace.join(".agents").join("skills");
5298        let user_omc_skills = user_home.join(".omc").join("skills");
5299        let claude_config_skills = claude_config_dir.join("skills");
5300        let claude_config_commands = claude_config_dir.join("commands");
5301        let learned_skills = claude_config_dir.join("skills").join("omc-learned");
5302        let original_home = std::env::var_os("HOME");
5303        let original_claude_config_dir = std::env::var_os("CLAUDE_CONFIG_DIR");
5304
5305        write_skill(&project_omc_skills, "hud", "OMC HUD guidance");
5306        write_skill(
5307            &project_agents_skills,
5308            "trace",
5309            "Compatibility skill guidance",
5310        );
5311        write_skill(&user_omc_skills, "cancel", "OMC cancel guidance");
5312        write_skill(
5313            &claude_config_skills,
5314            "statusline",
5315            "Claude config skill guidance",
5316        );
5317        write_legacy_command(
5318            &claude_config_commands,
5319            "doctor-check",
5320            "Claude config command guidance",
5321        );
5322        write_skill(&learned_skills, "learned", "Learned skill guidance");
5323        std::env::set_var("HOME", &user_home);
5324        std::env::set_var("CLAUDE_CONFIG_DIR", &claude_config_dir);
5325
5326        let report = super::handle_skills_slash_command(None, &workspace).expect("skills list");
5327        assert!(report.contains("available skills"));
5328        assert!(report.contains("hud · OMC HUD guidance"));
5329        assert!(report.contains("trace · Compatibility skill guidance"));
5330        assert!(report.contains("cancel · OMC cancel guidance"));
5331        assert!(report.contains("statusline · Claude config skill guidance"));
5332        assert!(report.contains("doctor-check · Claude config command guidance · legacy /commands"));
5333        assert!(report.contains("learned · Learned skill guidance"));
5334
5335        let help =
5336            super::handle_skills_slash_command_json(Some("help"), &workspace).expect("skills help");
5337        let sources = help["usage"]["sources"]
5338            .as_array()
5339            .expect("skills help sources");
5340        assert_eq!(help["usage"]["aliases"][0], "/skill");
5341        assert!(sources.iter().any(|value| value == ".omc/skills"));
5342        assert!(sources.iter().any(|value| value == ".agents/skills"));
5343        assert!(sources.iter().any(|value| value == "~/.omc/skills"));
5344        assert!(sources
5345            .iter()
5346            .any(|value| value == "~/.claude/skills/omc-learned"));
5347
5348        restore_env_var("HOME", original_home);
5349        restore_env_var("CLAUDE_CONFIG_DIR", original_claude_config_dir);
5350        let _ = fs::remove_dir_all(workspace);
5351        let _ = fs::remove_dir_all(user_home);
5352        let _ = fs::remove_dir_all(claude_config_dir);
5353    }
5354
5355    #[test]
5356    fn mcp_usage_supports_help_and_unexpected_args() {
5357        let cwd = temp_dir("mcp-usage");
5358
5359        let help = super::handle_mcp_slash_command(Some("help"), &cwd).expect("mcp help");
5360        assert!(help.contains("Usage            /mcp [list|show <server>|help]"));
5361        assert!(help.contains("Direct CLI       claw mcp [list|show <server>|help]"));
5362
5363        let unexpected =
5364            super::handle_mcp_slash_command(Some("show alpha beta"), &cwd).expect("mcp usage");
5365        assert!(unexpected.contains("Unexpected       show alpha beta"));
5366
5367        let nested_help =
5368            super::handle_mcp_slash_command(Some("show --help"), &cwd).expect("mcp help");
5369        assert!(nested_help.contains("Usage            /mcp [list|show <server>|help]"));
5370        assert!(nested_help.contains("Unexpected       show"));
5371
5372        let unknown_help =
5373            super::handle_mcp_slash_command(Some("inspect --help"), &cwd).expect("mcp usage");
5374        assert!(unknown_help.contains("Usage            /mcp [list|show <server>|help]"));
5375        assert!(unknown_help.contains("Unexpected       inspect"));
5376
5377        let _ = fs::remove_dir_all(cwd);
5378    }
5379
5380    #[test]
5381    fn renders_mcp_reports_from_loaded_config() {
5382        let workspace = temp_dir("mcp-config-workspace");
5383        let config_home = temp_dir("mcp-config-home");
5384        fs::create_dir_all(workspace.join(".claw")).expect("workspace config dir");
5385        fs::create_dir_all(&config_home).expect("config home");
5386        fs::write(
5387            workspace.join(".claw").join("settings.json"),
5388            r#"{
5389              "mcpServers": {
5390                "alpha": {
5391                  "command": "uvx",
5392                  "args": ["alpha-server"],
5393                  "env": {"ALPHA_TOKEN": "secret"},
5394                  "toolCallTimeoutMs": 1200
5395                },
5396                "remote": {
5397                  "type": "http",
5398                  "url": "https://remote.example/mcp",
5399                  "headers": {"Authorization": "Bearer secret"},
5400                  "headersHelper": "./bin/headers",
5401                  "oauth": {
5402                    "clientId": "remote-client",
5403                    "callbackPort": 7878
5404                  }
5405                }
5406              }
5407            }"#,
5408        )
5409        .expect("write settings");
5410        fs::write(
5411            workspace.join(".claw").join("settings.local.json"),
5412            r#"{
5413              "mcpServers": {
5414                "remote": {
5415                  "type": "ws",
5416                  "url": "wss://remote.example/mcp"
5417                }
5418              }
5419            }"#,
5420        )
5421        .expect("write local settings");
5422
5423        let loader = ConfigLoader::new(&workspace, &config_home);
5424        let list = super::render_mcp_report_for(&loader, &workspace, None)
5425            .expect("mcp list report should render");
5426        assert!(list.contains("Configured servers 2"));
5427        assert!(list.contains("alpha"));
5428        assert!(list.contains("stdio"));
5429        assert!(list.contains("project"));
5430        assert!(list.contains("uvx alpha-server"));
5431        assert!(list.contains("remote"));
5432        assert!(list.contains("ws"));
5433        assert!(list.contains("local"));
5434        assert!(list.contains("wss://remote.example/mcp"));
5435
5436        let show = super::render_mcp_report_for(&loader, &workspace, Some("show alpha"))
5437            .expect("mcp show report should render");
5438        assert!(show.contains("Name              alpha"));
5439        assert!(show.contains("Command           uvx"));
5440        assert!(show.contains("Args              alpha-server"));
5441        assert!(show.contains("Env keys          ALPHA_TOKEN"));
5442        assert!(show.contains("Tool timeout      1200 ms"));
5443
5444        let remote = super::render_mcp_report_for(&loader, &workspace, Some("show remote"))
5445            .expect("mcp show remote report should render");
5446        assert!(remote.contains("Transport         ws"));
5447        assert!(remote.contains("URL               wss://remote.example/mcp"));
5448
5449        let missing = super::render_mcp_report_for(&loader, &workspace, Some("show missing"))
5450            .expect("missing report should render");
5451        assert!(missing.contains("server `missing` is not configured"));
5452
5453        let _ = fs::remove_dir_all(workspace);
5454        let _ = fs::remove_dir_all(config_home);
5455    }
5456
5457    #[test]
5458    fn renders_mcp_reports_as_json() {
5459        let workspace = temp_dir("mcp-json-workspace");
5460        let config_home = temp_dir("mcp-json-home");
5461        fs::create_dir_all(workspace.join(".claw")).expect("workspace config dir");
5462        fs::create_dir_all(&config_home).expect("config home");
5463        fs::write(
5464            workspace.join(".claw").join("settings.json"),
5465            r#"{
5466              "mcpServers": {
5467                "alpha": {
5468                  "command": "uvx",
5469                  "args": ["alpha-server"],
5470                  "env": {"ALPHA_TOKEN": "secret"},
5471                  "toolCallTimeoutMs": 1200
5472                },
5473                "remote": {
5474                  "type": "http",
5475                  "url": "https://remote.example/mcp",
5476                  "headers": {"Authorization": "Bearer secret"},
5477                  "headersHelper": "./bin/headers",
5478                  "oauth": {
5479                    "clientId": "remote-client",
5480                    "callbackPort": 7878
5481                  }
5482                }
5483              }
5484            }"#,
5485        )
5486        .expect("write settings");
5487        fs::write(
5488            workspace.join(".claw").join("settings.local.json"),
5489            r#"{
5490              "mcpServers": {
5491                "remote": {
5492                  "type": "ws",
5493                  "url": "wss://remote.example/mcp"
5494                }
5495              }
5496            }"#,
5497        )
5498        .expect("write local settings");
5499
5500        let loader = ConfigLoader::new(&workspace, &config_home);
5501        let list =
5502            render_mcp_report_json_for(&loader, &workspace, None).expect("mcp list json render");
5503        assert_eq!(list["kind"], "mcp");
5504        assert_eq!(list["action"], "list");
5505        assert_eq!(list["configured_servers"], 2);
5506        assert_eq!(list["servers"][0]["name"], "alpha");
5507        assert_eq!(list["servers"][0]["transport"]["id"], "stdio");
5508        assert_eq!(list["servers"][0]["details"]["command"], "uvx");
5509        assert_eq!(list["servers"][1]["name"], "remote");
5510        assert_eq!(list["servers"][1]["scope"]["id"], "local");
5511        assert_eq!(list["servers"][1]["transport"]["id"], "ws");
5512        assert_eq!(
5513            list["servers"][1]["details"]["url"],
5514            "wss://remote.example/mcp"
5515        );
5516
5517        let show = render_mcp_report_json_for(&loader, &workspace, Some("show alpha"))
5518            .expect("mcp show json render");
5519        assert_eq!(show["action"], "show");
5520        assert_eq!(show["found"], true);
5521        assert_eq!(show["server"]["name"], "alpha");
5522        assert_eq!(show["server"]["details"]["env_keys"][0], "ALPHA_TOKEN");
5523        assert_eq!(show["server"]["details"]["tool_call_timeout_ms"], 1200);
5524
5525        let missing = render_mcp_report_json_for(&loader, &workspace, Some("show missing"))
5526            .expect("mcp missing json render");
5527        assert_eq!(missing["found"], false);
5528        assert_eq!(missing["server_name"], "missing");
5529
5530        let help =
5531            render_mcp_report_json_for(&loader, &workspace, Some("help")).expect("mcp help json");
5532        assert_eq!(help["action"], "help");
5533        assert_eq!(help["usage"]["sources"][0], ".claw/settings.json");
5534
5535        let _ = fs::remove_dir_all(workspace);
5536        let _ = fs::remove_dir_all(config_home);
5537    }
5538
5539    #[test]
5540    fn mcp_degrades_gracefully_on_malformed_mcp_config_144() {
5541        // #144: mirror of #143's partial-success contract for `claw mcp`.
5542        // Previously `mcp` hard-failed on any config parse error, hiding
5543        // well-formed servers and forcing claws to fall back to `doctor`.
5544        // Now `mcp` emits a degraded envelope instead: exit 0, status:
5545        // "degraded", config_load_error populated, servers[] empty.
5546        let _guard = env_guard();
5547        let workspace = temp_dir("mcp-degrades-144");
5548        let config_home = temp_dir("mcp-degrades-144-cfg");
5549        fs::create_dir_all(workspace.join(".claw")).expect("create workspace .claw dir");
5550        fs::create_dir_all(&config_home).expect("create config home");
5551        // One valid server + one malformed entry missing `command`.
5552        fs::write(
5553            workspace.join(".claw.json"),
5554            r#"{
5555  "mcpServers": {
5556    "everything": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-everything"]},
5557    "missing-command": {"args": ["arg-only-no-command"]}
5558  }
5559}
5560"#,
5561        )
5562        .expect("write malformed .claw.json");
5563
5564        let loader = ConfigLoader::new(&workspace, &config_home);
5565        // list action: must return Ok (not Err) with degraded envelope.
5566        let list = render_mcp_report_json_for(&loader, &workspace, None)
5567            .expect("mcp list should not hard-fail on config parse errors (#144)");
5568        assert_eq!(list["kind"], "mcp");
5569        assert_eq!(list["action"], "list");
5570        assert_eq!(
5571            list["status"].as_str(),
5572            Some("degraded"),
5573            "top-level status should be 'degraded': {list}"
5574        );
5575        let err = list["config_load_error"]
5576            .as_str()
5577            .expect("config_load_error must be a string on degraded runs");
5578        assert!(
5579            err.contains("mcpServers.missing-command"),
5580            "config_load_error should name the malformed field path: {err}"
5581        );
5582        assert_eq!(list["configured_servers"], 0);
5583        assert!(list["servers"].as_array().unwrap().is_empty());
5584
5585        // show action: should also degrade (not hard-fail).
5586        let show = render_mcp_report_json_for(&loader, &workspace, Some("show everything"))
5587            .expect("mcp show should not hard-fail on config parse errors (#144)");
5588        assert_eq!(show["kind"], "mcp");
5589        assert_eq!(show["action"], "show");
5590        assert_eq!(
5591            show["status"].as_str(),
5592            Some("degraded"),
5593            "show action should also report status: 'degraded': {show}"
5594        );
5595        assert!(show["config_load_error"].is_string());
5596
5597        // Clean path: status: "ok", config_load_error: null.
5598        let clean_ws = temp_dir("mcp-degrades-144-clean");
5599        fs::create_dir_all(&clean_ws).expect("clean ws");
5600        let clean_loader = ConfigLoader::new(&clean_ws, &config_home);
5601        let clean_list = render_mcp_report_json_for(&clean_loader, &clean_ws, None)
5602            .expect("clean mcp list should succeed");
5603        assert_eq!(
5604            clean_list["status"].as_str(),
5605            Some("ok"),
5606            "clean run should report status: 'ok'"
5607        );
5608        assert!(clean_list["config_load_error"].is_null());
5609
5610        let _ = fs::remove_dir_all(workspace);
5611        let _ = fs::remove_dir_all(config_home);
5612        let _ = fs::remove_dir_all(clean_ws);
5613    }
5614
5615    #[test]
5616    fn parses_quoted_skill_frontmatter_values() {
5617        let contents = "---\nname: \"hud\"\ndescription: 'Quoted description'\n---\n";
5618        let (name, description) = super::parse_skill_frontmatter(contents);
5619        assert_eq!(name.as_deref(), Some("hud"));
5620        assert_eq!(description.as_deref(), Some("Quoted description"));
5621    }
5622
5623    #[test]
5624    fn installs_skill_into_user_registry_and_preserves_nested_files() {
5625        let workspace = temp_dir("skills-install-workspace");
5626        let source_root = workspace.join("source").join("help");
5627        let install_root = temp_dir("skills-install-root");
5628        write_skill(
5629            source_root.parent().expect("parent"),
5630            "help",
5631            "Helpful skill",
5632        );
5633        let script_dir = source_root.join("scripts");
5634        fs::create_dir_all(&script_dir).expect("script dir");
5635        fs::write(script_dir.join("run.sh"), "#!/bin/sh\necho help\n").expect("write script");
5636
5637        let installed = super::install_skill_into(
5638            source_root.to_str().expect("utf8 skill path"),
5639            &workspace,
5640            &install_root,
5641        )
5642        .expect("skill should install");
5643
5644        assert_eq!(installed.invocation_name, "help");
5645        assert_eq!(installed.display_name.as_deref(), Some("help"));
5646        assert!(installed.installed_path.ends_with(Path::new("help")));
5647        assert!(installed.installed_path.join("SKILL.md").is_file());
5648        assert!(installed
5649            .installed_path
5650            .join("scripts")
5651            .join("run.sh")
5652            .is_file());
5653
5654        let report = super::render_skill_install_report(&installed);
5655        assert!(report.contains("Result           installed help"));
5656        assert!(report.contains("Invoke as        $help"));
5657        assert!(report.contains(&install_root.display().to_string()));
5658
5659        let roots = vec![SkillRoot {
5660            source: DefinitionSource::UserCodexHome,
5661            path: install_root.clone(),
5662            origin: SkillOrigin::SkillsDir,
5663        }];
5664        let listed = render_skills_report(
5665            &load_skills_from_roots(&roots).expect("installed skills should load"),
5666        );
5667        assert!(listed.contains("User config roots:"));
5668        assert!(listed.contains("help · Helpful skill"));
5669
5670        let _ = fs::remove_dir_all(workspace);
5671        let _ = fs::remove_dir_all(install_root);
5672    }
5673
5674    #[test]
5675    fn installs_plugin_from_path_and_lists_it() {
5676        let config_home = temp_dir("home");
5677        let source_root = temp_dir("source");
5678        write_external_plugin(&source_root, "demo", "1.0.0");
5679
5680        let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
5681        let install = handle_plugins_slash_command(
5682            Some("install"),
5683            Some(source_root.to_str().expect("utf8 path")),
5684            &mut manager,
5685        )
5686        .expect("install command should succeed");
5687        assert!(install.reload_runtime);
5688        assert!(install.message.contains("installed demo@external"));
5689        assert!(install.message.contains("Name             demo"));
5690        assert!(install.message.contains("Version          1.0.0"));
5691        assert!(install.message.contains("Status           enabled"));
5692
5693        let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
5694            .expect("list command should succeed");
5695        assert!(!list.reload_runtime);
5696        assert!(list.message.contains("demo"));
5697        assert!(list.message.contains("v1.0.0"));
5698        assert!(list.message.contains("enabled"));
5699
5700        let _ = fs::remove_dir_all(config_home);
5701        let _ = fs::remove_dir_all(source_root);
5702    }
5703
5704    #[test]
5705    fn enables_and_disables_plugin_by_name() {
5706        let config_home = temp_dir("toggle-home");
5707        let source_root = temp_dir("toggle-source");
5708        write_external_plugin(&source_root, "demo", "1.0.0");
5709
5710        let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
5711        handle_plugins_slash_command(
5712            Some("install"),
5713            Some(source_root.to_str().expect("utf8 path")),
5714            &mut manager,
5715        )
5716        .expect("install command should succeed");
5717
5718        let disable = handle_plugins_slash_command(Some("disable"), Some("demo"), &mut manager)
5719            .expect("disable command should succeed");
5720        assert!(disable.reload_runtime);
5721        assert!(disable.message.contains("disabled demo@external"));
5722        assert!(disable.message.contains("Name             demo"));
5723        assert!(disable.message.contains("Status           disabled"));
5724
5725        let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
5726            .expect("list command should succeed");
5727        assert!(list.message.contains("demo"));
5728        assert!(list.message.contains("disabled"));
5729
5730        let enable = handle_plugins_slash_command(Some("enable"), Some("demo"), &mut manager)
5731            .expect("enable command should succeed");
5732        assert!(enable.reload_runtime);
5733        assert!(enable.message.contains("enabled demo@external"));
5734        assert!(enable.message.contains("Name             demo"));
5735        assert!(enable.message.contains("Status           enabled"));
5736
5737        let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
5738            .expect("list command should succeed");
5739        assert!(list.message.contains("demo"));
5740        assert!(list.message.contains("enabled"));
5741
5742        let _ = fs::remove_dir_all(config_home);
5743        let _ = fs::remove_dir_all(source_root);
5744    }
5745
5746    #[test]
5747    fn lists_auto_installed_bundled_plugins_with_status() {
5748        let config_home = temp_dir("bundled-home");
5749        let bundled_root = temp_dir("bundled-root");
5750        let bundled_plugin = bundled_root.join("starter");
5751        write_bundled_plugin(&bundled_plugin, "starter", "0.1.0", false);
5752
5753        let mut config = PluginManagerConfig::new(&config_home);
5754        config.bundled_root = Some(bundled_root.clone());
5755        let mut manager = PluginManager::new(config);
5756
5757        let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
5758            .expect("list command should succeed");
5759        assert!(!list.reload_runtime);
5760        assert!(list.message.contains("starter"));
5761        assert!(list.message.contains("v0.1.0"));
5762        assert!(list.message.contains("disabled"));
5763
5764        let _ = fs::remove_dir_all(config_home);
5765        let _ = fs::remove_dir_all(bundled_root);
5766    }
5767}