Skip to main content

rustyclaw_core/
commands.rs

1use crate::config::Config;
2use crate::providers;
3use crate::secrets::SecretsManager;
4use crate::skills::SkillManager;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum CommandAction {
8    None,
9    ClearMessages,
10    Quit,
11    /// Start (connect) the gateway
12    GatewayStart,
13    /// Stop (disconnect) the gateway
14    GatewayStop,
15    /// Restart the gateway connection
16    GatewayRestart,
17    /// Show gateway status info (no subcommand given)
18    GatewayInfo,
19    /// Change the active provider
20    SetProvider(String),
21    /// Change the active model
22    SetModel(String),
23    /// Show skills dialog
24    ShowSkills,
25    /// Show the secrets dialog
26    ShowSecrets,
27    /// Show the provider selector dialog
28    ShowProviderSelector,
29    /// Show the tool permissions dialog
30    ShowToolPermissions,
31    /// Reload gateway configuration
32    GatewayReload,
33    /// Download media by ID (id, optional destination path)
34    Download(String, Option<String>),
35}
36
37#[derive(Debug, Clone)]
38pub struct CommandResponse {
39    pub messages: Vec<String>,
40    pub action: CommandAction,
41}
42
43pub struct CommandContext<'a> {
44    pub secrets_manager: &'a mut SecretsManager,
45    pub skill_manager: &'a mut SkillManager,
46    pub config: &'a mut Config,
47}
48
49/// List of all known command names (without the / prefix).
50/// Includes subcommand forms so tab-completion works for them.
51pub fn command_names() -> Vec<String> {
52    let mut names: Vec<String> = vec![
53        "help".into(),
54        "clear".into(),
55        "download".into(),
56        "enable-access".into(),
57        "disable-access".into(),
58        "onboard".into(),
59        "reload-skills".into(),
60        "gateway".into(),
61        "gateway start".into(),
62        "gateway stop".into(),
63        "gateway restart".into(),
64        "reload".into(),
65        "provider".into(),
66        "model".into(),
67        "skills".into(),
68        "skill".into(),
69        "tools".into(),
70        "skill info".into(),
71        "skill remove".into(),
72        "skill search".into(),
73        "skill install".into(),
74        "skill publish".into(),
75        "skill link-secret".into(),
76        "skill unlink-secret".into(),
77        "skill create".into(),
78        "secrets".into(),
79        "clawhub".into(),
80        "clawhub auth".into(),
81        "clawhub auth login".into(),
82        "clawhub auth status".into(),
83        "clawhub auth logout".into(),
84        "clawhub search".into(),
85        "clawhub trending".into(),
86        "clawhub categories".into(),
87        "clawhub info".into(),
88        "clawhub browse".into(),
89        "clawhub profile".into(),
90        "clawhub starred".into(),
91        "clawhub star".into(),
92        "clawhub unstar".into(),
93        "clawhub install".into(),
94        "clawhub publish".into(),
95        "agent setup".into(),
96        "ollama".into(),
97        "exo".into(),
98        "uv".into(),
99        "npm".into(),
100        "quit".into(),
101    ];
102    for p in providers::provider_ids() {
103        names.push(format!("provider {}", p));
104    }
105    for m in providers::all_model_names() {
106        names.push(format!("model {}", m));
107    }
108    names
109}
110
111pub fn handle_command(input: &str, context: &mut CommandContext<'_>) -> CommandResponse {
112    // Strip the leading '/' if present
113    let trimmed = input.trim().trim_start_matches('/');
114    if trimmed.is_empty() {
115        return CommandResponse {
116            messages: Vec::new(),
117            action: CommandAction::None,
118        };
119    }
120
121    let parts: Vec<&str> = trimmed.split_whitespace().collect();
122    if parts.is_empty() {
123        return CommandResponse {
124            messages: Vec::new(),
125            action: CommandAction::None,
126        };
127    }
128
129    match parts[0] {
130        "agent" => {
131            if parts.get(1) == Some(&"setup") {
132                let ws_dir = context.config.workspace_dir();
133                match crate::tools::agent_setup::exec_agent_setup(&serde_json::json!({}), &ws_dir) {
134                    Ok(msg) => CommandResponse {
135                        messages: vec![msg],
136                        action: CommandAction::None,
137                    },
138                    Err(e) => CommandResponse {
139                        messages: vec![format!("Agent setup failed: {}", e)],
140                        action: CommandAction::None,
141                    },
142                }
143            } else {
144                CommandResponse {
145                    messages: vec!["Usage: /agent setup".to_string()],
146                    action: CommandAction::None,
147                }
148            }
149        }
150        "ollama" => {
151            // /ollama <action> [model]
152            let action = parts.get(1).copied().unwrap_or("status");
153            let model = parts.get(2).copied();
154            let dest = parts.get(3).copied();
155            let mut args = serde_json::json!({"action": action});
156            if let Some(m) = model { args["model"] = serde_json::json!(m); }
157            if let Some(d) = dest { args["destination"] = serde_json::json!(d); }
158            let ws_dir = context.config.workspace_dir();
159            match crate::tools::ollama::exec_ollama_manage(&args, &ws_dir) {
160                Ok(msg) => CommandResponse { messages: vec![msg], action: CommandAction::None },
161                Err(e) => CommandResponse { messages: vec![format!("ollama error: {}", e)], action: CommandAction::None },
162            }
163        }
164        "exo" => {
165            // /exo <action> [model]
166            let action = parts.get(1).copied().unwrap_or("status");
167            let model = parts.get(2).copied();
168            let mut args = serde_json::json!({"action": action});
169            if let Some(m) = model { args["model"] = serde_json::json!(m); }
170            let ws_dir = context.config.workspace_dir();
171            match crate::tools::exo_ai::exec_exo_manage(&args, &ws_dir) {
172                Ok(msg) => CommandResponse { messages: vec![msg], action: CommandAction::None },
173                Err(e) => CommandResponse { messages: vec![format!("exo error: {}", e)], action: CommandAction::None },
174            }
175        }
176        "uv" => {
177            // /uv <action> [package ...]
178            let action = parts.get(1).copied().unwrap_or("version");
179            let rest: Vec<&str> = parts.iter().skip(2).copied().collect();
180            let mut args = serde_json::json!({"action": action});
181            if rest.len() == 1 {
182                args["package"] = serde_json::json!(rest[0]);
183            } else if rest.len() > 1 {
184                args["packages"] = serde_json::json!(rest);
185            }
186            let ws_dir = context.config.workspace_dir();
187            match crate::tools::uv::exec_uv_manage(&args, &ws_dir) {
188                Ok(msg) => CommandResponse { messages: vec![msg], action: CommandAction::None },
189                Err(e) => CommandResponse { messages: vec![format!("uv error: {}", e)], action: CommandAction::None },
190            }
191        }
192        "npm" => {
193            // /npm <action> [package ...]
194            let action = parts.get(1).copied().unwrap_or("status");
195            let rest: Vec<&str> = parts.iter().skip(2).copied().collect();
196            let mut args = serde_json::json!({"action": action});
197            if rest.len() == 1 {
198                args["package"] = serde_json::json!(rest[0]);
199            } else if rest.len() > 1 {
200                args["packages"] = serde_json::json!(rest);
201            }
202            let ws_dir = context.config.workspace_dir();
203            match crate::tools::npm::exec_npm_manage(&args, &ws_dir) {
204                Ok(msg) => CommandResponse { messages: vec![msg], action: CommandAction::None },
205                Err(e) => CommandResponse { messages: vec![format!("npm error: {}", e)], action: CommandAction::None },
206            }
207        }
208        "help" => CommandResponse {
209            messages: vec![
210                "Available commands:".to_string(),
211                "  /help                    - Show this help".to_string(),
212                "  /clear                   - Clear messages and conversation memory".to_string(),
213                "  /download <id> [path]    - Download media attachment to file".to_string(),
214                "  /enable-access           - Enable agent access to secrets".to_string(),
215                "  /disable-access          - Disable agent access to secrets".to_string(),
216                "  /onboard                 - Run setup wizard (use CLI: rustyclaw onboard)".to_string(),
217                "  /reload-skills           - Reload skills".to_string(),
218                "  /gateway                 - Show gateway connection status".to_string(),
219                "  /gateway start           - Connect to the gateway".to_string(),
220                "  /gateway stop            - Disconnect from the gateway".to_string(),
221                "  /gateway restart         - Restart the gateway connection".to_string(),
222                "  /reload                  - Reload gateway config (no restart)".to_string(),
223                "  /provider <name>         - Change the AI provider".to_string(),
224                "  /model <name>            - Change the AI model".to_string(),
225                "  /skills                  - Show loaded skills".to_string(),
226                "  /skill                   - Skill management (info/install/publish/link)".to_string(),
227                "  /tools                   - Edit tool permissions (allow/deny/ask/skill)".to_string(),
228                "  /secrets                 - Open the secrets vault".to_string(),
229                "  /clawhub                 - ClawHub skill registry commands".to_string(),
230                "  /agent setup             - Set up local model tools (uv, exo, ollama)".to_string(),
231                "  /ollama <action> [model] - Ollama admin (setup/pull/list/ps/status/…)".to_string(),
232                "  /exo <action> [model]    - Exo cluster admin (setup/start/stop/status/…)".to_string(),
233                "  /uv <action> [pkg …]     - Python/uv admin (setup/pip-install/list/…)".to_string(),
234                "  /npm <action> [pkg …]    - Node.js/npm admin (setup/install/run/build/…)".to_string(),
235            ],
236            action: CommandAction::None,
237        },
238        "clear" => CommandResponse {
239            messages: vec!["Messages and conversation memory cleared.".to_string()],
240            action: CommandAction::ClearMessages,
241        },
242        "download" => {
243            if parts.len() < 2 {
244                CommandResponse {
245                    messages: vec![
246                        "Usage: /download <media_id> [destination_path]".to_string(),
247                        "Example: /download media_0001".to_string(),
248                        "Example: /download media_0001 ~/Downloads/image.jpg".to_string(),
249                    ],
250                    action: CommandAction::None,
251                }
252            } else {
253                let media_id = parts[1].to_string();
254                let dest_path = parts.get(2).map(|s| s.to_string());
255                CommandResponse {
256                    messages: vec![format!("Downloading {}...", media_id)],
257                    action: CommandAction::Download(media_id, dest_path),
258                }
259            }
260        },
261        "enable-access" => {
262            context.secrets_manager.set_agent_access(true);
263            context.config.agent_access = true;
264            let _ = context.config.save(None);
265            CommandResponse {
266                messages: vec!["Agent access to secrets enabled.".to_string()],
267                action: CommandAction::None,
268            }
269        }
270        "disable-access" => {
271            context.secrets_manager.set_agent_access(false);
272            context.config.agent_access = false;
273            let _ = context.config.save(None);
274            CommandResponse {
275                messages: vec!["Agent access to secrets disabled.".to_string()],
276                action: CommandAction::None,
277            }
278        }
279        "reload-skills" => match context.skill_manager.load_skills() {
280            Ok(_) => CommandResponse {
281                messages: vec![format!(
282                    "Reloaded {} skills.",
283                    context.skill_manager.get_skills().len()
284                )],
285                action: CommandAction::None,
286            },
287            Err(err) => CommandResponse {
288                messages: vec![format!("Error reloading skills: {}", err)],
289                action: CommandAction::None,
290            },
291        },
292        "onboard" => CommandResponse {
293            messages: vec![
294                "The onboard wizard is an interactive CLI command.".to_string(),
295                "Run it from your terminal:  rustyclaw onboard".to_string(),
296            ],
297            action: CommandAction::None,
298        },
299        "gateway" => match parts.get(1).copied() {
300            Some("start") => CommandResponse {
301                messages: vec!["Starting gateway connection…".to_string()],
302                action: CommandAction::GatewayStart,
303            },
304            Some("stop") => CommandResponse {
305                messages: vec!["Stopping gateway connection…".to_string()],
306                action: CommandAction::GatewayStop,
307            },
308            Some("restart") => CommandResponse {
309                messages: vec!["Restarting gateway connection…".to_string()],
310                action: CommandAction::GatewayRestart,
311            },
312            Some(sub) => CommandResponse {
313                messages: vec![
314                    format!("Unknown gateway subcommand: {}", sub),
315                    "Usage: /gateway start|stop|restart".to_string(),
316                ],
317                action: CommandAction::None,
318            },
319            None => CommandResponse {
320                messages: Vec::new(),
321                action: CommandAction::GatewayInfo,
322            },
323        },
324        "reload" => CommandResponse {
325            messages: vec!["Reloading gateway configuration…".to_string()],
326            action: CommandAction::GatewayReload,
327        },
328        "skills" => CommandResponse {
329            messages: Vec::new(),
330            action: CommandAction::ShowSkills,
331        },
332        "tools" => CommandResponse {
333            messages: Vec::new(),
334            action: CommandAction::ShowToolPermissions,
335        },
336        "skill" => handle_skill_subcommand(&parts[1..], context),
337        "secrets" => CommandResponse {
338            messages: Vec::new(),
339            action: CommandAction::ShowSecrets,
340        },
341        "provider" => match parts.get(1) {
342            Some(name) => {
343                let name = name.to_string();
344                CommandResponse {
345                    messages: vec![format!("Switching provider to {}…", name)],
346                    action: CommandAction::SetProvider(name),
347                }
348            }
349            None => {
350                CommandResponse {
351                    messages: Vec::new(),
352                    action: CommandAction::ShowProviderSelector,
353                }
354            }
355        },
356        "model" => match parts.get(1) {
357            Some(name) => {
358                let name = name.to_string();
359                CommandResponse {
360                    messages: vec![format!("Switching model to {}…", name)],
361                    action: CommandAction::SetModel(name),
362                }
363            }
364            None => {
365                let list = providers::all_model_names().join(", ");
366                CommandResponse {
367                    messages: vec![
368                        "Usage: /model <name>".to_string(),
369                        format!("Known models: {}", list),
370                    ],
371                    action: CommandAction::None,
372                }
373            }
374        },
375        "clawhub" | "hub" | "registry" => handle_clawhub_subcommand(&parts[1..], context),
376        "q" | "quit" | "exit" => CommandResponse {
377            messages: Vec::new(),
378            action: CommandAction::Quit,
379        },
380        _ => CommandResponse {
381            messages: vec![
382                format!("Unknown command: /{}", parts[0]),
383                "Type /help for available commands".to_string(),
384            ],
385            action: CommandAction::None,
386        },
387    }
388}
389
390fn handle_skill_subcommand(parts: &[&str], context: &mut CommandContext<'_>) -> CommandResponse {
391    match parts.first().copied() {
392        Some("info") => {
393            let name = parts.get(1).copied().unwrap_or("");
394            if name.is_empty() {
395                return CommandResponse {
396                    messages: vec!["Usage: /skill info <name>".to_string()],
397                    action: CommandAction::None,
398                };
399            }
400            match context.skill_manager.skill_info(name) {
401                Some(info) => CommandResponse {
402                    messages: vec![info],
403                    action: CommandAction::None,
404                },
405                None => CommandResponse {
406                    messages: vec![format!("Skill '{}' not found.", name)],
407                    action: CommandAction::None,
408                },
409            }
410        }
411        Some("remove") => {
412            let name = parts.get(1).copied().unwrap_or("");
413            if name.is_empty() {
414                return CommandResponse {
415                    messages: vec!["Usage: /skill remove <name>".to_string()],
416                    action: CommandAction::None,
417                };
418            }
419            match context.skill_manager.remove_skill(name) {
420                Ok(()) => CommandResponse {
421                    messages: vec![format!("Skill '{}' removed.", name)],
422                    action: CommandAction::None,
423                },
424                Err(e) => CommandResponse {
425                    messages: vec![e.to_string()],
426                    action: CommandAction::None,
427                },
428            }
429        }
430        Some("search") => {
431            let query = parts[1..].join(" ");
432            if query.is_empty() {
433                return CommandResponse {
434                    messages: vec!["Usage: /skill search <query>".to_string()],
435                    action: CommandAction::None,
436                };
437            }
438            match context.skill_manager.search_registry(&query) {
439                Ok(results) => {
440                    if results.is_empty() {
441                        CommandResponse {
442                            messages: vec![format!("No skills found matching '{}'.", query)],
443                            action: CommandAction::None,
444                        }
445                    } else {
446                        let has_local = results.iter().any(|r| r.version == "local");
447                        let header = if has_local {
448                            format!(
449                                "{} local skill(s) matching '{}' (registry offline):",
450                                results.len(),
451                                query,
452                            )
453                        } else {
454                            format!(
455                                "{} result(s) for '{}':",
456                                results.len(),
457                                query,
458                            )
459                        };
460                        let mut msgs: Vec<String> = vec![header];
461                        for r in &results {
462                            msgs.push(format!(
463                                "  • {} v{} by {} — {}",
464                                r.name, r.version, r.author, r.description,
465                            ));
466                        }
467                        CommandResponse {
468                            messages: msgs,
469                            action: CommandAction::None,
470                        }
471                    }
472                }
473                Err(e) => CommandResponse {
474                    messages: vec![format!("Registry search failed: {}", e)],
475                    action: CommandAction::None,
476                },
477            }
478        }
479        Some("install") => {
480            let name = parts.get(1).copied().unwrap_or("");
481            if name.is_empty() {
482                return CommandResponse {
483                    messages: vec!["Usage: /skill install <name> [version]".to_string()],
484                    action: CommandAction::None,
485                };
486            }
487            let version = parts.get(2).copied();
488            match context.skill_manager.install_from_registry(name, version) {
489                Ok(skill) => {
490                    let _ = context.skill_manager.load_skills();
491                    CommandResponse {
492                        messages: vec![format!("Skill '{}' installed from ClawHub.", skill.name)],
493                        action: CommandAction::None,
494                    }
495                }
496                Err(e) => CommandResponse {
497                    messages: vec![format!("Install failed: {}", e)],
498                    action: CommandAction::None,
499                },
500            }
501        }
502        Some("publish") => {
503            let name = parts.get(1).copied().unwrap_or("");
504            if name.is_empty() {
505                return CommandResponse {
506                    messages: vec!["Usage: /skill publish <name>".to_string()],
507                    action: CommandAction::None,
508                };
509            }
510            match context.skill_manager.publish_to_registry(name) {
511                Ok(msg) => CommandResponse {
512                    messages: vec![msg],
513                    action: CommandAction::None,
514                },
515                Err(e) => CommandResponse {
516                    messages: vec![format!("Publish failed: {}", e)],
517                    action: CommandAction::None,
518                },
519            }
520        }
521        Some("link-secret") => {
522            let skill = parts.get(1).copied().unwrap_or("");
523            let secret = parts.get(2).copied().unwrap_or("");
524            if skill.is_empty() || secret.is_empty() {
525                return CommandResponse {
526                    messages: vec!["Usage: /skill link-secret <skill> <secret>".to_string()],
527                    action: CommandAction::None,
528                };
529            }
530            match context.skill_manager.link_secret(skill, secret) {
531                Ok(_) => CommandResponse {
532                    messages: vec![format!(
533                        "Secret '{}' linked to skill '{}'.",
534                        secret, skill,
535                    )],
536                    action: CommandAction::None,
537                },
538                Err(e) => CommandResponse {
539                    messages: vec![format!("Link failed: {}", e)],
540                    action: CommandAction::None,
541                },
542            }
543        }
544        Some("unlink-secret") => {
545            let skill = parts.get(1).copied().unwrap_or("");
546            let secret = parts.get(2).copied().unwrap_or("");
547            if skill.is_empty() || secret.is_empty() {
548                return CommandResponse {
549                    messages: vec!["Usage: /skill unlink-secret <skill> <secret>".to_string()],
550                    action: CommandAction::None,
551                };
552            }
553            match context.skill_manager.unlink_secret(skill, secret) {
554                Ok(_) => CommandResponse {
555                    messages: vec![format!(
556                        "Secret '{}' unlinked from skill '{}'.",
557                        secret, skill,
558                    )],
559                    action: CommandAction::None,
560                },
561                Err(e) => CommandResponse {
562                    messages: vec![format!("Unlink failed: {}", e)],
563                    action: CommandAction::None,
564                },
565            }
566        }
567        Some("create") => {
568            // /skill create <name> <description>
569            // Instructions are sent to the agent as a follow-up message
570            let name = parts.get(1).copied().unwrap_or("");
571            if name.is_empty() {
572                return CommandResponse {
573                    messages: vec![
574                        "Usage: /skill create <name> <one-line description>".to_string(),
575                        "".to_string(),
576                        "This creates an empty skill scaffold. To have the agent write a full".to_string(),
577                        "skill from a prompt, just ask: \"Create a skill that deploys to S3\"".to_string(),
578                    ],
579                    action: CommandAction::None,
580                };
581            }
582            let description = if parts.len() > 2 {
583                parts[2..].join(" ")
584            } else {
585                format!("A skill called {}", name)
586            };
587            let instructions = format!("# {}\n\nTODO: Add instructions for this skill.", name);
588            match context.skill_manager.create_skill(name, &description, &instructions, None) {
589                Ok(path) => CommandResponse {
590                    messages: vec![
591                        format!("✅ Skill '{}' created at {}", name, path.display()),
592                        "Edit the SKILL.md to add instructions, or ask the agent to fill it in.".to_string(),
593                    ],
594                    action: CommandAction::None,
595                },
596                Err(e) => CommandResponse {
597                    messages: vec![format!("Create failed: {}", e)],
598                    action: CommandAction::None,
599                },
600            }
601        }
602        Some(sub) => CommandResponse {
603            messages: vec![
604                format!("Unknown skill subcommand: {}", sub),
605                "Usage: /skill info|remove|search|install|publish|create|link-secret|unlink-secret".to_string(),
606            ],
607            action: CommandAction::None,
608        },
609        None => CommandResponse {
610            messages: vec![
611                "Skill commands:".to_string(),
612                "  /skill info <name>                 — Show skill details".to_string(),
613                "  /skill remove <name>               — Remove a skill".to_string(),
614                "  /skill search <query>              — Search ClawHub registry".to_string(),
615                "  /skill install <name> [version]    — Install from ClawHub".to_string(),
616                "  /skill publish <name>              — Publish to ClawHub".to_string(),
617                "  /skill create <name> [description] — Create a new skill".to_string(),
618                "  /skill link-secret <skill> <secret> — Link secret to skill".to_string(),
619                "  /skill unlink-secret <skill> <secret> — Unlink secret".to_string(),
620            ],
621            action: CommandAction::None,
622        },
623    }
624}
625
626fn handle_clawhub_subcommand(parts: &[&str], context: &mut CommandContext<'_>) -> CommandResponse {
627    match parts.first().copied() {
628        Some("auth") => match parts.get(1).copied() {
629            Some("login") => {
630                // Token-based login: /clawhub auth login <token>
631                let token = parts.get(2).copied().unwrap_or("");
632                if token.is_empty() {
633                    return CommandResponse {
634                        messages: vec![
635                            "Usage: /clawhub auth login <api_token>".to_string(),
636                            "Get your token at https://clawhub.ai/settings/tokens".to_string(),
637                        ],
638                        action: CommandAction::None,
639                    };
640                }
641                match context.skill_manager.auth_token(token) {
642                    Ok(resp) if resp.ok => {
643                        // Store token in config
644                        context.config.clawhub_token = Some(token.to_string());
645                        let _ = context.config.save(None);
646                        let url = context.skill_manager.registry_url().to_string();
647                        context.skill_manager.set_registry(
648                            &url,
649                            Some(token.to_string()),
650                        );
651                        let user = resp.username.unwrap_or_else(|| "unknown".into());
652                        CommandResponse {
653                            messages: vec![format!("✓ Authenticated as '{}' on ClawHub.", user)],
654                            action: CommandAction::None,
655                        }
656                    }
657                    Ok(_) => CommandResponse {
658                        messages: vec!["✗ Token is invalid.".to_string()],
659                        action: CommandAction::None,
660                    },
661                    Err(e) => CommandResponse {
662                        messages: vec![format!("✗ Auth failed: {}", e)],
663                        action: CommandAction::None,
664                    },
665                }
666            }
667            Some("status") => match context.skill_manager.auth_status() {
668                Ok(msg) => CommandResponse {
669                    messages: vec![msg],
670                    action: CommandAction::None,
671                },
672                Err(e) => CommandResponse {
673                    messages: vec![format!("Auth status check failed: {}", e)],
674                    action: CommandAction::None,
675                },
676            },
677            Some("logout") => {
678                context.config.clawhub_token = None;
679                let _ = context.config.save(None);
680                let url = context.skill_manager.registry_url().to_string();
681                context.skill_manager.set_registry(
682                    &url,
683                    None,
684                );
685                CommandResponse {
686                    messages: vec!["Logged out from ClawHub.".to_string()],
687                    action: CommandAction::None,
688                }
689            }
690            Some(sub) => CommandResponse {
691                messages: vec![
692                    format!("Unknown auth subcommand: {}", sub),
693                    "Usage: /clawhub auth login|status|logout".to_string(),
694                ],
695                action: CommandAction::None,
696            },
697            None => CommandResponse {
698                messages: vec![
699                    "ClawHub auth commands:".to_string(),
700                    "  /clawhub auth login <token>  — Authenticate with API token".to_string(),
701                    "  /clawhub auth status         — Show auth status".to_string(),
702                    "  /clawhub auth logout         — Remove stored credentials".to_string(),
703                ],
704                action: CommandAction::None,
705            },
706        },
707        Some("search") => {
708            let query = parts[1..].join(" ");
709            if query.is_empty() {
710                return CommandResponse {
711                    messages: vec!["Usage: /clawhub search <query>".to_string()],
712                    action: CommandAction::None,
713                };
714            }
715            match context.skill_manager.search_registry(&query) {
716                Ok(results) => {
717                    if results.is_empty() {
718                        CommandResponse {
719                            messages: vec![format!("No skills found matching '{}'.", query)],
720                            action: CommandAction::None,
721                        }
722                    } else {
723                        let mut msgs = vec![format!("{} result(s) for '{}':", results.len(), query)];
724                        for r in &results {
725                            let dl = if r.downloads > 0 {
726                                format!(" (↓{})", r.downloads)
727                            } else {
728                                String::new()
729                            };
730                            msgs.push(format!(
731                                "  • {} v{} by {} — {}{}",
732                                r.name, r.version, r.author, r.description, dl,
733                            ));
734                        }
735                        msgs.push(String::new());
736                        msgs.push("Install with: /clawhub install <name>".to_string());
737                        CommandResponse {
738                            messages: msgs,
739                            action: CommandAction::None,
740                        }
741                    }
742                }
743                Err(e) => CommandResponse {
744                    messages: vec![format!("Search failed: {}", e)],
745                    action: CommandAction::None,
746                },
747            }
748        }
749        Some("trending") => {
750            let category = parts.get(1).copied();
751            match context.skill_manager.trending(category, Some(15)) {
752                Ok(entries) => {
753                    if entries.is_empty() {
754                        CommandResponse {
755                            messages: vec!["No trending skills found.".to_string()],
756                            action: CommandAction::None,
757                        }
758                    } else {
759                        let header = match category {
760                            Some(cat) => format!("Trending skills in '{}':", cat),
761                            None => "Trending skills on ClawHub:".to_string(),
762                        };
763                        let mut msgs = vec![header];
764                        for (i, e) in entries.iter().enumerate() {
765                            msgs.push(format!(
766                                "  {}. {} — {} (★{} ↓{})",
767                                i + 1,
768                                e.name,
769                                e.description,
770                                e.stars,
771                                e.downloads,
772                            ));
773                        }
774                        CommandResponse {
775                            messages: msgs,
776                            action: CommandAction::None,
777                        }
778                    }
779                }
780                Err(e) => CommandResponse {
781                    messages: vec![format!("Failed to fetch trending: {}", e)],
782                    action: CommandAction::None,
783                },
784            }
785        }
786        Some("categories" | "cats") => match context.skill_manager.categories() {
787            Ok(cats) => {
788                if cats.is_empty() {
789                    CommandResponse {
790                        messages: vec!["No categories found.".to_string()],
791                        action: CommandAction::None,
792                    }
793                } else {
794                    let mut msgs = vec!["ClawHub skill categories:".to_string()];
795                    for c in &cats {
796                        let count = if c.count > 0 {
797                            format!(" ({})", c.count)
798                        } else {
799                            String::new()
800                        };
801                        msgs.push(format!("  • {}{} — {}", c.name, count, c.description));
802                    }
803                    msgs.push(String::new());
804                    msgs.push("Browse by category: /clawhub trending <category>".to_string());
805                    CommandResponse {
806                        messages: msgs,
807                        action: CommandAction::None,
808                    }
809                }
810            }
811            Err(e) => CommandResponse {
812                messages: vec![format!("Failed to fetch categories: {}", e)],
813                action: CommandAction::None,
814            },
815        },
816        Some("info") => {
817            let name = parts.get(1).copied().unwrap_or("");
818            if name.is_empty() {
819                return CommandResponse {
820                    messages: vec!["Usage: /clawhub info <skill_name>".to_string()],
821                    action: CommandAction::None,
822                };
823            }
824            match context.skill_manager.registry_info(name) {
825                Ok(detail) => {
826                    let mut msgs = vec![format!("{}  v{}", detail.name, detail.version)];
827                    if !detail.description.is_empty() {
828                        msgs.push(format!("  {}", detail.description));
829                    }
830                    if !detail.author.is_empty() {
831                        msgs.push(format!("  Author: {}", detail.author));
832                    }
833                    if !detail.license.is_empty() {
834                        msgs.push(format!("  License: {}", detail.license));
835                    }
836                    msgs.push(format!("  ★ {}  ↓ {}", detail.stars, detail.downloads));
837                    if let Some(ref repo) = detail.repository {
838                        msgs.push(format!("  Repo: {}", repo));
839                    }
840                    if !detail.categories.is_empty() {
841                        msgs.push(format!("  Categories: {}", detail.categories.join(", ")));
842                    }
843                    if !detail.required_secrets.is_empty() {
844                        msgs.push(format!("  Requires secrets: {}", detail.required_secrets.join(", ")));
845                    }
846                    if !detail.updated_at.is_empty() {
847                        msgs.push(format!("  Updated: {}", detail.updated_at));
848                    }
849                    CommandResponse {
850                        messages: msgs,
851                        action: CommandAction::None,
852                    }
853                }
854                Err(e) => CommandResponse {
855                    messages: vec![format!("Failed to fetch skill info: {}", e)],
856                    action: CommandAction::None,
857                },
858            }
859        }
860        Some("browse" | "open") => {
861            let url = context.skill_manager.registry_url();
862            // Try to open in default browser
863            #[cfg(target_os = "macos")]
864            let _ = std::process::Command::new("open").arg(url).spawn();
865            #[cfg(target_os = "linux")]
866            let _ = std::process::Command::new("xdg-open").arg(url).spawn();
867            #[cfg(target_os = "windows")]
868            let _ = std::process::Command::new("cmd").args(["/C", "start", url]).spawn();
869            CommandResponse {
870                messages: vec![format!("Opening {} in your browser…", url)],
871                action: CommandAction::None,
872            }
873        }
874        Some("profile" | "me") => match context.skill_manager.profile() {
875            Ok(p) => {
876                let mut msgs = vec![
877                    format!("ClawHub profile: {}", p.username),
878                ];
879                if !p.display_name.is_empty() {
880                    msgs.push(format!("  Name: {}", p.display_name));
881                }
882                if !p.bio.is_empty() {
883                    msgs.push(format!("  Bio: {}", p.bio));
884                }
885                msgs.push(format!("  Published: {}  Starred: {}", p.published_count, p.starred_count));
886                if !p.joined.is_empty() {
887                    msgs.push(format!("  Joined: {}", p.joined));
888                }
889                CommandResponse {
890                    messages: msgs,
891                    action: CommandAction::None,
892                }
893            }
894            Err(e) => CommandResponse {
895                messages: vec![format!("Failed to fetch profile: {}", e)],
896                action: CommandAction::None,
897            },
898        },
899        Some("starred" | "stars") => match context.skill_manager.starred() {
900            Ok(entries) => {
901                if entries.is_empty() {
902                    CommandResponse {
903                        messages: vec!["No starred skills. Star skills with: /clawhub star <name>".to_string()],
904                        action: CommandAction::None,
905                    }
906                } else {
907                    let mut msgs = vec![format!("{} starred skill(s):", entries.len())];
908                    for e in &entries {
909                        msgs.push(format!(
910                            "  ★ {} v{} by {} — {}",
911                            e.name, e.version, e.author, e.description,
912                        ));
913                    }
914                    CommandResponse {
915                        messages: msgs,
916                        action: CommandAction::None,
917                    }
918                }
919            }
920            Err(e) => CommandResponse {
921                messages: vec![format!("Failed to fetch starred skills: {}", e)],
922                action: CommandAction::None,
923            },
924        },
925        Some("star") => {
926            let name = parts.get(1).copied().unwrap_or("");
927            if name.is_empty() {
928                return CommandResponse {
929                    messages: vec!["Usage: /clawhub star <skill_name>".to_string()],
930                    action: CommandAction::None,
931                };
932            }
933            match context.skill_manager.star(name) {
934                Ok(msg) => CommandResponse {
935                    messages: vec![format!("★ {}", msg)],
936                    action: CommandAction::None,
937                },
938                Err(e) => CommandResponse {
939                    messages: vec![format!("Star failed: {}", e)],
940                    action: CommandAction::None,
941                },
942            }
943        }
944        Some("unstar") => {
945            let name = parts.get(1).copied().unwrap_or("");
946            if name.is_empty() {
947                return CommandResponse {
948                    messages: vec!["Usage: /clawhub unstar <skill_name>".to_string()],
949                    action: CommandAction::None,
950                };
951            }
952            match context.skill_manager.unstar(name) {
953                Ok(msg) => CommandResponse {
954                    messages: vec![msg],
955                    action: CommandAction::None,
956                },
957                Err(e) => CommandResponse {
958                    messages: vec![format!("Unstar failed: {}", e)],
959                    action: CommandAction::None,
960                },
961            }
962        }
963        Some("install") => {
964            let name = parts.get(1).copied().unwrap_or("");
965            if name.is_empty() {
966                return CommandResponse {
967                    messages: vec!["Usage: /clawhub install <name> [version]".to_string()],
968                    action: CommandAction::None,
969                };
970            }
971            let version = parts.get(2).copied();
972            match context.skill_manager.install_from_registry(name, version) {
973                Ok(skill) => {
974                    let _ = context.skill_manager.load_skills();
975                    CommandResponse {
976                        messages: vec![format!("✓ Skill '{}' installed from ClawHub.", skill.name)],
977                        action: CommandAction::None,
978                    }
979                }
980                Err(e) => CommandResponse {
981                    messages: vec![format!("Install failed: {}", e)],
982                    action: CommandAction::None,
983                },
984            }
985        }
986        Some("publish") => {
987            let name = parts.get(1).copied().unwrap_or("");
988            if name.is_empty() {
989                return CommandResponse {
990                    messages: vec!["Usage: /clawhub publish <name>".to_string()],
991                    action: CommandAction::None,
992                };
993            }
994            match context.skill_manager.publish_to_registry(name) {
995                Ok(msg) => CommandResponse {
996                    messages: vec![format!("✓ {}", msg)],
997                    action: CommandAction::None,
998                },
999                Err(e) => CommandResponse {
1000                    messages: vec![format!("Publish failed: {}", e)],
1001                    action: CommandAction::None,
1002                },
1003            }
1004        }
1005        Some(sub) => CommandResponse {
1006            messages: vec![
1007                format!("Unknown clawhub subcommand: {}", sub),
1008                "Type /clawhub for available commands.".to_string(),
1009            ],
1010            action: CommandAction::None,
1011        },
1012        None => CommandResponse {
1013            messages: vec![
1014                "ClawHub — Skill Registry".to_string(),
1015                format!("  Registry: {}", context.skill_manager.registry_url()),
1016                String::new(),
1017                "  /clawhub auth                — Authentication commands".to_string(),
1018                "  /clawhub search <query>      — Search for skills".to_string(),
1019                "  /clawhub trending [category] — Browse trending skills".to_string(),
1020                "  /clawhub categories          — List skill categories".to_string(),
1021                "  /clawhub info <name>         — Show skill details".to_string(),
1022                "  /clawhub browse              — Open ClawHub in browser".to_string(),
1023                "  /clawhub profile             — Show your profile".to_string(),
1024                "  /clawhub starred             — List your starred skills".to_string(),
1025                "  /clawhub star <name>         — Star a skill".to_string(),
1026                "  /clawhub unstar <name>       — Unstar a skill".to_string(),
1027                "  /clawhub install <name>      — Install a skill".to_string(),
1028                "  /clawhub publish <name>      — Publish a local skill".to_string(),
1029            ],
1030            action: CommandAction::None,
1031        },
1032    }
1033}