Skip to main content

fastskill_core/core/
sources.rs

1//! Sources system for managing skill repositories
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8use tokio::sync::RwLock;
9
10/// Main sources configuration
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct SourcesConfig {
13    #[serde(default)]
14    pub sources: Vec<SourceDefinition>,
15}
16
17/// Source definition
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct SourceDefinition {
20    pub name: String,
21    #[serde(default = "default_priority")]
22    pub priority: u32,
23    pub source: SourceConfig,
24}
25
26impl SourceDefinition {
27    /// Check if this source supports listing skills
28    /// Returns true for git-marketplace, zip-url, and local sources
29    /// Returns false for HTTP registries (http-registry)
30    pub fn supports_listing(&self) -> bool {
31        matches!(
32            &self.source,
33            SourceConfig::Git { .. } | SourceConfig::ZipUrl { .. } | SourceConfig::Local { .. }
34        )
35    }
36}
37
38/// Default priority value (0 = highest priority)
39fn default_priority() -> u32 {
40    0
41}
42
43/// Source authentication configuration
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(tag = "type")]
46pub enum SourceAuth {
47    #[serde(rename = "pat")]
48    Pat { env_var: String },
49    #[serde(rename = "ssh-key")]
50    SshKey { path: PathBuf },
51    #[serde(rename = "basic")]
52    Basic {
53        username: String,
54        password_env: String,
55    },
56}
57
58/// Source configuration
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(tag = "type")]
61pub enum SourceConfig {
62    #[serde(rename = "git")]
63    Git {
64        url: String,
65        #[serde(default)]
66        branch: Option<String>,
67        #[serde(default)]
68        tag: Option<String>,
69        #[serde(default)]
70        auth: Option<SourceAuth>,
71    },
72    #[serde(rename = "zip-url")]
73    ZipUrl {
74        base_url: String,
75        #[serde(default)]
76        auth: Option<SourceAuth>,
77    },
78    #[serde(rename = "local")]
79    Local { path: PathBuf },
80}
81
82/// Information about a skill available from a source
83#[derive(Debug, Clone)]
84pub struct SkillInfo {
85    pub id: String,
86    pub name: String,
87    pub description: String,
88    pub version: Option<String>,
89    pub source_name: String,
90}
91
92/// Marketplace.json structure
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct MarketplaceJson {
95    pub version: String,
96    pub skills: Vec<MarketplaceSkill>,
97}
98
99/// Skill entry in marketplace.json
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct MarketplaceSkill {
102    pub id: String,
103    pub name: String,
104    pub description: String,
105    pub version: String,
106    #[serde(default)]
107    pub author: Option<String>,
108    #[serde(default)]
109    pub download_url: Option<String>,
110}
111
112/// Claude Code marketplace.json format structures
113/// This format is used by Claude Code standard repositories
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct ClaudeCodeMarketplaceJson {
116    pub name: String,
117    #[serde(default)]
118    pub owner: Option<ClaudeCodeOwner>,
119    #[serde(default)]
120    pub metadata: Option<ClaudeCodeMetadata>,
121    pub plugins: Vec<ClaudeCodePlugin>,
122}
123
124/// Owner information in Claude Code format
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct ClaudeCodeOwner {
127    pub name: String,
128    #[serde(default)]
129    pub email: Option<String>,
130}
131
132/// Metadata in Claude Code format
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct ClaudeCodeMetadata {
135    #[serde(default)]
136    pub description: Option<String>,
137    #[serde(default)]
138    pub version: Option<String>,
139}
140
141/// Plugin entry in Claude Code format
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct ClaudeCodePlugin {
144    pub name: String,
145    #[serde(default)]
146    pub description: Option<String>,
147    #[serde(default)]
148    pub source: Option<String>,
149    #[serde(default)]
150    pub strict: Option<bool>,
151    pub skills: Vec<String>, // Array of skill paths (relative to repository root)
152}
153
154// Marketplace.json format: Only Claude Code format is supported
155
156/// Cached marketplace.json entry
157#[derive(Debug, Clone)]
158struct CachedMarketplace {
159    data: MarketplaceJson,
160    fetched_at: DateTime<Utc>,
161    ttl_seconds: u64,
162}
163
164impl CachedMarketplace {
165    fn is_expired(&self) -> bool {
166        let now = Utc::now();
167        let elapsed = (now - self.fetched_at).num_seconds() as u64;
168        elapsed > self.ttl_seconds
169    }
170}
171
172/// Sources manager for handling multiple sources
173pub struct SourcesManager {
174    pub(crate) config_path: PathBuf,
175    sources: HashMap<String, SourceDefinition>,
176    marketplace_cache: Arc<RwLock<HashMap<String, CachedMarketplace>>>,
177    cache_ttl_seconds: u64,
178}
179
180impl SourcesManager {
181    /// Create a new sources manager
182    pub fn new(config_path: PathBuf) -> Self {
183        Self {
184            config_path,
185            sources: HashMap::new(),
186            marketplace_cache: Arc::new(RwLock::new(HashMap::new())),
187            cache_ttl_seconds: 300, // 5 minutes default TTL
188        }
189    }
190
191    /// Create a new sources manager with custom cache TTL
192    pub fn with_cache_ttl(config_path: PathBuf, cache_ttl_seconds: u64) -> Self {
193        Self {
194            config_path,
195            sources: HashMap::new(),
196            marketplace_cache: Arc::new(RwLock::new(HashMap::new())),
197            cache_ttl_seconds,
198        }
199    }
200
201    /// Load sources from TOML file
202    pub fn load(&mut self) -> Result<(), SourcesError> {
203        if !self.config_path.exists() {
204            // Create default empty config if file doesn't exist
205            let config = SourcesConfig {
206                sources: Vec::new(),
207            };
208            config.save_to_file(&self.config_path)?;
209            self.sources = HashMap::new();
210            return Ok(());
211        }
212
213        let config = SourcesConfig::load_from_file(&self.config_path)?;
214        // Sort sources by priority (lower number = higher priority)
215        let mut sorted_sources: Vec<SourceDefinition> = config.sources;
216        sorted_sources.sort_by_key(|s| s.priority);
217
218        self.sources = sorted_sources
219            .into_iter()
220            .map(|source| (source.name.clone(), source))
221            .collect();
222
223        Ok(())
224    }
225
226    /// Save sources to TOML file
227    pub fn save(&self) -> Result<(), SourcesError> {
228        let mut sources: Vec<SourceDefinition> = self.sources.values().cloned().collect();
229        // Sort by priority before saving
230        sources.sort_by_key(|s| s.priority);
231        let config = SourcesConfig { sources };
232        config.save_to_file(&self.config_path)?;
233        Ok(())
234    }
235
236    /// Add a new source
237    pub fn add_source(&mut self, name: String, config: SourceConfig) -> Result<(), SourcesError> {
238        self.add_source_with_priority(name, config, 0)
239    }
240
241    /// Add a new source with priority
242    pub fn add_source_with_priority(
243        &mut self,
244        name: String,
245        config: SourceConfig,
246        priority: u32,
247    ) -> Result<(), SourcesError> {
248        if self.sources.contains_key(&name) {
249            return Err(SourcesError::AlreadyExists(name));
250        }
251
252        let definition = SourceDefinition {
253            name: name.clone(),
254            priority,
255            source: config,
256        };
257
258        self.sources.insert(name, definition);
259        Ok(())
260    }
261
262    /// Remove a source
263    pub fn remove_source(&mut self, name: &str) -> Result<(), SourcesError> {
264        if self.sources.remove(name).is_none() {
265            return Err(SourcesError::SourceNotFound(name.to_string()));
266        }
267        Ok(())
268    }
269
270    /// Get a source by name
271    pub fn get_source(&self, name: &str) -> Option<&SourceDefinition> {
272        self.sources.get(name)
273    }
274
275    /// List all sources (sorted by priority)
276    pub fn list_sources(&self) -> Vec<&SourceDefinition> {
277        let mut sources: Vec<&SourceDefinition> = self.sources.values().collect();
278        sources.sort_by_key(|s| s.priority);
279        sources
280    }
281
282    /// Clear the marketplace cache
283    pub async fn clear_cache(&self) {
284        let mut cache = self.marketplace_cache.write().await;
285        cache.clear();
286    }
287
288    /// Get available skills from all sources (checked in priority order)
289    pub async fn get_available_skills(&self) -> Result<Vec<SkillInfo>, SourcesError> {
290        let mut all_skills = Vec::new();
291
292        // Get sources sorted by priority
293        let mut sources: Vec<(&String, &SourceDefinition)> = self.sources.iter().collect();
294        sources.sort_by_key(|(_, def)| def.priority);
295
296        for (source_name, source_def) in sources {
297            let skills = self.get_skills_from_source(source_name, source_def).await?;
298            all_skills.extend(skills);
299        }
300
301        Ok(all_skills)
302    }
303
304    /// Get skills from a specific source
305    pub async fn get_skills_from_source(
306        &self,
307        source_name: &str,
308        source_def: &SourceDefinition,
309    ) -> Result<Vec<SkillInfo>, SourcesError> {
310        match &source_def.source {
311            SourceConfig::Git { url, branch, .. } => {
312                // Try to load marketplace.json from Git source
313                // Pass branch info for proper URL construction
314                self.load_marketplace_from_url_with_branch(url, branch.as_deref(), source_name)
315                    .await
316            }
317            SourceConfig::ZipUrl { base_url, .. } => {
318                // Load marketplace.json from ZipUrl source
319                self.load_marketplace_from_url_with_branch(base_url, None, source_name)
320                    .await
321            }
322            SourceConfig::Local { path } => {
323                // Scan local path for skills
324                self.scan_local_source(path, source_name).await
325            }
326        }
327    }
328
329    /// Convert Claude Code format to FastSkill internal format
330    /// This extracts skills from plugins by resolving skill paths
331    async fn convert_claude_to_fastskill_format(
332        &self,
333        claude_marketplace: ClaudeCodeMarketplaceJson,
334        base_url: String,
335        _source_name: &str,
336    ) -> Result<MarketplaceJson, SourcesError> {
337        let mut skills = Vec::new();
338        let owner_name = claude_marketplace.owner.as_ref().map(|o| o.name.clone());
339        let metadata_version = claude_marketplace
340            .metadata
341            .as_ref()
342            .and_then(|m| m.version.clone());
343
344        for plugin in claude_marketplace.plugins {
345            let plugin_source = plugin.source.as_deref().unwrap_or("./");
346
347            for skill_path in plugin.skills {
348                // Resolve skill path relative to plugin source
349                let resolved_path = if skill_path.starts_with("./") {
350                    // Relative to plugin source
351                    format!(
352                        "{}{}",
353                        plugin_source.trim_end_matches('/'),
354                        &skill_path[1..]
355                    )
356                } else if skill_path.starts_with('/') {
357                    // Absolute from repo root
358                    skill_path.trim_start_matches('/').to_string()
359                } else {
360                    // Relative to plugin source
361                    format!("{}/{}", plugin_source.trim_end_matches('/'), skill_path)
362                };
363
364                // Extract skill ID from path (use directory name or last component)
365                let skill_id = resolved_path
366                    .trim_end_matches('/')
367                    .split('/')
368                    .next_back()
369                    .unwrap_or(&resolved_path)
370                    .to_string();
371
372                // Use plugin description as fallback, or metadata description
373                let description = plugin
374                    .description
375                    .clone()
376                    .or_else(|| {
377                        claude_marketplace
378                            .metadata
379                            .as_ref()
380                            .and_then(|m| m.description.clone())
381                    })
382                    .unwrap_or_else(|| format!("Skill from {}", plugin.name));
383
384                // Construct download URL if base_url is provided
385                let download_url = if base_url.contains("github.com")
386                    && !base_url.contains("raw.githubusercontent.com")
387                {
388                    let repo_path = base_url
389                        .trim_start_matches("https://github.com/")
390                        .trim_start_matches("http://github.com/")
391                        .trim_end_matches(".git")
392                        .trim_end_matches('/');
393                    Some(format!(
394                        "https://github.com/{}/tree/main/{}",
395                        repo_path, resolved_path
396                    ))
397                } else if !base_url.is_empty() {
398                    let base = base_url.trim_end_matches('/');
399                    Some(format!("{}/{}", base, resolved_path))
400                } else {
401                    None
402                };
403
404                skills.push(MarketplaceSkill {
405                    id: skill_id.clone(),
406                    name: skill_id.clone(), // Use ID as name if not available
407                    description,
408                    version: metadata_version
409                        .clone()
410                        .unwrap_or_else(|| "1.0.0".to_string()),
411                    author: owner_name.clone(),
412                    download_url,
413                });
414            }
415        }
416
417        Ok(MarketplaceJson {
418            version: "1.0".to_string(),
419            skills,
420        })
421    }
422
423    /// Try to fetch marketplace.json from a URL
424    /// Only Claude Code format is supported
425    /// Tries Claude Code standard location first (.claude-plugin/marketplace.json), then root location
426    async fn try_fetch_marketplace(
427        &self,
428        url: &str,
429        base_repo_url: Option<&str>,
430    ) -> Result<MarketplaceJson, SourcesError> {
431        let client = reqwest::Client::new();
432        let response = client.get(url).send().await.map_err(|e| {
433            SourcesError::Network(format!("Failed to fetch marketplace.json: {}", e))
434        })?;
435
436        if !response.status().is_success() {
437            return Err(SourcesError::Network(format!(
438                "Failed to fetch marketplace.json: HTTP {}",
439                response.status()
440            )));
441        }
442
443        // Parse as Claude Code format (only supported format)
444        let claude_marketplace: ClaudeCodeMarketplaceJson = response.json().await.map_err(|e| {
445            SourcesError::Parse(format!(
446                "Failed to parse Claude Code marketplace.json: {}",
447                e
448            ))
449        })?;
450
451        // Extract base repository URL for path resolution
452        // If base_repo_url is provided, use it; otherwise try to extract from raw URL
453        let base_url = if let Some(repo_url) = base_repo_url {
454            repo_url.to_string()
455        } else if url.contains("raw.githubusercontent.com") {
456            // Extract repo path from raw.githubusercontent.com URL
457            // e.g., https://raw.githubusercontent.com/owner/repo/branch/.claude-plugin/marketplace.json
458            // -> https://github.com/owner/repo
459            let parts: Vec<&str> = url.split('/').collect();
460            if parts.len() >= 5 {
461                let owner = parts[3];
462                let repo = parts[4];
463                format!("https://github.com/{}/{}", owner, repo)
464            } else {
465                String::new()
466            }
467        } else {
468            // For other URLs, extract base by removing filename
469            if let Some(pos) = url.rfind('/') {
470                url[..pos].to_string()
471            } else {
472                url.to_string()
473            }
474        };
475
476        // Convert Claude Code format to FastSkill internal format
477        let marketplace = self
478            .convert_claude_to_fastskill_format(claude_marketplace, base_url, "")
479            .await?;
480
481        // Validate marketplace.json structure
482        for skill in &marketplace.skills {
483            if skill.id.is_empty() || skill.name.is_empty() || skill.description.is_empty() {
484                return Err(SourcesError::Parse(
485                    "Invalid marketplace.json: skills must have id, name, and description"
486                        .to_string(),
487                ));
488            }
489        }
490
491        Ok(marketplace)
492    }
493
494    /// Load marketplace.json from a URL
495    /// Tries Claude Code standard location (.claude-plugin/marketplace.json) first, then root (marketplace.json)
496    async fn load_marketplace_from_url_with_branch(
497        &self,
498        base_url: &str,
499        branch: Option<&str>,
500        source_name: &str,
501    ) -> Result<Vec<SkillInfo>, SourcesError> {
502        // Construct marketplace.json URLs (try both locations)
503        // Priority 1: Claude Code standard location (.claude-plugin/marketplace.json)
504        // Priority 2: Root location (marketplace.json)
505        let branch_name = branch.unwrap_or("main");
506        let (claude_plugin_url, root_url) =
507            if base_url.contains("github.com") && !base_url.contains("raw.githubusercontent.com") {
508                // Convert GitHub URL to raw content URL
509                let repo_path = base_url
510                    .trim_start_matches("https://github.com/")
511                    .trim_start_matches("http://github.com/")
512                    .trim_end_matches(".git")
513                    .trim_end_matches('/');
514                (
515                    format!(
516                        "https://raw.githubusercontent.com/{}/{}/.claude-plugin/marketplace.json",
517                        repo_path, branch_name
518                    ),
519                    format!(
520                        "https://raw.githubusercontent.com/{}/{}/marketplace.json",
521                        repo_path, branch_name
522                    ),
523                )
524            } else {
525                let base = if base_url.ends_with('/') {
526                    base_url.to_string()
527                } else {
528                    format!("{}/", base_url)
529                };
530                (
531                    format!("{}.claude-plugin/marketplace.json", base),
532                    format!("{}marketplace.json", base),
533                )
534            };
535
536        // Check cache first (try both URLs)
537        let cache_key = claude_plugin_url.clone();
538        {
539            let cache = self.marketplace_cache.read().await;
540            if let Some(cached) = cache.get(&cache_key) {
541                if !cached.is_expired() {
542                    return Ok(cached
543                        .data
544                        .skills
545                        .iter()
546                        .map(|skill| SkillInfo {
547                            id: skill.id.clone(),
548                            name: skill.name.clone(),
549                            description: skill.description.clone(),
550                            version: Some(skill.version.clone()),
551                            source_name: source_name.to_string(),
552                        })
553                        .collect());
554                }
555            }
556            // Also check root URL cache
557            if let Some(cached) = cache.get(&root_url) {
558                if !cached.is_expired() {
559                    return Ok(cached
560                        .data
561                        .skills
562                        .iter()
563                        .map(|skill| SkillInfo {
564                            id: skill.id.clone(),
565                            name: skill.name.clone(),
566                            description: skill.description.clone(),
567                            version: Some(skill.version.clone()),
568                            source_name: source_name.to_string(),
569                        })
570                        .collect());
571                }
572            }
573        }
574
575        // Try Claude Code standard location first
576        let (marketplace, successful_url) = match self
577            .try_fetch_marketplace(&claude_plugin_url, Some(base_url))
578            .await
579        {
580            Ok(m) => {
581                tracing::debug!(
582                    "Loaded marketplace.json from Claude Code standard location: {}",
583                    claude_plugin_url
584                );
585                (m, claude_plugin_url.clone())
586            }
587            Err(e) => {
588                // Fall back to root location
589                tracing::debug!(
590                    "Claude Code location failed ({}), trying root location: {}",
591                    e,
592                    root_url
593                );
594                match self.try_fetch_marketplace(&root_url, Some(base_url)).await {
595                    Ok(m) => {
596                        tracing::debug!("Loaded marketplace.json from root location: {}", root_url);
597                        (m, root_url.clone())
598                    }
599                    Err(e2) => {
600                        return Err(SourcesError::Network(format!(
601                            "Failed to fetch marketplace.json from both locations. Claude Code location (.claude-plugin/marketplace.json): {}. Root location (marketplace.json): {}",
602                            e, e2
603                        )));
604                    }
605                }
606            }
607        };
608
609        {
610            let mut cache = self.marketplace_cache.write().await;
611            cache.insert(
612                successful_url.clone(),
613                CachedMarketplace {
614                    data: marketplace.clone(),
615                    fetched_at: Utc::now(),
616                    ttl_seconds: self.cache_ttl_seconds,
617                },
618            );
619        }
620
621        // Convert to SkillInfo
622        Ok(marketplace
623            .skills
624            .iter()
625            .map(|skill| SkillInfo {
626                id: skill.id.clone(),
627                name: skill.name.clone(),
628                description: skill.description.clone(),
629                version: Some(skill.version.clone()),
630                source_name: source_name.to_string(),
631            })
632            .collect())
633    }
634
635    /// Get marketplace.json for a specific source
636    /// Tries Claude Code standard location (.claude-plugin/marketplace.json) first, then root (marketplace.json)
637    pub async fn get_marketplace_json(
638        &self,
639        source_name: &str,
640    ) -> Result<MarketplaceJson, SourcesError> {
641        let source_def = self
642            .sources
643            .get(source_name)
644            .ok_or_else(|| SourcesError::SourceNotFound(source_name.to_string()))?;
645
646        let (base_url, branch) = match &source_def.source {
647            SourceConfig::Git { url, branch, .. } => {
648                (url.as_str(), branch.as_deref().unwrap_or("main"))
649            }
650            SourceConfig::ZipUrl { base_url, .. } => (base_url.as_str(), ""),
651            SourceConfig::Local { .. } => {
652                return Err(SourcesError::Network(
653                    "Local sources do not support marketplace.json".to_string(),
654                ));
655            }
656        };
657
658        // Construct marketplace.json URLs (try both locations)
659        // Priority 1: Claude Code standard location (.claude-plugin/marketplace.json)
660        // Priority 2: Root location (marketplace.json)
661        let (claude_plugin_url, root_url) =
662            if base_url.contains("github.com") && !base_url.contains("raw.githubusercontent.com") {
663                // Convert GitHub URL to raw content URL
664                let repo_path = base_url
665                    .trim_start_matches("https://github.com/")
666                    .trim_start_matches("http://github.com/")
667                    .trim_end_matches(".git")
668                    .trim_end_matches('/');
669                (
670                    format!(
671                        "https://raw.githubusercontent.com/{}/{}/.claude-plugin/marketplace.json",
672                        repo_path, branch
673                    ),
674                    format!(
675                        "https://raw.githubusercontent.com/{}/{}/marketplace.json",
676                        repo_path, branch
677                    ),
678                )
679            } else {
680                let base = if base_url.ends_with('/') {
681                    base_url.to_string()
682                } else {
683                    format!("{}/", base_url)
684                };
685                (
686                    format!("{}.claude-plugin/marketplace.json", base),
687                    format!("{}marketplace.json", base),
688                )
689            };
690
691        // Check cache first (try both URLs)
692        {
693            let cache = self.marketplace_cache.read().await;
694            if let Some(cached) = cache.get(&claude_plugin_url) {
695                if !cached.is_expired() {
696                    return Ok(cached.data.clone());
697                }
698            }
699            // Also check root URL cache
700            if let Some(cached) = cache.get(&root_url) {
701                if !cached.is_expired() {
702                    return Ok(cached.data.clone());
703                }
704            }
705        }
706
707        // Try Claude Code standard location first
708        let (marketplace, successful_url) = match self
709            .try_fetch_marketplace(&claude_plugin_url, Some(base_url))
710            .await
711        {
712            Ok(m) => {
713                tracing::debug!(
714                    "Loaded marketplace.json from Claude Code standard location: {}",
715                    claude_plugin_url
716                );
717                (m, claude_plugin_url.clone())
718            }
719            Err(e) => {
720                // Fall back to root location
721                tracing::debug!(
722                    "Claude Code location failed ({}), trying root location: {}",
723                    e,
724                    root_url
725                );
726                match self.try_fetch_marketplace(&root_url, Some(base_url)).await {
727                    Ok(m) => {
728                        tracing::debug!("Loaded marketplace.json from root location: {}", root_url);
729                        (m, root_url.clone())
730                    }
731                    Err(e2) => {
732                        return Err(SourcesError::Network(format!(
733                            "Failed to fetch marketplace.json from both locations. Claude Code location (.claude-plugin/marketplace.json): {}. Root location (marketplace.json): {}",
734                            e, e2
735                        )));
736                    }
737                }
738            }
739        };
740
741        // Cache the result (use the URL that succeeded)
742        {
743            let mut cache = self.marketplace_cache.write().await;
744            cache.insert(
745                successful_url.clone(),
746                CachedMarketplace {
747                    data: marketplace.clone(),
748                    fetched_at: Utc::now(),
749                    ttl_seconds: self.cache_ttl_seconds,
750                },
751            );
752        }
753
754        Ok(marketplace)
755    }
756
757    /// Scan a local directory for skills
758    async fn scan_local_source(
759        &self,
760        path: &PathBuf,
761        source_name: &str,
762    ) -> Result<Vec<SkillInfo>, SourcesError> {
763        use walkdir::WalkDir;
764
765        let resolved_path = if path.is_absolute() {
766            path.clone()
767        } else {
768            // Resolve relative to current directory
769            std::env::current_dir()
770                .map_err(SourcesError::Io)?
771                .join(path)
772        };
773
774        if !resolved_path.exists() {
775            return Err(SourcesError::NotFound(resolved_path));
776        }
777
778        if !resolved_path.is_dir() {
779            return Err(SourcesError::Io(std::io::Error::new(
780                std::io::ErrorKind::NotADirectory,
781                format!("Path is not a directory: {}", resolved_path.display()),
782            )));
783        }
784
785        let mut skills = Vec::new();
786
787        // Walk directory recursively
788        for entry in WalkDir::new(&resolved_path)
789            .into_iter()
790            .filter_map(|e| e.ok())
791        {
792            let entry_path = entry.path();
793            if entry_path.is_file()
794                && entry_path.file_name() == Some(std::ffi::OsStr::new("SKILL.md"))
795            {
796                // Found a skill directory
797                if let Some(skill_dir) = entry_path.parent() {
798                    // Try to extract skill metadata
799                    if let Ok(skill_info) =
800                        self.extract_skill_info_from_path(skill_dir, source_name)
801                    {
802                        skills.push(skill_info);
803                    }
804                }
805            }
806        }
807
808        Ok(skills)
809    }
810
811    /// Extract skill information from a local path
812    fn extract_skill_info_from_path(
813        &self,
814        skill_path: &Path,
815        source_name: &str,
816    ) -> Result<SkillInfo, SourcesError> {
817        use std::fs;
818
819        let skill_file = skill_path.join("SKILL.md");
820        if !skill_file.exists() {
821            return Err(SourcesError::NotFound(skill_file));
822        }
823
824        // Read SKILL.md to extract metadata
825        let content = fs::read_to_string(&skill_file).map_err(SourcesError::Io)?;
826
827        // Extract frontmatter (simple YAML frontmatter parser)
828        let (id, name, description, version) =
829            self.parse_skill_frontmatter(&content, skill_path)?;
830
831        Ok(SkillInfo {
832            id,
833            name,
834            description,
835            version: Some(version),
836            source_name: source_name.to_string(),
837        })
838    }
839
840    /// Parse YAML frontmatter from SKILL.md
841    fn parse_skill_frontmatter(
842        &self,
843        content: &str,
844        skill_path: &Path,
845    ) -> Result<(String, String, String, String), SourcesError> {
846        // Simple frontmatter parser - look for --- delimited YAML
847        if !content.starts_with("---\n") {
848            // No frontmatter, use directory name as fallback
849            let id = skill_path
850                .file_name()
851                .and_then(|n| n.to_str())
852                .unwrap_or("unknown")
853                .to_string();
854            return Ok((
855                id.clone(),
856                id.clone(),
857                "No description".to_string(),
858                "1.0.0".to_string(),
859            ));
860        }
861
862        // Find end of frontmatter
863        let end_marker = content[4..]
864            .find("---\n")
865            .ok_or_else(|| SourcesError::Parse("Invalid frontmatter format".to_string()))?;
866
867        let frontmatter = &content[4..end_marker + 4];
868
869        // Simple YAML parsing - just extract key fields
870        let mut id = None;
871        let mut name = None;
872        let mut description = None;
873        let mut version = None;
874
875        for line in frontmatter.lines() {
876            let line = line.trim();
877            if line.starts_with("id:") {
878                id = line
879                    .split(':')
880                    .nth(1)
881                    .map(|s| s.trim().trim_matches('"').to_string());
882            } else if line.starts_with("name:") {
883                name = line
884                    .split(':')
885                    .nth(1)
886                    .map(|s| s.trim().trim_matches('"').to_string());
887            } else if line.starts_with("description:") {
888                description = line
889                    .split(':')
890                    .nth(1)
891                    .map(|s| s.trim().trim_matches('"').to_string());
892            } else if line.starts_with("version:") {
893                version = line
894                    .split(':')
895                    .nth(1)
896                    .map(|s| s.trim().trim_matches('"').to_string());
897            }
898        }
899
900        // Use directory name as fallback for id
901        let skill_id = id.unwrap_or_else(|| {
902            skill_path
903                .file_name()
904                .and_then(|n| n.to_str())
905                .unwrap_or("unknown")
906                .to_string()
907        });
908
909        Ok((
910            skill_id.clone(),
911            name.unwrap_or(skill_id.clone()),
912            description.unwrap_or_else(|| "No description".to_string()),
913            version.unwrap_or_else(|| "1.0.0".to_string()),
914        ))
915    }
916}
917
918impl SourcesConfig {
919    /// Load sources config from TOML file
920    pub fn load_from_file(path: &Path) -> Result<Self, SourcesError> {
921        if !path.exists() {
922            return Err(SourcesError::NotFound(path.to_path_buf()));
923        }
924
925        let content = std::fs::read_to_string(path).map_err(SourcesError::Io)?;
926
927        let config: SourcesConfig =
928            toml::from_str(&content).map_err(|e| SourcesError::Parse(e.to_string()))?;
929
930        Ok(config)
931    }
932
933    /// Save sources config to TOML file
934    pub fn save_to_file(&self, path: &Path) -> Result<(), SourcesError> {
935        let content =
936            toml::to_string_pretty(self).map_err(|e| SourcesError::Serialize(e.to_string()))?;
937
938        std::fs::write(path, content).map_err(SourcesError::Io)?;
939
940        Ok(())
941    }
942}
943
944/// Sources-related errors
945#[derive(Debug, thiserror::Error)]
946pub enum SourcesError {
947    #[error("Sources config file not found: {0}")]
948    NotFound(PathBuf),
949
950    #[error("IO error: {0}")]
951    Io(#[from] std::io::Error),
952
953    #[error("Parse error: {0}")]
954    Parse(String),
955
956    #[error("Serialize error: {0}")]
957    Serialize(String),
958
959    #[error("Source already exists: {0}")]
960    AlreadyExists(String),
961
962    #[error("Source not found: {0}")]
963    SourceNotFound(String),
964
965    #[error("Network error: {0}")]
966    Network(String),
967
968    #[error("Git error: {0}")]
969    Git(String),
970}
971
972#[cfg(test)]
973#[allow(clippy::unwrap_used)]
974mod tests {
975    use super::*;
976    use tempfile::NamedTempFile;
977
978    #[test]
979    fn test_sources_config_parsing() {
980        let toml_content = r#"
981            [[sources]]
982            name = "team-tools"
983            source = { type = "git", url = "https://github.com/org/team-plugins.git", branch = "main" }
984
985            [[sources]]
986            name = "official-skills"
987            source = { type = "zip-url", base_url = "https://skills.example.com/" }
988
989            [[sources]]
990            name = "local"
991            source = { type = "local", path = "./local-sources" }
992        "#;
993
994        let config: SourcesConfig = toml::from_str(toml_content).unwrap();
995
996        assert_eq!(config.sources.len(), 3);
997        assert_eq!(config.sources[0].name, "team-tools");
998        assert_eq!(config.sources[1].name, "official-skills");
999        assert_eq!(config.sources[2].name, "local");
1000    }
1001
1002    #[test]
1003    fn test_source_config_variants() {
1004        // Test Git source
1005        let git_config = SourceConfig::Git {
1006            url: "https://github.com/org/repo.git".to_string(),
1007            branch: Some("main".to_string()),
1008            tag: None,
1009            auth: None,
1010        };
1011
1012        // Test ZipUrl source
1013        let zip_config = SourceConfig::ZipUrl {
1014            base_url: "https://skills.example.com/".to_string(),
1015            auth: None,
1016        };
1017
1018        // Test Local source
1019        let local_config = SourceConfig::Local {
1020            path: PathBuf::from("./local-sources"),
1021        };
1022
1023        // Verify they serialize correctly
1024        let git_toml = toml::to_string(&git_config).unwrap();
1025        assert!(git_toml.contains("type = \"git\""));
1026
1027        let zip_toml = toml::to_string(&zip_config).unwrap();
1028        assert!(zip_toml.contains("type = \"zip-url\""));
1029
1030        let local_toml = toml::to_string(&local_config).unwrap();
1031        assert!(local_toml.contains("type = \"local\""));
1032    }
1033
1034    #[tokio::test]
1035    async fn test_sources_manager() {
1036        let temp_file = NamedTempFile::new().unwrap();
1037        let config_path = temp_file.path().to_path_buf();
1038
1039        let mut manager = SourcesManager::new(config_path.clone());
1040
1041        // Load (should create empty config)
1042        manager.load().unwrap();
1043        assert_eq!(manager.list_sources().len(), 0);
1044
1045        // Add sources
1046        manager
1047            .add_source(
1048                "team-tools".to_string(),
1049                SourceConfig::Git {
1050                    url: "https://github.com/org/team-plugins.git".to_string(),
1051                    branch: Some("main".to_string()),
1052                    tag: None,
1053                    auth: None,
1054                },
1055            )
1056            .unwrap();
1057
1058        manager
1059            .add_source(
1060                "official".to_string(),
1061                SourceConfig::ZipUrl {
1062                    base_url: "https://skills.example.com/".to_string(),
1063                    auth: None,
1064                },
1065            )
1066            .unwrap();
1067
1068        assert_eq!(manager.list_sources().len(), 2);
1069
1070        // Save and reload
1071        manager.save().unwrap();
1072
1073        let mut new_manager = SourcesManager::new(config_path);
1074        new_manager.load().unwrap();
1075        assert_eq!(new_manager.list_sources().len(), 2);
1076    }
1077}