Skip to main content

oxios_kernel/tools/builtin/
marketplace_tool.rs

1//! Marketplace tool — wraps `MarketplaceApi` behind the `AgentTool` interface.
2//!
3//! Provides agents with multi-registry marketplace capabilities.
4//! Registries: ClawHub, Skills.sh (Vercel Labs ecosystem).
5//!
6//! Actions: search, get, install, update, update_all, check_updates,
7//!           skills_sh_search, skills_sh_list, skills_sh_install, skills_sh_detail.
8//!
9//! ## Example
10//!
11//! ```json
12//! { "action": "search", "query": "code review", "limit": 10 }
13//! { "action": "install", "slug": "code-review-helper", "version": "1.2.0" }
14//! { "action": "skills_sh_search", "query": "frontend design" }
15//! { "action": "skills_sh_install", "skill_id": "vercel-labs/agent-skills/frontend-design" }
16//! ```
17
18use 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
26/// Agent tool for multi-registry marketplace operations.
27///
28/// Wraps the `MarketplaceApi` domain of the `KernelHandle`. Allows agents
29/// to search, install, and update skills from ClawHub and Skills.sh.
30///
31/// ## Actions
32///
33/// | Action              | Description                              | Required params  | Optional params      |
34/// |---------------------|------------------------------------------|------------------|----------------------|
35/// | `search`            | Search ClawHub for skills                | `query`          | `limit`              |
36/// | `get`               | Get skill detail from ClawHub            | `slug`           | —                    |
37/// | `install`           | Install a skill from ClawHub             | `slug`           | `version`            |
38/// | `update`            | Update a specific installed skill         | `slug`           | —                    |
39/// | `update_all`        | Update all installed ClawHub skills       | —                | —                    |
40/// | `check_updates`     | Check for available updates              | —                | —                    |
41/// | `skills_sh_search`  | Search Skills.sh for skills              | `query`          | `limit`              |
42/// | `skills_sh_list`    | List Skills.sh leaderboard               | —                | `view`, `page`, `per_page` |
43/// | `skills_sh_install` | Install a skill from Skills.sh           | `skill_id`       | —                    |
44/// | `skills_sh_detail`  | Get skill detail from Skills.sh          | `skill_id`       | —                    |
45pub struct MarketplaceTool {
46    api: MarketplaceApi,
47}
48
49impl MarketplaceTool {
50    /// Create a new `MarketplaceTool` from a `KernelHandle`.
51    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 Actions ───────────────────────────────────────
318
319            "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}