use std::io::{Cursor, Read};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{json, Value};
use tokio::sync::mpsc;
use crate::error::{Error, Result};
use crate::llm::ToolSpec;
use crate::tools::Tool;
#[derive(Clone, Debug, PartialEq)]
pub struct SkillSearchResult {
pub slug: String,
pub name: String,
pub description: String,
pub downloads: u64,
pub stars: u32,
pub version: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct SkillZipFile {
pub path: String,
pub content: String,
pub size: usize,
}
pub struct SkillSearchRequest {
pub query: String,
pub results: Vec<SkillSearchResult>,
pub reply: tokio::sync::oneshot::Sender<Option<String>>,
}
pub struct SkillFilesRequest {
pub slug: String,
pub files: Vec<SkillZipFile>,
pub reply: tokio::sync::oneshot::Sender<bool>,
}
pub enum SkillInstallEvent {
Search(SkillSearchRequest),
Files(SkillFilesRequest),
}
const SKILLHUB_BASE: &str = "https://api.skillhub.cn/api/v1";
const SKILLHUB_CDN: &str = "https://skillhub-1388575217.cos.ap-guangzhou.myqcloud.com/skills";
const SEARCH_LIMIT: usize = 8;
const HTTP_TIMEOUT_SECS: u64 = 30;
#[derive(Deserialize)]
struct SearchResponse {
results: Vec<SearchItem>,
}
#[derive(Deserialize)]
struct SearchItem {
slug: String,
name: String,
#[serde(default)]
description: String,
#[serde(default)]
downloads: u64,
#[serde(default)]
stars: u32,
#[serde(default)]
version: String,
}
impl From<SearchItem> for SkillSearchResult {
fn from(item: SearchItem) -> Self {
SkillSearchResult {
slug: item.slug,
name: item.name,
description: item.description,
downloads: item.downloads,
stars: item.stars,
version: item.version,
}
}
}
pub struct InstallSkill {
skill_tx: Option<Arc<mpsc::UnboundedSender<SkillInstallEvent>>>,
}
impl InstallSkill {
pub fn new(skill_tx: Option<mpsc::UnboundedSender<SkillInstallEvent>>) -> Self {
Self {
skill_tx: skill_tx.map(Arc::new),
}
}
fn http_client() -> reqwest::Result<reqwest::Client> {
reqwest::Client::builder()
.timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
.connect_timeout(Duration::from_secs(10))
.user_agent("recursive-agent/install_skill")
.build()
}
async fn search(client: &reqwest::Client, query: &str) -> Result<Vec<SkillSearchResult>> {
let url = format!("{SKILLHUB_BASE}/search");
let url = format!("{url}?q={}&limit={SEARCH_LIMIT}", query.replace(' ', "+"));
let resp = client.get(&url).send().await.map_err(|e| Error::Tool {
name: "install_skill".into(),
call_id: None,
message: format!("skillhub.cn search request failed: {e}"),
})?;
if !resp.status().is_success() {
let status = resp.status();
return Err(Error::Tool {
name: "install_skill".into(),
call_id: None,
message: format!("skillhub.cn search returned HTTP {status}"),
});
}
let body: SearchResponse = resp.json().await.map_err(|e| Error::Tool {
name: "install_skill".into(),
call_id: None,
message: format!("failed to parse skillhub.cn search response: {e}"),
})?;
Ok(body.results.into_iter().map(Into::into).collect())
}
async fn download_zip(client: &reqwest::Client, slug: &str) -> Result<Vec<u8>> {
let primary_url = format!("{SKILLHUB_BASE}/download?slug={slug}");
let fallback_url = format!("{SKILLHUB_CDN}/{slug}.zip");
match Self::fetch_bytes(client, &primary_url).await {
Ok(b) => Ok(b),
Err(_) => Self::fetch_bytes(client, &fallback_url)
.await
.map_err(|e| Error::Tool {
name: "install_skill".into(),
call_id: None,
message: format!("failed to download skill '{slug}': {e}"),
}),
}
}
async fn fetch_bytes(client: &reqwest::Client, url: &str) -> Result<Vec<u8>> {
let resp = client.get(url).send().await.map_err(|e| Error::Tool {
name: "install_skill".into(),
call_id: None,
message: e.to_string(),
})?;
if !resp.status().is_success() {
let status = resp.status();
return Err(Error::Tool {
name: "install_skill".into(),
call_id: None,
message: format!("HTTP {status} for {url}"),
});
}
resp.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| Error::Tool {
name: "install_skill".into(),
call_id: None,
message: e.to_string(),
})
}
fn parse_zip(data: &[u8]) -> Result<Vec<SkillZipFile>> {
let cursor = Cursor::new(data);
let mut archive = zip::ZipArchive::new(cursor).map_err(|e| Error::Tool {
name: "install_skill".into(),
call_id: None,
message: format!("failed to open zip archive: {e}"),
})?;
let mut files = Vec::new();
for i in 0..archive.len() {
let mut entry = archive.by_index(i).map_err(|e| Error::Tool {
name: "install_skill".into(),
call_id: None,
message: format!("failed to read zip entry {i}: {e}"),
})?;
if entry.is_dir() {
continue;
}
let path = entry.name().to_string();
let size = entry.size() as usize;
let mut buf = Vec::new();
entry.read_to_end(&mut buf).map_err(|e| Error::Tool {
name: "install_skill".into(),
call_id: None,
message: format!("failed to read zip entry '{path}': {e}"),
})?;
let content = String::from_utf8(buf).unwrap_or_else(|_| "<binary>".to_string());
files.push(SkillZipFile {
path,
content,
size,
});
}
files.sort_by(|a, b| a.path.cmp(&b.path));
Ok(files)
}
fn extract_zip(data: &[u8], dest_dir: &std::path::Path) -> Result<()> {
let cursor = Cursor::new(data);
let mut archive = zip::ZipArchive::new(cursor).map_err(|e| Error::Tool {
name: "install_skill".into(),
call_id: None,
message: format!("failed to open zip for extraction: {e}"),
})?;
for i in 0..archive.len() {
let mut entry = archive.by_index(i).map_err(|e| Error::Tool {
name: "install_skill".into(),
call_id: None,
message: format!("zip entry {i} read error: {e}"),
})?;
let raw_name = entry.name().to_string();
let relative = raw_name
.split_once('/')
.map(|(_, rest)| rest)
.unwrap_or(&raw_name);
let out_path = dest_dir.join(relative);
if entry.is_dir() {
std::fs::create_dir_all(&out_path).map_err(|e| Error::Tool {
name: "install_skill".into(),
call_id: None,
message: format!("failed to create dir '{}: {e}", out_path.display()),
})?;
continue;
}
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| Error::Tool {
name: "install_skill".into(),
call_id: None,
message: format!("mkdir '{}': {e}", parent.display()),
})?;
}
let mut buf = Vec::new();
entry.read_to_end(&mut buf).map_err(|e| Error::Tool {
name: "install_skill".into(),
call_id: None,
message: format!("read zip entry '{raw_name}': {e}"),
})?;
std::fs::write(&out_path, &buf).map_err(|e| Error::Tool {
name: "install_skill".into(),
call_id: None,
message: format!("write '{}': {e}", out_path.display()),
})?;
}
Ok(())
}
fn install_dir(slug: &str) -> Result<PathBuf> {
let home = dirs::home_dir().ok_or_else(|| Error::Tool {
name: "install_skill".into(),
call_id: None,
message: "cannot determine home directory".to_string(),
})?;
Ok(home.join(".recursive").join("skills").join(slug))
}
}
#[async_trait]
impl Tool for InstallSkill {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "install_skill".into(),
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(),
parameters: json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Keywords to search for on skillhub.cn"
}
},
"required": ["query"]
}),
}
}
fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
crate::tools::ToolSideEffect::External
}
async fn execute(&self, arguments: Value) -> Result<String> {
let tx = match &self.skill_tx {
Some(tx) => Arc::clone(tx),
None => {
return Ok("Error: install_skill is only available in TUI mode.\n\
The skill catalog is listed in the system prompt \
(search for \"Available skills\"); call `load_skill` to \
activate one.\n\
To install manually: skillhub install <slug>"
.to_string());
}
};
let query = arguments["query"]
.as_str()
.ok_or_else(|| Error::BadToolArgs {
name: "install_skill".into(),
message: "missing required parameter: query".to_string(),
})?
.trim()
.to_string();
if query.is_empty() {
return Err(Error::BadToolArgs {
name: "install_skill".into(),
message: "query must not be empty".to_string(),
});
}
let client = Self::http_client().map_err(|e| Error::Tool {
name: "install_skill".into(),
call_id: None,
message: format!("failed to build HTTP client: {e}"),
})?;
let results = Self::search(&client, &query).await?;
if results.is_empty() {
return Ok(format!(
"No skills found on skillhub.cn for query \"{query}\".\n\
Try a different keyword, or browse https://skillhub.cn manually."
));
}
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<Option<String>>();
tx.send(SkillInstallEvent::Search(SkillSearchRequest {
query: query.clone(),
results,
reply: reply_tx,
}))
.map_err(|_| Error::Tool {
name: "install_skill".into(),
call_id: None,
message: "TUI skill-install channel closed unexpectedly".to_string(),
})?;
let selected_slug = reply_rx.await.map_err(|_| Error::Tool {
name: "install_skill".into(),
call_id: None,
message: "TUI did not reply to skill selection (channel dropped)".to_string(),
})?;
let slug = match selected_slug {
Some(s) => s,
None => return Ok("Installation cancelled.".to_string()),
};
let zip_bytes = Self::download_zip(&client, &slug).await?;
let files = Self::parse_zip(&zip_bytes)?;
let (confirm_tx, confirm_rx) = tokio::sync::oneshot::channel::<bool>();
tx.send(SkillInstallEvent::Files(SkillFilesRequest {
slug: slug.clone(),
files,
reply: confirm_tx,
}))
.map_err(|_| Error::Tool {
name: "install_skill".into(),
call_id: None,
message: "TUI skill-install channel closed unexpectedly (phase 2)".to_string(),
})?;
let confirmed = confirm_rx.await.map_err(|_| Error::Tool {
name: "install_skill".into(),
call_id: None,
message: "TUI did not reply to install confirmation (channel dropped)".to_string(),
})?;
if !confirmed {
return Ok("Installation cancelled.".to_string());
}
let dest = Self::install_dir(&slug)?;
std::fs::create_dir_all(&dest).map_err(|e| Error::Tool {
name: "install_skill".into(),
call_id: None,
message: format!(
"failed to create install directory '{}': {e}",
dest.display()
),
})?;
Self::extract_zip(&zip_bytes, &dest)?;
Ok(format!("Skill '{slug}' installed to {}", dest.display()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn headless_mode_returns_error_message() {
let tool = InstallSkill::new(None);
let result = tool.execute(json!({"query": "pdf"})).await.unwrap();
assert!(
result.contains("only available in TUI mode"),
"expected headless error, got: {result}"
);
}
#[tokio::test]
async fn empty_query_errors() {
let (tx, _rx) = mpsc::unbounded_channel::<SkillInstallEvent>();
let tool = InstallSkill::new(Some(tx));
let err = tool.execute(json!({"query": ""})).await.unwrap_err();
assert!(
err.to_string().contains("empty"),
"expected empty error: {err}"
);
}
#[test]
fn parse_zip_rejects_bad_data() {
let result = InstallSkill::parse_zip(b"not a zip");
assert!(result.is_err(), "expected parse error for bad data");
}
#[test]
fn install_dir_uses_home_recursive_skills() {
let dir = InstallSkill::install_dir("my-skill").unwrap();
assert!(
dir.ends_with(".recursive/skills/my-skill"),
"unexpected install dir: {}",
dir.display()
);
}
fn make_zip(entries: &[(&str, &str)]) -> Vec<u8> {
use std::io::Write;
let buf = Vec::new();
let cursor = Cursor::new(buf);
let mut zip = zip::ZipWriter::new(cursor);
let opts = zip::write::FileOptions::<()>::default()
.compression_method(zip::CompressionMethod::Stored);
for (name, content) in entries {
zip.start_file(*name, opts).unwrap();
zip.write_all(content.as_bytes()).unwrap();
}
zip.finish().unwrap().into_inner()
}
#[test]
fn parse_zip_returns_file_entries() {
let data = make_zip(&[("SKILL.md", "# My Skill"), ("README.md", "readme")]);
let files = InstallSkill::parse_zip(&data).unwrap();
assert_eq!(files.len(), 2, "must parse both files");
let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
assert!(paths.contains(&"SKILL.md"));
assert!(paths.contains(&"README.md"));
}
#[test]
fn parse_zip_sorts_by_path() {
let data = make_zip(&[("Z_tool.md", "z"), ("A_skill.md", "a")]);
let files = InstallSkill::parse_zip(&data).unwrap();
assert_eq!(
files[0].path, "A_skill.md",
"files must be sorted ascending by path"
);
assert_eq!(files[1].path, "Z_tool.md");
}
#[test]
fn parse_zip_captures_content_and_size() {
let data = make_zip(&[("SKILL.md", "hello world")]);
let files = InstallSkill::parse_zip(&data).unwrap();
assert_eq!(files[0].content, "hello world");
assert_eq!(files[0].size, 11);
}
#[test]
fn extract_zip_strips_leading_slug_component() {
use std::io::Write;
let buf = Vec::new();
let cursor = Cursor::new(buf);
let mut zip = zip::ZipWriter::new(cursor);
let opts = zip::write::FileOptions::<()>::default()
.compression_method(zip::CompressionMethod::Stored);
zip.start_file("my-skill/SKILL.md", opts).unwrap();
zip.write_all(b"# My Skill content").unwrap();
let zip_data = zip.finish().unwrap().into_inner();
let dest = tempfile::TempDir::new().unwrap();
InstallSkill::extract_zip(&zip_data, dest.path()).unwrap();
let output = std::fs::read_to_string(dest.path().join("SKILL.md")).unwrap();
assert_eq!(output, "# My Skill content");
}
#[test]
fn parse_zip_replaces_binary_content_with_placeholder() {
use std::io::Write;
let buf = Vec::new();
let cursor = Cursor::new(buf);
let mut zip = zip::ZipWriter::new(cursor);
let opts = zip::write::FileOptions::<()>::default()
.compression_method(zip::CompressionMethod::Stored);
zip.start_file("binary.bin", opts).unwrap();
zip.write_all(&[0xFF, 0xFE, 0x00, 0x01]).unwrap();
let data = zip.finish().unwrap().into_inner();
let files = InstallSkill::parse_zip(&data).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(
files[0].content, "<binary>",
"non-UTF8 file content must become '<binary>'"
);
}
#[test]
fn extract_zip_file_without_slug_prefix_uses_full_name() {
use std::io::Write;
let buf = Vec::new();
let cursor = Cursor::new(buf);
let mut zip = zip::ZipWriter::new(cursor);
let opts = zip::write::FileOptions::<()>::default()
.compression_method(zip::CompressionMethod::Stored);
zip.start_file("SKILL.md", opts).unwrap();
zip.write_all(b"no slug here").unwrap();
let data = zip.finish().unwrap().into_inner();
let dest = tempfile::TempDir::new().unwrap();
InstallSkill::extract_zip(&data, dest.path()).unwrap();
let output = std::fs::read_to_string(dest.path().join("SKILL.md")).unwrap();
assert_eq!(output, "no slug here");
}
}