1use async_trait::async_trait;
19use oxi_sdk::{AgentTool, AgentToolResult, ToolContext};
20use serde_json::{json, Value};
21use tokio::sync::oneshot;
22
23use crate::kernel_handle::KernelHandle;
24use crate::kernel_handle::MarketplaceApi;
25
26pub struct MarketplaceTool {
46 api: MarketplaceApi,
47}
48
49impl MarketplaceTool {
50 pub fn from_kernel(kernel: &KernelHandle) -> Self {
52 Self {
53 api: kernel.marketplace_api.clone(),
54 }
55 }
56}
57
58impl std::fmt::Debug for MarketplaceTool {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 f.debug_struct("MarketplaceTool").finish()
61 }
62}
63
64#[async_trait]
65impl AgentTool for MarketplaceTool {
66 fn name(&self) -> &str {
67 "marketplace"
68 }
69
70 fn label(&self) -> &str {
71 "Marketplace"
72 }
73
74 fn description(&self) -> &'static str {
75 "Search, install, and update skills from the ClawHub marketplace and Skills.sh registry. \
76 ClawHub actions: search, get, install, update, update_all, check_updates. \
77 Skills.sh actions: skills_sh_search, skills_sh_list, skills_sh_install, skills_sh_detail."
78 }
79
80 fn parameters_schema(&self) -> Value {
81 json!({
82 "type": "object",
83 "properties": {
84 "action": {
85 "type": "string",
86 "enum": ["search", "get", "install", "update", "update_all", "check_updates", "skills_sh_search", "skills_sh_list", "skills_sh_install", "skills_sh_detail"],
87 "description": "Marketplace operation to perform"
88 },
89 "query": {
90 "type": "string",
91 "description": "Search query string (search action)"
92 },
93 "limit": {
94 "type": "integer",
95 "description": "Maximum number of results to return (search action, default 20)"
96 },
97 "slug": {
98 "type": "string",
99 "description": "Skill slug — the unique identifier on ClawHub (get, install, update actions)"
100 },
101 "skill_id": {
102 "type": "string",
103 "description": "Skills.sh skill identifier (format: owner/repo/skill-slug)"
104 },
105 "version": {
106 "type": "string",
107 "description": "Specific version to install (install action, optional; defaults to latest)"
108 },
109 "view": {
110 "type": "string",
111 "description": "Skills.sh leaderboard view: all-time, trending, or hot",
112 "default": "all-time"
113 },
114 "page": {
115 "type": "integer",
116 "description": "Page number for Skills.sh listing (0-indexed)"
117 },
118 "per_page": {
119 "type": "integer",
120 "description": "Results per page for Skills.sh listing (1-500, default 50)"
121 }
122 },
123 "required": ["action"]
124 })
125 }
126
127 async fn execute(
128 &self,
129 _tool_call_id: &str,
130 params: Value,
131 _signal: Option<oneshot::Receiver<()>>,
132 _ctx: &ToolContext,
133 ) -> Result<AgentToolResult, String> {
134 let action = params
135 .get("action")
136 .and_then(|v| v.as_str())
137 .ok_or_else(|| "Missing required parameter: action".to_string())?;
138
139 match action {
140 "search" => {
141 let query = params
142 .get("query")
143 .and_then(|v| v.as_str())
144 .ok_or_else(|| "search requires 'query' parameter".to_string())?;
145 let limit = params["limit"].as_u64().map(|l| l as usize);
146
147 match self.api.search(query, limit).await {
148 Ok(results) => {
149 let display: Vec<Value> = results
150 .into_iter()
151 .map(|r| {
152 json!({
153 "slug": r.slug,
154 "displayName": r.display_name,
155 "summary": r.summary,
156 "version": r.version,
157 "score": r.score,
158 })
159 })
160 .collect();
161 Ok(AgentToolResult::success(
162 serde_json::to_string_pretty(&json!({
163 "results": display,
164 "count": display.len(),
165 }))
166 .unwrap_or_default(),
167 ))
168 }
169 Err(e) => Ok(AgentToolResult::error(format!(
170 "Marketplace search failed: {e}"
171 ))),
172 }
173 }
174
175 "get" => {
176 let slug = params
177 .get("slug")
178 .and_then(|v| v.as_str())
179 .ok_or_else(|| "get requires 'slug' parameter".to_string())?;
180
181 match self.api.get_skill(slug).await {
182 Ok(detail) => {
183 let display = json!({
184 "slug": detail.skill.as_ref().map(|s| &s.slug),
185 "displayName": detail.skill.as_ref().map(|s| &s.display_name),
186 "summary": detail.skill.as_ref().and_then(|s| s.summary.clone()),
187 "latestVersion": detail.latest_version.as_ref().map(|v| &v.version),
188 "changelog": detail.latest_version.as_ref().and_then(|v| v.changelog.clone()),
189 "os": detail.metadata.as_ref().and_then(|m| m.os.clone()),
190 "owner": detail.owner.as_ref().map(|o| {
191 json!({
192 "handle": o.handle,
193 "displayName": o.display_name,
194 })
195 }),
196 });
197 Ok(AgentToolResult::success(
198 serde_json::to_string_pretty(&display).unwrap_or_default(),
199 ))
200 }
201 Err(e) => Ok(AgentToolResult::error(format!(
202 "Failed to get skill '{slug}': {e}"
203 ))),
204 }
205 }
206
207 "install" => {
208 let slug = params
209 .get("slug")
210 .and_then(|v| v.as_str())
211 .ok_or_else(|| "install requires 'slug' parameter".to_string())?;
212 let version = params
213 .get("version")
214 .and_then(|v| v.as_str());
215
216 match self.api.install(slug, version).await {
217 Ok(result) => Ok(AgentToolResult::success(
218 serde_json::to_string_pretty(&json!({
219 "ok": result.ok,
220 "slug": result.slug,
221 "version": result.version,
222 "targetDir": result.target_dir.display().to_string(),
223 "changelog": result.changelog,
224 }))
225 .unwrap_or_default(),
226 )),
227 Err(e) => Ok(AgentToolResult::error(format!(
228 "Failed to install '{slug}': {e}"
229 ))),
230 }
231 }
232
233 "update" => {
234 let slug = params
235 .get("slug")
236 .and_then(|v| v.as_str())
237 .ok_or_else(|| "update requires 'slug' parameter".to_string())?;
238
239 match self.api.update(slug).await {
240 Ok(result) => Ok(AgentToolResult::success(
241 serde_json::to_string_pretty(&json!({
242 "ok": result.ok,
243 "slug": result.slug,
244 "previousVersion": result.previous_version,
245 "version": result.version,
246 "changed": result.changed,
247 }))
248 .unwrap_or_default(),
249 )),
250 Err(e) => Ok(AgentToolResult::error(format!(
251 "Failed to update '{slug}': {e}"
252 ))),
253 }
254 }
255
256 "update_all" => {
257 match self.api.update_all().await {
258 Ok(results) => {
259 let display: Vec<Value> = results
260 .into_iter()
261 .map(|r| {
262 json!({
263 "ok": r.ok,
264 "slug": r.slug,
265 "previousVersion": r.previous_version,
266 "version": r.version,
267 "changed": r.changed,
268 "error": r.error,
269 })
270 })
271 .collect();
272 Ok(AgentToolResult::success(
273 serde_json::to_string_pretty(&json!({
274 "results": display,
275 "count": display.len(),
276 }))
277 .unwrap_or_default(),
278 ))
279 }
280 Err(e) => Ok(AgentToolResult::error(format!(
281 "Failed to update all skills: {e}"
282 ))),
283 }
284 }
285
286 "check_updates" => {
287 match self.api.check_updates().await {
288 Ok(updates) => {
289 if updates.is_empty() {
290 return Ok(AgentToolResult::success("All skills are up to date."));
291 }
292 let display: Vec<Value> = updates
293 .into_iter()
294 .map(|u| {
295 json!({
296 "slug": u.slug,
297 "currentVersion": u.current_version,
298 "latestVersion": u.latest_version,
299 "changelog": u.changelog,
300 })
301 })
302 .collect();
303 Ok(AgentToolResult::success(
304 serde_json::to_string_pretty(&json!({
305 "updates": display,
306 "count": display.len(),
307 }))
308 .unwrap_or_default(),
309 ))
310 }
311 Err(e) => Ok(AgentToolResult::error(format!(
312 "Failed to check updates: {e}"
313 ))),
314 }
315 }
316
317 "skills_sh_search" => {
320 let query = params
321 .get("query")
322 .and_then(|v| v.as_str())
323 .ok_or_else(|| "skills_sh_search requires 'query' parameter".to_string())?;
324 let limit = params["limit"].as_u64().map(|l| l as usize);
325
326 match self.api.search_skills_sh(query, limit).await {
327 Ok(resp) => {
328 let display: Vec<Value> = resp
329 .data
330 .into_iter()
331 .map(|s| {
332 json!({
333 "id": s.id,
334 "name": s.name,
335 "slug": s.slug,
336 "source": s.source,
337 "installs": s.installs,
338 "sourceType": s.source_type,
339 "installUrl": s.install_url,
340 })
341 })
342 .collect();
343 Ok(AgentToolResult::success(
344 serde_json::to_string_pretty(&json!({
345 "results": display,
346 "count": display.len(),
347 "searchType": resp.search_type,
348 }))
349 .unwrap_or_default(),
350 ))
351 }
352 Err(e) => Ok(AgentToolResult::error(format!(
353 "Skills.sh search failed: {e}"
354 ))),
355 }
356 }
357
358 "skills_sh_list" => {
359 let view = params.get("view").and_then(|v| v.as_str());
360 let page = params["page"].as_i64();
361 let per_page = params["per_page"].as_i64();
362
363 match self.api.list_skills_sh(view, page, per_page).await {
364 Ok(resp) => {
365 let display: Vec<Value> = resp
366 .data
367 .into_iter()
368 .map(|s| {
369 json!({
370 "id": s.id,
371 "name": s.name,
372 "slug": s.slug,
373 "source": s.source,
374 "installs": s.installs,
375 })
376 })
377 .collect();
378 Ok(AgentToolResult::success(
379 serde_json::to_string_pretty(&json!({
380 "results": display,
381 "pagination": {
382 "page": resp.pagination.page,
383 "total": resp.pagination.total,
384 "hasMore": resp.pagination.has_more,
385 },
386 }))
387 .unwrap_or_default(),
388 ))
389 }
390 Err(e) => Ok(AgentToolResult::error(format!(
391 "Skills.sh list failed: {e}"
392 ))),
393 }
394 }
395
396 "skills_sh_install" => {
397 let skill_id = params
398 .get("skill_id")
399 .and_then(|v| v.as_str())
400 .ok_or_else(|| "skills_sh_install requires 'skill_id' parameter".to_string())?;
401
402 match self.api.install_skills_sh(skill_id).await {
403 Ok(result) => Ok(AgentToolResult::success(
404 serde_json::to_string_pretty(&json!({
405 "ok": result.ok,
406 "slug": result.slug,
407 "source": result.source,
408 "skillId": result.skill_id,
409 "targetDir": result.target_dir.display().to_string(),
410 "fileCount": result.file_count,
411 }))
412 .unwrap_or_default(),
413 )),
414 Err(e) => Ok(AgentToolResult::error(format!(
415 "Failed to install from Skills.sh: {e}"
416 ))),
417 }
418 }
419
420 "skills_sh_detail" => {
421 let skill_id = params
422 .get("skill_id")
423 .and_then(|v| v.as_str())
424 .ok_or_else(|| "skills_sh_detail requires 'skill_id' parameter".to_string())?;
425
426 match self.api.get_skills_sh_skill(skill_id).await {
427 Ok(detail) => {
428 let files: Vec<Value> = detail
429 .files
430 .map(|f| f.into_iter().map(|file| json!({ "path": file.path, "size": file.contents.len() })).collect())
431 .unwrap_or_default();
432 Ok(AgentToolResult::success(
433 serde_json::to_string_pretty(&json!({
434 "id": detail.id,
435 "source": detail.source,
436 "slug": detail.slug,
437 "installs": detail.installs,
438 "hash": detail.hash,
439 "fileCount": files.len(),
440 "files": files,
441 }))
442 .unwrap_or_default(),
443 ))
444 }
445 Err(e) => Ok(AgentToolResult::error(format!(
446 "Failed to get Skills.sh detail: {e}"
447 ))),
448 }
449 }
450
451 other => Err(format!(
452 "Unknown marketplace action '{other}'. Valid: search, get, install, update, update_all, check_updates, skills_sh_search, skills_sh_list, skills_sh_install, skills_sh_detail"
453 )),
454 }
455 }
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 #[test]
463 fn test_schema_structure() {
464 let schema = json!({
465 "type": "object",
466 "properties": {
467 "action": {
468 "type": "string",
469 "enum": ["search", "get", "install", "update", "update_all", "check_updates",
470 "skills_sh_search", "skills_sh_list", "skills_sh_install", "skills_sh_detail"]
471 },
472 "query": { "type": "string" },
473 "limit": { "type": "integer" },
474 "slug": { "type": "string" },
475 "skill_id": { "type": "string" },
476 "version": { "type": "string" }
477 },
478 "required": ["action"]
479 });
480
481 let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
482 assert_eq!(actions.len(), 10);
483 }
484}