skillhub 0.2.0

CLI for SkillHub - AI Agent Skill Registry
use crate::error::{CliError, Result};
use reqwest::{Client, StatusCode};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Debug, Serialize, Deserialize)]
pub struct Reply<T> {
    pub code: i32,
    pub msg: String,
    pub data: Option<T>,
}

impl<T> Reply<T> {
    pub fn into_result(self) -> Result<T> {
        if self.code == 0 {
            self.data.ok_or_else(|| CliError::Api(self.code, "Empty response data".to_string()))
        } else {
            Err(CliError::Api(self.code, self.msg))
        }
    }
}

#[derive(Debug, Serialize)]
pub struct LoginRequest {
    pub username: String,
    pub password: String,
}

#[derive(Debug, Deserialize)]
pub struct LoginResponse {
    pub token: String,
}

#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct SkillSummary {
    #[serde(default)]
    pub id: Uuid,
    pub name: String,
    #[serde(default)]
    pub display_name: String,
    pub description: String,
    pub version: String,
    pub downloads: i64,
    #[serde(default)]
    pub avg_rating: f64,
    pub namespace: String,
}

#[derive(Debug, Deserialize)]
pub struct SearchResponse {
    pub items: Vec<SkillSummary>,
    pub total: i64,
}

pub struct ApiClient {
    client: Client,
    base_url: String,
    token: Option<String>,
}

impl ApiClient {
    pub fn new(base_url: String, token: Option<String>) -> Self {
        Self {
            client: Client::new(),
            base_url,
            token,
        }
    }

    fn require_auth(&self) -> Result<()> {
        if self.token.is_none() {
            return Err(CliError::NotAuthenticated);
        }
        Ok(())
    }

    async fn post<T: Serialize, R: for<'de> Deserialize<'de>>(&self, path: &str, body: &T) -> Result<R> {
        let mut req = self.client.post(format!("{}{}", self.base_url, path));
        if let Some(token) = &self.token {
            req = req.header("Authorization", format!("Bearer {}", token));
        }
        let resp = req.json(body).send().await?;
        self.handle_response(resp).await
    }

    async fn get<R: for<'de> Deserialize<'de>>(&self, path: &str) -> Result<R> {
        let mut req = self.client.get(format!("{}{}", self.base_url, path));
        if let Some(token) = &self.token {
            req = req.header("Authorization", format!("Bearer {}", token));
        }
        let resp = req.send().await?;
        self.handle_response(resp).await
    }

    async fn post_multipart(&self, path: &str, form: reqwest::multipart::Form) -> Result<Reply<serde_json::Value>> {
        self.require_auth()?;
        let req = self
            .client
            .post(format!("{}{}", self.base_url, path))
            .header("Authorization", format!("Bearer {}", self.token.as_ref().unwrap()))
            .multipart(form);
        let resp = req.send().await?;
        self.handle_response(resp).await
    }

    async fn download(&self, path: &str) -> Result<bytes::Bytes> {
        self.require_auth()?;
        let req = self
            .client
            .get(format!("{}{}", self.base_url, path))
            .header("Authorization", format!("Bearer {}", self.token.as_ref().unwrap()));
        let resp = req.send().await?;
        let status = resp.status();
        if status != StatusCode::OK {
            let text = resp.text().await.unwrap_or_default();
            return Err(CliError::Api(status.as_u16() as i32, text));
        }
        Ok(resp.bytes().await?)
    }

    async fn handle_response<R: for<'de> Deserialize<'de>>(&self, resp: reqwest::Response) -> Result<R> {
        let status = resp.status();
        let text = resp.text().await?;
        if status != StatusCode::OK {
            return Err(CliError::Api(status.as_u16() as i32, text));
        }
        let reply: Reply<R> = serde_json::from_str(&text)
            .map_err(|e| CliError::Parse(format!("Failed to parse response: {}", e)))?;
        reply.into_result()
    }

    pub async fn login(&mut self, username: &str, password: &str) -> Result<LoginResponse> {
        let req = LoginRequest {
            username: username.to_string(),
            password: password.to_string(),
        };
        let resp: LoginResponse = self.post("/api/auth/login", &req).await?;
        self.token = Some(resp.token.clone());
        Ok(resp)
    }

    pub async fn search(&self, query: &str, page: i32, page_size: i32, sort: Option<&str>) -> Result<SearchResponse> {
        let mut path = format!(
            "/api/skill-hub/search?q={}&page={}&page_size={}",
            urlencoding::encode(query),
            page,
            page_size
        );
        if let Some(s) = sort {
            path.push_str(&format!("&sort={}", s));
        }
        self.get(&path).await
    }

    pub async fn publish(&self, namespace: &str, zip_path: &std::path::Path) -> Result<serde_json::Value> {
        self.require_auth()?;
        let zip_bytes = std::fs::read(zip_path)?;
        let part = reqwest::multipart::Part::bytes(zip_bytes)
            .file_name(zip_path.file_name().unwrap().to_string_lossy().to_string())
            .mime_str("application/zip")?;
        let form = reqwest::multipart::Form::new().part("file", part);
        let reply = self.post_multipart(&format!("/api/skill-hub/{}/publish", namespace), form).await?;
        reply.into_result()
    }

    pub async fn download_bundle(&self, namespace: &str, slug: &str, version: &str) -> Result<bytes::Bytes> {
        self.download(&format!(
            "/api/skill-hub/{}/{}/versions/{}/download",
            namespace, slug, version
        ))
        .await
    }

    pub async fn get_skill_detail(&self, namespace: &str, slug: &str) -> Result<serde_json::Value> {
        self.get(&format!("/api/skill-hub/{}/{}", namespace, slug)).await
    }
}