Skip to main content

construct/gateway/
api_clawhub.rs

1//! ClawHub marketplace integration — proxy endpoints for the Construct gateway.
2//!
3//! Routes:
4//!   GET  /api/clawhub/search?q=...&limit=...     — search skills on ClawHub
5//!   GET  /api/clawhub/trending?limit=...          — trending skills
6//!   GET  /api/clawhub/skills/:slug                — skill detail
7//!   POST /api/clawhub/install/:slug               — install skill into local Kumiho
8
9use axum::{
10    Json,
11    extract::{Path, Query, State},
12    http::{HeaderMap, StatusCode},
13    response::{IntoResponse, Response},
14};
15use serde::Deserialize;
16use std::collections::HashMap;
17
18use super::AppState;
19use super::api::require_auth;
20use super::api_agents::build_kumiho_client;
21
22// ── Query types ─────────────────────────────────────────────────────────────
23
24#[derive(Deserialize)]
25pub struct SearchQuery {
26    pub q: String,
27    #[serde(default = "default_limit")]
28    pub limit: u32,
29}
30
31#[derive(Deserialize)]
32pub struct TrendingQuery {
33    #[serde(default = "default_limit")]
34    pub limit: u32,
35}
36
37fn default_limit() -> u32 {
38    20
39}
40
41// ── Helpers ─────────────────────────────────────────────────────────────────
42
43/// Extract ClawHub config without holding the MutexGuard.
44fn clawhub_config(state: &AppState) -> (bool, String, Option<String>) {
45    let config = state.config.lock();
46    (
47        config.clawhub.enabled,
48        config.clawhub.api_url.trim_end_matches('/').to_string(),
49        config.clawhub.api_token.clone(),
50    )
51}
52
53fn make_client(token: &Option<String>) -> reqwest::Client {
54    let mut builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(15));
55    if let Some(t) = token {
56        if !t.is_empty() {
57            let mut headers = reqwest::header::HeaderMap::new();
58            if let Ok(v) = reqwest::header::HeaderValue::from_str(&format!("Bearer {t}")) {
59                headers.insert(reqwest::header::AUTHORIZATION, v);
60            }
61            builder = builder.default_headers(headers);
62        }
63    }
64    builder.build().unwrap_or_default()
65}
66
67fn err_json(status: StatusCode, msg: impl std::fmt::Display) -> Response {
68    (status, Json(serde_json::json!({"error": msg.to_string()}))).into_response()
69}
70
71async fn proxy_get(client: &reqwest::Client, url: &str) -> Result<serde_json::Value, Response> {
72    let resp = client
73        .get(url)
74        .send()
75        .await
76        .map_err(|e| err_json(StatusCode::BAD_GATEWAY, format!("ClawHub unreachable: {e}")))?;
77    if !resp.status().is_success() {
78        let status = resp.status().as_u16();
79        let body = resp.text().await.unwrap_or_default();
80        return Err(err_json(
81            StatusCode::from_u16(status).unwrap_or(StatusCode::BAD_GATEWAY),
82            body,
83        ));
84    }
85    resp.json::<serde_json::Value>()
86        .await
87        .map_err(|e| err_json(StatusCode::BAD_GATEWAY, format!("Parse error: {e}")))
88}
89
90// ── Handlers ────────────────────────────────────────────────────────────────
91
92/// GET /api/clawhub/search?q=...&limit=...
93pub async fn handle_clawhub_search(
94    State(state): State<AppState>,
95    headers: HeaderMap,
96    Query(query): Query<SearchQuery>,
97) -> Response {
98    if let Err(e) = require_auth(&state, &headers) {
99        return e.into_response();
100    }
101    let (enabled, base, token) = clawhub_config(&state);
102    if !enabled {
103        return err_json(StatusCode::BAD_REQUEST, "ClawHub integration disabled");
104    }
105
106    let client = make_client(&token);
107    let url = format!(
108        "{base}/api/v1/search?q={}&limit={}",
109        urlencoding::encode(&query.q),
110        query.limit
111    );
112    match proxy_get(&client, &url).await {
113        Ok(body) => Json(body).into_response(),
114        Err(e) => e,
115    }
116}
117
118/// GET /api/clawhub/trending?limit=...
119pub async fn handle_clawhub_trending(
120    State(state): State<AppState>,
121    headers: HeaderMap,
122    Query(query): Query<TrendingQuery>,
123) -> Response {
124    if let Err(e) = require_auth(&state, &headers) {
125        return e.into_response();
126    }
127    let (enabled, base, token) = clawhub_config(&state);
128    if !enabled {
129        return err_json(StatusCode::BAD_REQUEST, "ClawHub integration disabled");
130    }
131
132    let client = make_client(&token);
133    let url = format!(
134        "{base}/api/v1/skills?sort=trending&limit={}&nonSuspiciousOnly=true",
135        query.limit
136    );
137    match proxy_get(&client, &url).await {
138        Ok(body) => Json(body).into_response(),
139        Err(e) => e,
140    }
141}
142
143/// GET /api/clawhub/skills/:slug
144pub async fn handle_clawhub_skill_detail(
145    State(state): State<AppState>,
146    headers: HeaderMap,
147    Path(slug): Path<String>,
148) -> Response {
149    if let Err(e) = require_auth(&state, &headers) {
150        return e.into_response();
151    }
152    let (enabled, base, token) = clawhub_config(&state);
153    if !enabled {
154        return err_json(StatusCode::BAD_REQUEST, "ClawHub integration disabled");
155    }
156
157    let client = make_client(&token);
158
159    // Fetch skill detail + SKILL.md content in parallel
160    let detail_url = format!("{base}/api/v1/skills/{slug}");
161    let content_url = format!("{base}/api/v1/skills/{slug}/file?path=SKILL.md&tag=latest");
162    let c2 = client.clone();
163    let (detail_res, content_res) = tokio::join!(
164        proxy_get(&client, &detail_url),
165        proxy_get(&c2, &content_url)
166    );
167
168    let mut detail_json = match detail_res {
169        Ok(v) => v,
170        Err(e) => return e,
171    };
172
173    // Attach SKILL.md content if available
174    if let Ok(v) = content_res {
175        if let Some(text) = v.as_str() {
176            detail_json["skill_md"] = serde_json::Value::String(text.to_string());
177        } else {
178            detail_json["skill_md"] = v;
179        }
180    }
181
182    Json(detail_json).into_response()
183}
184
185/// POST /api/clawhub/install/:slug
186///
187/// Fetches the SKILL.md from ClawHub and creates a local skill in Kumiho.
188pub async fn handle_clawhub_install(
189    State(state): State<AppState>,
190    headers: HeaderMap,
191    Path(slug): Path<String>,
192) -> Response {
193    if let Err(e) = require_auth(&state, &headers) {
194        return e.into_response();
195    }
196    let (enabled, base, token) = clawhub_config(&state);
197    if !enabled {
198        return err_json(StatusCode::BAD_REQUEST, "ClawHub integration disabled");
199    }
200
201    let client = make_client(&token);
202
203    // 1. Fetch skill metadata
204    let detail = match proxy_get(&client, &format!("{base}/api/v1/skills/{slug}")).await {
205        Ok(v) => v,
206        Err(e) => return e,
207    };
208
209    // 2. Fetch SKILL.md content
210    let skill_md = match client
211        .get(format!(
212            "{base}/api/v1/skills/{slug}/file?path=SKILL.md&tag=latest"
213        ))
214        .send()
215        .await
216    {
217        Ok(r) if r.status().is_success() => r.text().await.unwrap_or_default(),
218        _ => {
219            return err_json(
220                StatusCode::BAD_GATEWAY,
221                "Could not fetch SKILL.md from ClawHub",
222            );
223        }
224    };
225
226    // 3. Extract metadata
227    let display_name = detail
228        .get("displayName")
229        .or_else(|| detail.get("name"))
230        .and_then(|v| v.as_str())
231        .unwrap_or(&slug)
232        .to_string();
233    let description = detail
234        .get("description")
235        .and_then(|v| v.as_str())
236        .unwrap_or("")
237        .to_string();
238    let version = detail
239        .get("version")
240        .or_else(|| detail.get("latestVersion").and_then(|v| v.get("version")))
241        .and_then(|v| v.as_str())
242        .unwrap_or("1.0.0")
243        .to_string();
244
245    // 4. Create local skill via Kumiho
246    let kumiho = build_kumiho_client(&state);
247    let memory_project = {
248        let config = state.config.lock();
249        config.kumiho.memory_project.clone()
250    };
251
252    if let Err(e) = kumiho.ensure_project(&memory_project).await {
253        return err_json(
254            StatusCode::INTERNAL_SERVER_ERROR,
255            format!("Kumiho project error: {e}"),
256        );
257    }
258    if let Err(e) = kumiho.ensure_space(&memory_project, "Skills").await {
259        return err_json(
260            StatusCode::INTERNAL_SERVER_ERROR,
261            format!("Kumiho space error: {e}"),
262        );
263    }
264
265    // Lightweight metadata only — no content (that goes into the local file)
266    let mut metadata = HashMap::new();
267    metadata.insert("description".to_string(), description.clone());
268    metadata.insert("domain".to_string(), "Other".to_string());
269    metadata.insert("tags".to_string(), format!("clawhub,{slug}"));
270    metadata.insert("clawhub_slug".to_string(), slug.clone());
271    metadata.insert("clawhub_version".to_string(), version);
272    metadata.insert("source".to_string(), "clawhub".to_string());
273
274    let skill_space_path = format!("/{memory_project}/Skills");
275    let item = match kumiho
276        .create_item(&skill_space_path, &slug, "skilldef", HashMap::new())
277        .await
278    {
279        Ok(item) => item,
280        Err(e) => {
281            return err_json(
282                StatusCode::INTERNAL_SERVER_ERROR,
283                format!("Failed to create skill: {e}"),
284            );
285        }
286    };
287
288    let rev = match kumiho.create_revision(&item.kref, metadata).await {
289        Ok(rev) => rev,
290        Err(e) => {
291            return err_json(
292                StatusCode::INTERNAL_SERVER_ERROR,
293                format!("Failed to create revision: {e}"),
294            );
295        }
296    };
297
298    // Write content to local file and create artifact BEFORE publishing
299    {
300        let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
301        let skills_dir = std::path::PathBuf::from(home).join(".construct/workspace/skills");
302        let _ = tokio::fs::create_dir_all(&skills_dir).await;
303        let file_path = skills_dir.join(format!("{slug}.md"));
304        let location = format!("file://{}", file_path.display());
305
306        if let Err(e) = tokio::fs::write(&file_path, &skill_md).await {
307            tracing::warn!("Failed to write skill file for {slug}: {e}");
308        }
309
310        if let Err(e) = kumiho
311            .create_artifact(&rev.kref, "SKILL.md", &location, HashMap::new())
312            .await
313        {
314            tracing::warn!("Failed to create SKILL.md artifact for {}: {e}", item.kref);
315        }
316    }
317
318    let _ = kumiho.tag_revision(&rev.kref, "published").await;
319
320    (
321        StatusCode::CREATED,
322        Json(serde_json::json!({
323            "installed": true,
324            "slug": slug,
325            "name": display_name,
326            "kref": item.kref,
327            "description": description,
328        })),
329    )
330        .into_response()
331}