1use std::io::{Cursor, Read};
27use std::path::PathBuf;
28use std::sync::Arc;
29use std::time::Duration;
30
31use async_trait::async_trait;
32use serde::Deserialize;
33use serde_json::{json, Value};
34use tokio::sync::mpsc;
35
36use crate::error::{Error, Result};
37use crate::llm::ToolSpec;
38use crate::tools::Tool;
39
40#[derive(Clone, Debug, PartialEq)]
44pub struct SkillSearchResult {
45 pub slug: String,
46 pub name: String,
47 pub description: String,
48 pub downloads: u64,
49 pub stars: u32,
50 pub version: String,
51}
52
53#[derive(Clone, Debug, PartialEq)]
55pub struct SkillZipFile {
56 pub path: String,
58 pub content: String,
60 pub size: usize,
62}
63
64pub struct SkillSearchRequest {
69 pub query: String,
70 pub results: Vec<SkillSearchResult>,
71 pub reply: tokio::sync::oneshot::Sender<Option<String>>,
72}
73
74pub struct SkillFilesRequest {
78 pub slug: String,
79 pub files: Vec<SkillZipFile>,
80 pub reply: tokio::sync::oneshot::Sender<bool>,
81}
82
83pub enum SkillInstallEvent {
88 Search(SkillSearchRequest),
89 Files(SkillFilesRequest),
90}
91
92const SKILLHUB_BASE: &str = "https://api.skillhub.cn/api/v1";
94const SKILLHUB_CDN: &str = "https://skillhub-1388575217.cos.ap-guangzhou.myqcloud.com/skills";
96const SEARCH_LIMIT: usize = 8;
98const HTTP_TIMEOUT_SECS: u64 = 30;
100
101#[derive(Deserialize)]
104struct SearchResponse {
105 results: Vec<SearchItem>,
106}
107
108#[derive(Deserialize)]
109struct SearchItem {
110 slug: String,
111 name: String,
112 #[serde(default)]
113 description: String,
114 #[serde(default)]
115 downloads: u64,
116 #[serde(default)]
117 stars: u32,
118 #[serde(default)]
119 version: String,
120}
121
122impl From<SearchItem> for SkillSearchResult {
123 fn from(item: SearchItem) -> Self {
124 SkillSearchResult {
125 slug: item.slug,
126 name: item.name,
127 description: item.description,
128 downloads: item.downloads,
129 stars: item.stars,
130 version: item.version,
131 }
132 }
133}
134
135pub struct InstallSkill {
138 skill_tx: Option<Arc<mpsc::UnboundedSender<SkillInstallEvent>>>,
140}
141
142impl InstallSkill {
143 pub fn new(skill_tx: Option<mpsc::UnboundedSender<SkillInstallEvent>>) -> Self {
144 Self {
145 skill_tx: skill_tx.map(Arc::new),
146 }
147 }
148
149 fn http_client() -> reqwest::Result<reqwest::Client> {
151 reqwest::Client::builder()
152 .timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
153 .connect_timeout(Duration::from_secs(10))
154 .user_agent("recursive-agent/install_skill")
155 .build()
156 }
157
158 async fn search(client: &reqwest::Client, query: &str) -> Result<Vec<SkillSearchResult>> {
160 let url = format!("{SKILLHUB_BASE}/search");
161 let url = format!("{url}?q={}&limit={SEARCH_LIMIT}", query.replace(' ', "+"));
162 let resp = client.get(&url).send().await.map_err(|e| Error::Tool {
163 name: "install_skill".into(),
164 call_id: None,
165 message: format!("skillhub.cn search request failed: {e}"),
166 })?;
167
168 if !resp.status().is_success() {
169 let status = resp.status();
170 return Err(Error::Tool {
171 name: "install_skill".into(),
172 call_id: None,
173 message: format!("skillhub.cn search returned HTTP {status}"),
174 });
175 }
176
177 let body: SearchResponse = resp.json().await.map_err(|e| Error::Tool {
178 name: "install_skill".into(),
179 call_id: None,
180 message: format!("failed to parse skillhub.cn search response: {e}"),
181 })?;
182
183 Ok(body.results.into_iter().map(Into::into).collect())
184 }
185
186 async fn download_zip(client: &reqwest::Client, slug: &str) -> Result<Vec<u8>> {
188 let primary_url = format!("{SKILLHUB_BASE}/download?slug={slug}");
189 let fallback_url = format!("{SKILLHUB_CDN}/{slug}.zip");
190
191 match Self::fetch_bytes(client, &primary_url).await {
192 Ok(b) => Ok(b),
193 Err(_) => Self::fetch_bytes(client, &fallback_url)
194 .await
195 .map_err(|e| Error::Tool {
196 name: "install_skill".into(),
197 call_id: None,
198 message: format!("failed to download skill '{slug}': {e}"),
199 }),
200 }
201 }
202
203 async fn fetch_bytes(client: &reqwest::Client, url: &str) -> Result<Vec<u8>> {
204 let resp = client.get(url).send().await.map_err(|e| Error::Tool {
205 name: "install_skill".into(),
206 call_id: None,
207 message: e.to_string(),
208 })?;
209 if !resp.status().is_success() {
210 let status = resp.status();
211 return Err(Error::Tool {
212 name: "install_skill".into(),
213 call_id: None,
214 message: format!("HTTP {status} for {url}"),
215 });
216 }
217 resp.bytes()
218 .await
219 .map(|b| b.to_vec())
220 .map_err(|e| Error::Tool {
221 name: "install_skill".into(),
222 call_id: None,
223 message: e.to_string(),
224 })
225 }
226
227 fn parse_zip(data: &[u8]) -> Result<Vec<SkillZipFile>> {
229 let cursor = Cursor::new(data);
230 let mut archive = zip::ZipArchive::new(cursor).map_err(|e| Error::Tool {
231 name: "install_skill".into(),
232 call_id: None,
233 message: format!("failed to open zip archive: {e}"),
234 })?;
235
236 let mut files = Vec::new();
237 for i in 0..archive.len() {
238 let mut entry = archive.by_index(i).map_err(|e| Error::Tool {
239 name: "install_skill".into(),
240 call_id: None,
241 message: format!("failed to read zip entry {i}: {e}"),
242 })?;
243
244 if entry.is_dir() {
245 continue;
246 }
247
248 let path = entry.name().to_string();
249 let size = entry.size() as usize;
250
251 let mut buf = Vec::new();
252 entry.read_to_end(&mut buf).map_err(|e| Error::Tool {
253 name: "install_skill".into(),
254 call_id: None,
255 message: format!("failed to read zip entry '{path}': {e}"),
256 })?;
257
258 let content = String::from_utf8(buf).unwrap_or_else(|_| "<binary>".to_string());
259
260 files.push(SkillZipFile {
261 path,
262 content,
263 size,
264 });
265 }
266
267 files.sort_by(|a, b| a.path.cmp(&b.path));
269 Ok(files)
270 }
271
272 fn extract_zip(data: &[u8], dest_dir: &std::path::Path) -> Result<()> {
274 let cursor = Cursor::new(data);
275 let mut archive = zip::ZipArchive::new(cursor).map_err(|e| Error::Tool {
276 name: "install_skill".into(),
277 call_id: None,
278 message: format!("failed to open zip for extraction: {e}"),
279 })?;
280
281 for i in 0..archive.len() {
282 let mut entry = archive.by_index(i).map_err(|e| Error::Tool {
283 name: "install_skill".into(),
284 call_id: None,
285 message: format!("zip entry {i} read error: {e}"),
286 })?;
287
288 let raw_name = entry.name().to_string();
290 let relative = raw_name
291 .split_once('/')
292 .map(|(_, rest)| rest)
293 .unwrap_or(&raw_name);
294
295 let out_path = dest_dir.join(relative);
296
297 if entry.is_dir() {
298 std::fs::create_dir_all(&out_path).map_err(|e| Error::Tool {
299 name: "install_skill".into(),
300 call_id: None,
301 message: format!("failed to create dir '{}: {e}", out_path.display()),
302 })?;
303 continue;
304 }
305
306 if let Some(parent) = out_path.parent() {
307 std::fs::create_dir_all(parent).map_err(|e| Error::Tool {
308 name: "install_skill".into(),
309 call_id: None,
310 message: format!("mkdir '{}': {e}", parent.display()),
311 })?;
312 }
313
314 let mut buf = Vec::new();
315 entry.read_to_end(&mut buf).map_err(|e| Error::Tool {
316 name: "install_skill".into(),
317 call_id: None,
318 message: format!("read zip entry '{raw_name}': {e}"),
319 })?;
320
321 std::fs::write(&out_path, &buf).map_err(|e| Error::Tool {
322 name: "install_skill".into(),
323 call_id: None,
324 message: format!("write '{}': {e}", out_path.display()),
325 })?;
326 }
327 Ok(())
328 }
329
330 fn install_dir(slug: &str) -> Result<PathBuf> {
332 let home = dirs::home_dir().ok_or_else(|| Error::Tool {
333 name: "install_skill".into(),
334 call_id: None,
335 message: "cannot determine home directory".to_string(),
336 })?;
337 Ok(home.join(".recursive").join("skills").join(slug))
338 }
339}
340
341#[async_trait]
342impl Tool for InstallSkill {
343 fn spec(&self) -> ToolSpec {
344 ToolSpec {
345 name: "install_skill".into(),
346 description: "Search skillhub.cn for skills matching a query, let the user review the skill files in the TUI, and install the chosen skill to ~/.recursive/skills/. Only available in TUI mode — requires user confirmation before installing anything.".into(),
347 parameters: json!({
348 "type": "object",
349 "properties": {
350 "query": {
351 "type": "string",
352 "description": "Keywords to search for on skillhub.cn"
353 }
354 },
355 "required": ["query"]
356 }),
357 }
358 }
359
360 fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
361 crate::tools::ToolSideEffect::External
362 }
363
364 async fn execute(&self, arguments: Value) -> Result<String> {
365 let tx = match &self.skill_tx {
367 Some(tx) => Arc::clone(tx),
368 None => {
369 return Ok("Error: install_skill is only available in TUI mode.\n\
370 The skill catalog is listed in the system prompt \
371 (search for \"Available skills\"); call `load_skill` to \
372 activate one.\n\
373 To install manually: skillhub install <slug>"
374 .to_string());
375 }
376 };
377
378 let query = arguments["query"]
379 .as_str()
380 .ok_or_else(|| Error::BadToolArgs {
381 name: "install_skill".into(),
382 message: "missing required parameter: query".to_string(),
383 })?
384 .trim()
385 .to_string();
386
387 if query.is_empty() {
388 return Err(Error::BadToolArgs {
389 name: "install_skill".into(),
390 message: "query must not be empty".to_string(),
391 });
392 }
393
394 let client = Self::http_client().map_err(|e| Error::Tool {
396 name: "install_skill".into(),
397 call_id: None,
398 message: format!("failed to build HTTP client: {e}"),
399 })?;
400
401 let results = Self::search(&client, &query).await?;
402
403 if results.is_empty() {
404 return Ok(format!(
405 "No skills found on skillhub.cn for query \"{query}\".\n\
406 Try a different keyword, or browse https://skillhub.cn manually."
407 ));
408 }
409
410 let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<Option<String>>();
411 tx.send(SkillInstallEvent::Search(SkillSearchRequest {
412 query: query.clone(),
413 results,
414 reply: reply_tx,
415 }))
416 .map_err(|_| Error::Tool {
417 name: "install_skill".into(),
418 call_id: None,
419 message: "TUI skill-install channel closed unexpectedly".to_string(),
420 })?;
421
422 let selected_slug = reply_rx.await.map_err(|_| Error::Tool {
423 name: "install_skill".into(),
424 call_id: None,
425 message: "TUI did not reply to skill selection (channel dropped)".to_string(),
426 })?;
427
428 let slug = match selected_slug {
429 Some(s) => s,
430 None => return Ok("Installation cancelled.".to_string()),
431 };
432
433 let zip_bytes = Self::download_zip(&client, &slug).await?;
435 let files = Self::parse_zip(&zip_bytes)?;
436
437 let (confirm_tx, confirm_rx) = tokio::sync::oneshot::channel::<bool>();
438 tx.send(SkillInstallEvent::Files(SkillFilesRequest {
439 slug: slug.clone(),
440 files,
441 reply: confirm_tx,
442 }))
443 .map_err(|_| Error::Tool {
444 name: "install_skill".into(),
445 call_id: None,
446 message: "TUI skill-install channel closed unexpectedly (phase 2)".to_string(),
447 })?;
448
449 let confirmed = confirm_rx.await.map_err(|_| Error::Tool {
450 name: "install_skill".into(),
451 call_id: None,
452 message: "TUI did not reply to install confirmation (channel dropped)".to_string(),
453 })?;
454
455 if !confirmed {
456 return Ok("Installation cancelled.".to_string());
457 }
458
459 let dest = Self::install_dir(&slug)?;
461 std::fs::create_dir_all(&dest).map_err(|e| Error::Tool {
462 name: "install_skill".into(),
463 call_id: None,
464 message: format!(
465 "failed to create install directory '{}': {e}",
466 dest.display()
467 ),
468 })?;
469
470 Self::extract_zip(&zip_bytes, &dest)?;
471
472 Ok(format!("Skill '{slug}' installed to {}", dest.display()))
473 }
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479
480 #[tokio::test]
481 async fn headless_mode_returns_error_message() {
482 let tool = InstallSkill::new(None);
483 let result = tool.execute(json!({"query": "pdf"})).await.unwrap();
484 assert!(
485 result.contains("only available in TUI mode"),
486 "expected headless error, got: {result}"
487 );
488 }
489
490 #[tokio::test]
491 async fn empty_query_errors() {
492 let (tx, _rx) = mpsc::unbounded_channel::<SkillInstallEvent>();
493 let tool = InstallSkill::new(Some(tx));
494 let err = tool.execute(json!({"query": ""})).await.unwrap_err();
495 assert!(
496 err.to_string().contains("empty"),
497 "expected empty error: {err}"
498 );
499 }
500
501 #[test]
502 fn parse_zip_rejects_bad_data() {
503 let result = InstallSkill::parse_zip(b"not a zip");
504 assert!(result.is_err(), "expected parse error for bad data");
505 }
506
507 #[test]
508 fn install_dir_uses_home_recursive_skills() {
509 let dir = InstallSkill::install_dir("my-skill").unwrap();
510 assert!(
511 dir.ends_with(".recursive/skills/my-skill"),
512 "unexpected install dir: {}",
513 dir.display()
514 );
515 }
516
517 fn make_zip(entries: &[(&str, &str)]) -> Vec<u8> {
519 use std::io::Write;
520 let buf = Vec::new();
521 let cursor = Cursor::new(buf);
522 let mut zip = zip::ZipWriter::new(cursor);
523 let opts = zip::write::FileOptions::<()>::default()
524 .compression_method(zip::CompressionMethod::Stored);
525 for (name, content) in entries {
526 zip.start_file(*name, opts).unwrap();
527 zip.write_all(content.as_bytes()).unwrap();
528 }
529 zip.finish().unwrap().into_inner()
530 }
531
532 #[test]
533 fn parse_zip_returns_file_entries() {
534 let data = make_zip(&[("SKILL.md", "# My Skill"), ("README.md", "readme")]);
536 let files = InstallSkill::parse_zip(&data).unwrap();
537 assert_eq!(files.len(), 2, "must parse both files");
538 let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
539 assert!(paths.contains(&"SKILL.md"));
540 assert!(paths.contains(&"README.md"));
541 }
542
543 #[test]
544 fn parse_zip_sorts_by_path() {
545 let data = make_zip(&[("Z_tool.md", "z"), ("A_skill.md", "a")]);
547 let files = InstallSkill::parse_zip(&data).unwrap();
548 assert_eq!(
549 files[0].path, "A_skill.md",
550 "files must be sorted ascending by path"
551 );
552 assert_eq!(files[1].path, "Z_tool.md");
553 }
554
555 #[test]
556 fn parse_zip_captures_content_and_size() {
557 let data = make_zip(&[("SKILL.md", "hello world")]);
558 let files = InstallSkill::parse_zip(&data).unwrap();
559 assert_eq!(files[0].content, "hello world");
560 assert_eq!(files[0].size, 11);
561 }
562
563 #[test]
564 fn extract_zip_strips_leading_slug_component() {
565 use std::io::Write;
567 let buf = Vec::new();
568 let cursor = Cursor::new(buf);
569 let mut zip = zip::ZipWriter::new(cursor);
570 let opts = zip::write::FileOptions::<()>::default()
571 .compression_method(zip::CompressionMethod::Stored);
572 zip.start_file("my-skill/SKILL.md", opts).unwrap();
574 zip.write_all(b"# My Skill content").unwrap();
575 let zip_data = zip.finish().unwrap().into_inner();
576
577 let dest = tempfile::TempDir::new().unwrap();
578 InstallSkill::extract_zip(&zip_data, dest.path()).unwrap();
579
580 let output = std::fs::read_to_string(dest.path().join("SKILL.md")).unwrap();
582 assert_eq!(output, "# My Skill content");
583 }
584
585 #[test]
586 fn parse_zip_replaces_binary_content_with_placeholder() {
587 use std::io::Write;
589 let buf = Vec::new();
590 let cursor = Cursor::new(buf);
591 let mut zip = zip::ZipWriter::new(cursor);
592 let opts = zip::write::FileOptions::<()>::default()
593 .compression_method(zip::CompressionMethod::Stored);
594 zip.start_file("binary.bin", opts).unwrap();
596 zip.write_all(&[0xFF, 0xFE, 0x00, 0x01]).unwrap();
597 let data = zip.finish().unwrap().into_inner();
598
599 let files = InstallSkill::parse_zip(&data).unwrap();
600 assert_eq!(files.len(), 1);
601 assert_eq!(
602 files[0].content, "<binary>",
603 "non-UTF8 file content must become '<binary>'"
604 );
605 }
606
607 #[test]
608 fn extract_zip_file_without_slug_prefix_uses_full_name() {
609 use std::io::Write;
611 let buf = Vec::new();
612 let cursor = Cursor::new(buf);
613 let mut zip = zip::ZipWriter::new(cursor);
614 let opts = zip::write::FileOptions::<()>::default()
615 .compression_method(zip::CompressionMethod::Stored);
616 zip.start_file("SKILL.md", opts).unwrap();
618 zip.write_all(b"no slug here").unwrap();
619 let data = zip.finish().unwrap().into_inner();
620
621 let dest = tempfile::TempDir::new().unwrap();
622 InstallSkill::extract_zip(&data, dest.path()).unwrap();
623
624 let output = std::fs::read_to_string(dest.path().join("SKILL.md")).unwrap();
625 assert_eq!(output, "no slug here");
626 }
627}