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 #[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(¤t);
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]
1995pub 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
2430pub 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 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 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 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 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 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 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 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, ®istry_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 let input = "/history";
4509
4510 let parsed = SlashCommand::parse(input);
4512
4513 assert_eq!(parsed, Ok(Some(SlashCommand::History { count: None })));
4515 }
4516
4517 #[test]
4518 fn parses_history_command_with_numeric_count() {
4519 let input = "/history 25";
4521
4522 let parsed = SlashCommand::parse(input);
4524
4525 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 let input = "/history 25 extra";
4538
4539 let error = parse_error_message(input);
4541
4542 assert!(error.contains("Usage: /history [count]"));
4544 }
4545
4546 #[test]
4547 fn rejects_unexpected_arguments_for_no_arg_commands() {
4548 let input = "/compact now";
4550
4551 let error = parse_error_message(input);
4553
4554 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 let input = "/permissions admin";
4564
4565 let error = parse_error_message(input);
4567
4568 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 let input = "/teleport";
4581
4582 let error = parse_error_message(input);
4584
4585 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 let session_input = "/session switch";
4594 let plugin_input = "/plugins list extra";
4595
4596 let session_error = parse_error_message(session_input);
4598 let plugin_error = parse_error_message(plugin_input);
4599
4600 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 let agents_input = "/agents show planner";
4611
4612 let agents_error = parse_error_message(agents_input);
4614
4615 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 let categories = ["Session", "Tools", "Config", "Debug"];
4715
4716 let help = render_slash_command_help();
4718
4719 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 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 let command = "plugins";
4762
4763 let help = render_slash_command_help_detail(command).expect("detail help should exist");
4765
4766 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 let session_input = "/session switch current next";
4786 let plugin_input = "/plugin enable demo extra";
4787
4788 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 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 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 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 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 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 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 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}