Skip to main content

fastskill_core/core/registry/
client.rs

1//! Registry client for querying and downloading from skill registries
2
3use crate::core::metadata::SkillMetadata;
4use crate::core::registry::auth::Auth;
5use crate::core::registry::config::RegistryConfig;
6use crate::core::registry_index::{Dependency as RegistryDependency, IndexMetadata};
7use crate::core::service::ServiceError;
8use reqwest::Client;
9use serde::{Deserialize, Serialize};
10use sha2::Digest;
11use std::collections::HashMap;
12
13/// Registry client for querying and downloading skills
14pub struct RegistryClient {
15    config: RegistryConfig,
16    client: Client,
17    auth: Option<Box<dyn Auth>>,
18}
19
20/// Index entry for a skill version
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct IndexEntry {
23    pub name: String,
24    pub vers: String,
25    pub deps: Vec<RegistryDependency>,
26    pub cksum: String,
27    pub features: HashMap<String, Vec<String>>,
28    pub yanked: bool,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub links: Option<String>,
31    pub download_url: String,
32    #[serde(default)]
33    pub metadata: Option<IndexMetadata>,
34}
35
36// IndexMetadata is defined in registry_index.rs
37
38// Dependency is defined in registry_index.rs
39
40impl RegistryClient {
41    /// Create a new registry client
42    pub fn new(config: RegistryConfig) -> Result<Self, ServiceError> {
43        let client = Client::builder()
44            .user_agent("fastskill/0.6.8")
45            .build()
46            .map_err(|e| ServiceError::Custom(format!("Failed to create HTTP client: {}", e)))?;
47
48        // Setup authentication
49        let auth: Option<Box<dyn Auth>> = if let Some(ref auth_config) = config.auth {
50            match auth_config {
51                crate::core::registry::config::AuthConfig::Pat { env_var } => Some(Box::new(
52                    crate::core::registry::auth::GitHubPat::new(env_var.clone()),
53                )),
54                crate::core::registry::config::AuthConfig::Ssh { key_path } => Some(Box::new(
55                    crate::core::registry::auth::SshKey::new(key_path.clone()),
56                )),
57                crate::core::registry::config::AuthConfig::ApiKey { env_var } => Some(Box::new(
58                    crate::core::registry::auth::ApiKey::new(env_var.clone()),
59                )),
60            }
61        } else {
62            None
63        };
64
65        Ok(Self {
66            config,
67            client,
68            auth,
69        })
70    }
71
72    /// Get the index URL for a skill (flat layout: scope/skill-name)
73    fn get_index_url(&self, skill_id: &str) -> String {
74        // Flat layout: use skill_id directly (e.g., "dev-user/test-skill")
75        // index_url should be the base URL for the index (e.g., http://159.69.182.11:8080/index)
76        format!(
77            "{}/{}",
78            self.config.index_url.trim_end_matches('/'),
79            skill_id
80        )
81    }
82
83    /// Get skill information from registry
84    /// Returns all versions for the skill (reads single file with newline-delimited JSON)
85    pub async fn get_skill(&self, name: &str) -> Result<Vec<IndexEntry>, ServiceError> {
86        let url = self.get_index_url(name);
87
88        let mut request = self.client.get(&url);
89
90        // Add authentication if available
91        if let Some(ref auth) = self.auth {
92            if auth.is_configured() {
93                if let Ok(header_value) = auth.get_auth_header() {
94                    request = request.header("Authorization", header_value);
95                }
96            }
97        }
98
99        let response = request
100            .send()
101            .await
102            .map_err(|e| ServiceError::Custom(format!("Failed to fetch skill index: {}", e)))?;
103
104        if !response.status().is_success() {
105            if response.status() == 404 {
106                return Ok(Vec::new()); // Skill not found
107            }
108            return Err(ServiceError::Custom(format!(
109                "Failed to fetch skill index: HTTP {}",
110                response.status()
111            )));
112        }
113
114        let content = response
115            .text()
116            .await
117            .map_err(|e| ServiceError::Custom(format!("Failed to read index file: {}", e)))?;
118
119        // Parse line-by-line (newline-delimited JSON)
120        let mut entries = Vec::new();
121        for line in content.lines() {
122            let line = line.trim();
123            if line.is_empty() {
124                continue;
125            }
126
127            match serde_json::from_str::<IndexEntry>(line) {
128                Ok(entry) => entries.push(entry),
129                Err(e) => {
130                    // Log error but continue parsing other lines
131                    eprintln!(
132                        "Warning: Failed to parse index entry: {} (line: {})",
133                        e, line
134                    );
135                }
136            }
137        }
138
139        Ok(entries)
140    }
141
142    /// Get index entry for a specific skill version
143    async fn get_index_entry(
144        &self,
145        name: &str,
146        version: &str,
147    ) -> Result<Option<IndexEntry>, ServiceError> {
148        // Get all versions and find the matching one
149        let entries = self.get_skill(name).await?;
150        Ok(entries.into_iter().find(|e| e.vers == version))
151    }
152
153    /// Get available versions for a skill
154    pub async fn get_versions(&self, name: &str) -> Result<Vec<String>, ServiceError> {
155        let entries = self.get_skill(name).await?;
156        let mut versions: Vec<String> = entries.iter().map(|e| e.vers.clone()).collect();
157        // Sort by semantic version (newest first)
158        versions.sort_by(|a, b| {
159            // Simple reverse string comparison for now
160            // TODO: Use semver crate for proper version comparison
161            b.cmp(a)
162        });
163        Ok(versions)
164    }
165
166    /// Get latest version for a skill (excluding pre-releases by default)
167    pub async fn get_latest_version(
168        &self,
169        name: &str,
170        include_pre_release: bool,
171    ) -> Result<Option<String>, ServiceError> {
172        let versions = self.get_versions(name).await?;
173
174        if versions.is_empty() {
175            return Ok(None);
176        }
177
178        // Filter out pre-releases if not requested
179        let candidates: Vec<&String> = if include_pre_release {
180            versions.iter().collect()
181        } else {
182            versions
183                .iter()
184                .filter(|v| !v.contains('-')) // Simple pre-release detection
185                .collect()
186        };
187
188        if candidates.is_empty() {
189            // If all are pre-releases and not requested, return None
190            return Ok(None);
191        }
192
193        // Use semver crate for proper version comparison
194        use semver::Version;
195        let mut parsed_versions: Vec<(Version, &String)> = candidates
196            .iter()
197            .filter_map(|v| {
198                // Try to parse as semver, skip if invalid
199                Version::parse(v).ok().map(|ver| (ver, *v))
200            })
201            .collect();
202
203        if parsed_versions.is_empty() {
204            // Fallback to first version if none can be parsed as semver
205            return Ok(Some(candidates[0].clone()));
206        }
207
208        // Sort by version (highest first)
209        parsed_versions.sort_by(|a, b| b.0.cmp(&a.0));
210
211        Ok(Some(parsed_versions[0].1.clone()))
212    }
213
214    /// Get specific version of a skill
215    pub async fn get_version(
216        &self,
217        name: &str,
218        version: &str,
219    ) -> Result<Option<IndexEntry>, ServiceError> {
220        self.get_index_entry(name, version).await
221    }
222
223    /// Download skill package
224    pub async fn download(&self, name: &str, version: &str) -> Result<Vec<u8>, ServiceError> {
225        let entry = self.get_version(name, version).await?.ok_or_else(|| {
226            ServiceError::Custom(format!(
227                "Skill {} version {} not found in registry",
228                name, version
229            ))
230        })?;
231
232        if entry.yanked {
233            return Err(ServiceError::Custom(format!(
234                "Skill {} version {} has been yanked",
235                name, version
236            )));
237        }
238
239        let mut request = self.client.get(&entry.download_url);
240
241        // Add authentication if available
242        if let Some(ref auth) = self.auth {
243            if auth.is_configured() {
244                if let Ok(header_value) = auth.get_auth_header() {
245                    request = request.header("Authorization", header_value);
246                }
247            }
248        }
249
250        let response = request
251            .send()
252            .await
253            .map_err(|e| ServiceError::Custom(format!("Failed to download package: {}", e)))?;
254
255        if !response.status().is_success() {
256            return Err(ServiceError::Custom(format!(
257                "Failed to download package: HTTP {}",
258                response.status()
259            )));
260        }
261
262        let bytes = response
263            .bytes()
264            .await
265            .map_err(|e| ServiceError::Custom(format!("Failed to read package data: {}", e)))?;
266
267        // Verify checksum
268        let calculated = format!("sha256:{:x}", sha2::Sha256::digest(&bytes));
269        if calculated != entry.cksum {
270            return Err(ServiceError::Custom(format!(
271                "Checksum mismatch: expected {}, got {}",
272                entry.cksum, calculated
273            )));
274        }
275
276        Ok(bytes.to_vec())
277    }
278
279    /// Search skills in registry (basic implementation - scans index)
280    pub async fn search(&self, _query: &str) -> Result<Vec<SkillMetadata>, ServiceError> {
281        // For now, return empty - full search requires scanning entire index
282        // This would be implemented by fetching a search endpoint or scanning index
283        // For GitHub-based registries, we'd need to use GitHub API or clone the repo
284        Ok(Vec::new())
285    }
286}