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