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 GatewayStart,
13 GatewayStop,
15 GatewayRestart,
17 GatewayInfo,
19 SetProvider(String),
21 SetModel(String),
23 ShowSkills,
25 ShowSecrets,
27 ShowProviderSelector,
29 ShowToolPermissions,
31 GatewayReload,
33 FetchModels,
35 Download(String, Option<String>),
37 ThreadNew(String),
39 ThreadList,
41 ThreadClose(u64),
43 ThreadRename(u64, String),
45 ThreadBackground,
47 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
63fn 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
129pub 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
141pub 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 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 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 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 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 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 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 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 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 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 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 #[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}