Skip to main content

recursive/tools/
install_skill.rs

1//! `install_skill`: search skillhub.cn for a skill, let the user review it
2//! in the TUI, and install it to `~/.recursive/skills/<slug>/`.
3//!
4//! This tool is **TUI-only**.  When called without a TUI side-channel
5//! (`skill_tx.is_none()`) it returns an informative error rather than
6//! blocking forever.
7//!
8//! ## Communication flow
9//!
10//! ```text
11//! Agent calls install_skill(query)
12//!   → HTTP GET skillhub.cn/search?q=…
13//!   → sends SkillInstallEvent::Search to TUI side-channel
14//!   → awaits oneshot reply (selected slug or None)
15//! TUI shows results modal, user picks a skill or presses Esc
16//!   → oneshot reply: Some(slug) or None
17//! If slug: HTTP GET skillhub.cn/download?slug=…  (zip)
18//!   → parse zip → collect SkillZipFile list
19//!   → sends SkillInstallEvent::Files to TUI side-channel
20//!   → awaits oneshot reply (bool: confirm)
21//! TUI shows files preview; user presses y or Esc
22//!   → oneshot reply: true or false
23//! If confirmed: extract zip to ~/.recursive/skills/<slug>/
24//! ```
25
26use 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// ── Skill-hub install side-channel types ─────────────────────────────────────
41
42/// One search result returned by `skillhub.cn`.
43#[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/// A single file from inside a skill zip archive.
54#[derive(Clone, Debug, PartialEq)]
55pub struct SkillZipFile {
56    /// Relative path inside the archive, e.g. `"pdf/SKILL.md"`.
57    pub path: String,
58    /// UTF-8 text content; binary files are represented as `"<binary>"`.
59    pub content: String,
60    /// Original file size in bytes (0 for directories).
61    pub size: usize,
62}
63
64/// Phase 1: tool → TUI. User selects a slug or cancels.
65///
66/// `reply` carries `Some(slug)` if the user confirmed a choice, or `None`
67/// to cancel. Not `PartialEq` because `oneshot::Sender` is not `PartialEq`.
68pub struct SkillSearchRequest {
69    pub query: String,
70    pub results: Vec<SkillSearchResult>,
71    pub reply: tokio::sync::oneshot::Sender<Option<String>>,
72}
73
74/// Phase 2: tool → TUI. User reviews files and confirms installation.
75///
76/// `reply` carries `true` to install, `false` to cancel.
77pub struct SkillFilesRequest {
78    pub slug: String,
79    pub files: Vec<SkillZipFile>,
80    pub reply: tokio::sync::oneshot::Sender<bool>,
81}
82
83/// Events from the `install_skill` tool to the TUI side-channel.
84/// Carried on a dedicated `mpsc::UnboundedReceiver<SkillInstallEvent>` in
85/// the TUI backend, separate from `event_rx`, because the payloads contain
86/// `oneshot::Sender` values which are not `PartialEq`.
87pub enum SkillInstallEvent {
88    Search(SkillSearchRequest),
89    Files(SkillFilesRequest),
90}
91
92/// skillhub.cn base URL.
93const SKILLHUB_BASE: &str = "https://api.skillhub.cn/api/v1";
94/// CDN fallback for downloads.
95const SKILLHUB_CDN: &str = "https://skillhub-1388575217.cos.ap-guangzhou.myqcloud.com/skills";
96/// How many results to request from the search API.
97const SEARCH_LIMIT: usize = 8;
98/// Request timeout for all skillhub.cn calls.
99const HTTP_TIMEOUT_SECS: u64 = 30;
100
101// ── Serde types for the skillhub.cn search API ───────────────────────────────
102
103#[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
135// ── Tool struct ───────────────────────────────────────────────────────────────
136
137pub struct InstallSkill {
138    /// Present only when running inside the TUI.  `None` in headless mode.
139    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    /// Build a reqwest client with sensible timeouts.
150    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    /// Call skillhub.cn search API and return parsed results.
159    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    /// Download the skill zip from skillhub.cn (with CDN fallback).
187    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    /// Parse a zip archive in memory and return a flat list of (path, content) pairs.
228    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        // Sort by path for deterministic display.
268        files.sort_by(|a, b| a.path.cmp(&b.path));
269        Ok(files)
270    }
271
272    /// Extract a zip archive (already loaded in memory) to `dest_dir`.
273    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            // Strip the leading slug-directory component (e.g. "pdf/SKILL.md" → "SKILL.md")
289            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    /// Resolve the install directory: `~/.recursive/skills/<slug>/`.
331    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        // ── Headless guard ────────────────────────────────────────────────────
366        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        // ── Phase 1: search ───────────────────────────────────────────────────
395        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        // ── Phase 2: download zip and send file listing to TUI ────────────────
434        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        // ── Install ───────────────────────────────────────────────────────────
460        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    /// Build a minimal in-memory zip with the given entries.
518    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        // kills `is_dir()` guard removal and function-level replacement
535        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        // kills mutation of sort_by direction (a.path.cmp vs b.path.cmp)
546        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        // kills mutation of split_once('/') path stripping logic
566        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        // Simulate a zip from skillhub.cn where every file is under "my-skill/"
573        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        // The slug prefix "my-skill/" must be stripped; file lands as SKILL.md
581        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        // kills `unwrap_or_else(|_| "<binary>".to_string())` mutations
588        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        // Write raw non-UTF8 bytes (invalid UTF-8 sequence)
595        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        // kills mutations of `unwrap_or(&raw_name)` fallback in extract_zip
610        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        // File name has no '/' component — no slug to strip
617        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}