1use 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#[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
41fn 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
90pub 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
118pub 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
143pub 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 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 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
185pub 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 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 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 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 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 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 {
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}