Skip to main content

microclaw_engine/clawhub/
tools.rs

1use crate::clawhub::service::{ClawHubGateway, RegistryClawHubGateway};
2use crate::config::Config;
3use crate::internal::clawhub::install::InstallOptions;
4use crate::llm_types::ToolDefinition;
5use crate::tools::{schema_object, Tool, ToolResult};
6use async_trait::async_trait;
7use std::path::PathBuf;
8use std::sync::Arc;
9
10pub struct ClawHubSearchTool {
11    gateway: Arc<dyn ClawHubGateway>,
12}
13
14pub struct ClawHubInstallTool {
15    gateway: Arc<dyn ClawHubGateway>,
16    skills_dir: PathBuf,
17    lockfile_path: PathBuf,
18    skip_security: bool,
19}
20
21impl ClawHubSearchTool {
22    pub fn new(config: &Config) -> Self {
23        let gateway: Arc<dyn ClawHubGateway> =
24            Arc::new(RegistryClawHubGateway::from_config(config));
25        Self { gateway }
26    }
27}
28
29#[async_trait]
30impl Tool for ClawHubSearchTool {
31    fn name(&self) -> &str {
32        "clawhub_search"
33    }
34
35    fn definition(&self) -> ToolDefinition {
36        ToolDefinition {
37            name: "clawhub_search".to_string(),
38            description: "Search ClawHub registry for available skills. Use this instead of running clawdhub CLI commands - this is the built-in way to discover skills.".to_string(),
39            input_schema: schema_object(
40                serde_json::json!({
41                    "query": {
42                        "type": "string",
43                        "description": "Natural language search query"
44                    },
45                    "limit": {
46                        "type": "integer",
47                        "description": "Maximum number of results (default 10, max 50)"
48                    },
49                    "sort": {
50                        "type": "string",
51                        "description": "Sort order: trending, installs, latest"
52                    }
53                }),
54                &["query"],
55            ),
56        }
57    }
58
59    async fn execute(&self, params: serde_json::Value) -> ToolResult {
60        let query = match params.get("query").and_then(|v| v.as_str()) {
61            Some(q) => q,
62            None => return ToolResult::error("Missing required parameter: query".into()),
63        };
64        let limit = params.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
65        let sort = params
66            .get("sort")
67            .and_then(|v| v.as_str())
68            .unwrap_or("trending");
69
70        let gateway = self.gateway.clone();
71        let results = gateway.search(query, limit.min(50), sort).await;
72
73        match results {
74            Ok(results) => {
75                let output = results
76                    .iter()
77                    .map(|r| {
78                        let vt_info = r
79                            .virustotal
80                            .as_ref()
81                            .map(|v| {
82                                format!(" | VirusTotal: {} ({} reports)", v.status, v.report_count)
83                            })
84                            .unwrap_or_default();
85                        format!(
86                            "• {} — {} ({} installs){}",
87                            r.slug, r.description, r.install_count, vt_info
88                        )
89                    })
90                    .collect::<Vec<_>>()
91                    .join("\n");
92                ToolResult::success(output)
93            }
94            Err(e) => ToolResult::error(e.to_string()),
95        }
96    }
97}
98
99impl ClawHubInstallTool {
100    pub fn new(config: &Config) -> Self {
101        let gateway: Arc<dyn ClawHubGateway> =
102            Arc::new(RegistryClawHubGateway::from_config(config));
103        let skills_dir = PathBuf::from(config.skills_data_dir());
104        let lockfile_path = config.clawhub_lockfile_path();
105        Self {
106            gateway,
107            skills_dir,
108            lockfile_path,
109            skip_security: config.clawhub.skip_security_warnings,
110        }
111    }
112}
113
114#[async_trait]
115impl Tool for ClawHubInstallTool {
116    fn name(&self) -> &str {
117        "clawhub_install"
118    }
119
120    fn definition(&self) -> ToolDefinition {
121        ToolDefinition {
122            name: "clawhub_install".to_string(),
123            description: "Install a skill from ClawHub into ~/.microclaw/skills/ (or configured skills dir). Use this instead of running clawdhub CLI commands - this is the built-in way to install ClawHub skills."
124                .to_string(),
125            input_schema: schema_object(
126                serde_json::json!({
127                    "slug": {
128                        "type": "string",
129                        "description": "The ClawHub skill slug to install"
130                    },
131                    "version": {
132                        "type": "string",
133                        "description": "Specific version to install (default: latest)"
134                    },
135                    "force": {
136                        "type": "boolean",
137                        "description": "Overwrite if already installed"
138                    }
139                }),
140                &["slug"],
141            ),
142        }
143    }
144
145    async fn execute(&self, params: serde_json::Value) -> ToolResult {
146        let slug = match params.get("slug").and_then(|v| v.as_str()) {
147            Some(s) => s,
148            None => return ToolResult::error("Missing required parameter: slug".into()),
149        };
150        let version = params.get("version").and_then(|v| v.as_str());
151        let force = params
152            .get("force")
153            .and_then(|v| v.as_bool())
154            .unwrap_or(false);
155
156        let gateway = self.gateway.clone();
157        let options = InstallOptions {
158            force,
159            skip_gates: false,
160            skip_security: self.skip_security,
161        };
162
163        // Retry up to 3 times with brief delays for transient failures
164        let mut last_error = None;
165        for attempt in 1..=3 {
166            match gateway
167                .install(
168                    slug,
169                    version,
170                    &self.skills_dir,
171                    &self.lockfile_path,
172                    &options,
173                )
174                .await
175            {
176                Ok(result) => {
177                    let mut msg = result.message;
178                    for w in &result.warnings {
179                        msg.push_str(&format!("\n⚠ {}", w));
180                    }
181                    if !result.success {
182                        return ToolResult::error(msg);
183                    }
184                    if result.requires_restart {
185                        msg.push_str("\nRestart MicroClaw or run /reload-skills to activate.");
186                    }
187                    return ToolResult::success(msg);
188                }
189                Err(e) => {
190                    last_error = Some(e);
191                    if attempt < 3 {
192                        tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
193                    }
194                }
195            }
196        }
197
198        // All retries failed - return the last error
199        match last_error {
200            Some(e) => ToolResult::error(e.to_string()),
201            None => ToolResult::error("Unexpected error during installation".into()),
202        }
203    }
204}