Skip to main content

dot_agent_core/channel/
search.rs

1//! Channel search functionality
2//!
3//! Provides search capabilities across different channel types.
4
5use std::collections::HashMap;
6use std::path::PathBuf;
7use std::process::Command;
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::{DotAgentError, Result};
12
13use super::channel_registry::ChannelRegistry;
14use super::types::{Channel, ChannelSource, ChannelType, ProfileRef, SearchOptions};
15
16/// A skill entry from OpenAI Codex Skills Catalog
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct CodexSkill {
19    /// Skill name (directory name)
20    pub name: String,
21    /// Skill category (.system, .curated, .experimental)
22    pub category: String,
23    /// Full path within repo (e.g., "skills/.curated/pdf")
24    pub path: String,
25    /// Description from SKILL.md frontmatter (if fetched)
26    pub description: Option<String>,
27}
28
29/// A plugin entry from a Claude Code Plugin Marketplace
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct MarketplacePlugin {
32    /// Plugin name (kebab-case identifier)
33    pub name: String,
34    /// Plugin description
35    pub description: Option<String>,
36    /// Plugin version
37    pub version: Option<String>,
38    /// Plugin source (relative path or git source object)
39    pub source: serde_json::Value,
40    /// Plugin category
41    pub category: Option<String>,
42    /// Plugin keywords/tags
43    pub keywords: Option<Vec<String>>,
44    /// Plugin author
45    pub author: Option<serde_json::Value>,
46    /// LSP servers configuration (inline definition)
47    pub lsp_servers: Option<serde_json::Value>,
48    /// MCP servers configuration (inline definition)
49    pub mcp_servers: Option<serde_json::Value>,
50    /// Hooks configuration (inline definition)
51    pub hooks: Option<serde_json::Value>,
52    /// Commands configuration (inline definition)
53    pub commands: Option<serde_json::Value>,
54    /// Agents configuration (inline definition)
55    pub agents: Option<serde_json::Value>,
56}
57
58impl MarketplacePlugin {
59    /// Create from JSON value
60    pub fn from_json(value: &serde_json::Value) -> Self {
61        Self {
62            name: value
63                .get("name")
64                .and_then(|v| v.as_str())
65                .unwrap_or_default()
66                .to_string(),
67            description: value
68                .get("description")
69                .and_then(|v| v.as_str())
70                .map(|s| s.to_string()),
71            version: value
72                .get("version")
73                .and_then(|v| v.as_str())
74                .map(|s| s.to_string()),
75            source: value
76                .get("source")
77                .cloned()
78                .unwrap_or(serde_json::Value::Null),
79            category: value
80                .get("category")
81                .and_then(|v| v.as_str())
82                .map(|s| s.to_string()),
83            keywords: value.get("keywords").and_then(|v| {
84                v.as_array().map(|arr| {
85                    arr.iter()
86                        .filter_map(|k| k.as_str().map(|s| s.to_string()))
87                        .collect()
88                })
89            }),
90            author: value.get("author").cloned(),
91            lsp_servers: value.get("lspServers").cloned(),
92            mcp_servers: value.get("mcpServers").cloned(),
93            hooks: value.get("hooks").cloned(),
94            commands: value.get("commands").cloned(),
95            agents: value.get("agents").cloned(),
96        }
97    }
98
99    /// Get the source as a relative path (if it's a string)
100    pub fn source_path(&self) -> Option<&str> {
101        self.source.as_str()
102    }
103
104    /// Get the source as a git repo (if it's an object with "source": "github")
105    pub fn source_github_repo(&self) -> Option<&str> {
106        self.source.get("repo").and_then(|v| v.as_str())
107    }
108
109    /// Get the source as a URL (if it's an object with "source": "url")
110    pub fn source_url(&self) -> Option<&str> {
111        if self.source.get("source").and_then(|v| v.as_str()) == Some("url") {
112            self.source.get("url").and_then(|v| v.as_str())
113        } else {
114            None
115        }
116    }
117
118    /// Check if plugin has inline configurations (strict: false pattern)
119    pub fn has_inline_config(&self) -> bool {
120        self.lsp_servers.is_some()
121            || self.mcp_servers.is_some()
122            || self.hooks.is_some()
123            || self.commands.is_some()
124            || self.agents.is_some()
125    }
126
127    /// Write inline configurations to the target directory as separate files
128    pub fn write_config_files(&self, target_dir: &std::path::Path) -> Result<Vec<String>> {
129        use std::fs;
130
131        let mut written_files = Vec::new();
132
133        // Write .lsp.json if lspServers is defined
134        if let Some(lsp) = &self.lsp_servers {
135            let lsp_path = target_dir.join(".lsp.json");
136            let content =
137                serde_json::to_string_pretty(lsp).map_err(|e| DotAgentError::GitHubApiError {
138                    message: format!("Failed to serialize lspServers: {}", e),
139                })?;
140            fs::write(&lsp_path, content)?;
141            written_files.push(".lsp.json".to_string());
142        }
143
144        // Write .mcp.json if mcpServers is defined
145        if let Some(mcp) = &self.mcp_servers {
146            let mcp_path = target_dir.join(".mcp.json");
147            let content =
148                serde_json::to_string_pretty(mcp).map_err(|e| DotAgentError::GitHubApiError {
149                    message: format!("Failed to serialize mcpServers: {}", e),
150                })?;
151            fs::write(&mcp_path, content)?;
152            written_files.push(".mcp.json".to_string());
153        }
154
155        // Write hooks.json if hooks is defined
156        if let Some(hooks) = &self.hooks {
157            let hooks_path = target_dir.join("hooks.json");
158            let content =
159                serde_json::to_string_pretty(hooks).map_err(|e| DotAgentError::GitHubApiError {
160                    message: format!("Failed to serialize hooks: {}", e),
161                })?;
162            fs::write(&hooks_path, content)?;
163            written_files.push("hooks.json".to_string());
164        }
165
166        // Write commands.json if commands is defined
167        if let Some(commands) = &self.commands {
168            let commands_path = target_dir.join("commands.json");
169            let content = serde_json::to_string_pretty(commands).map_err(|e| {
170                DotAgentError::GitHubApiError {
171                    message: format!("Failed to serialize commands: {}", e),
172                }
173            })?;
174            fs::write(&commands_path, content)?;
175            written_files.push("commands.json".to_string());
176        }
177
178        // Write agents.json if agents is defined
179        if let Some(agents) = &self.agents {
180            let agents_path = target_dir.join("agents.json");
181            let content = serde_json::to_string_pretty(agents).map_err(|e| {
182                DotAgentError::GitHubApiError {
183                    message: format!("Failed to serialize agents: {}", e),
184                }
185            })?;
186            fs::write(&agents_path, content)?;
187            written_files.push("agents.json".to_string());
188        }
189
190        Ok(written_files)
191    }
192}
193
194/// Channel manager for search operations
195pub struct ChannelManager {
196    base_dir: PathBuf,
197    registry: ChannelRegistry,
198}
199
200impl ChannelManager {
201    /// Create a new channel manager
202    pub fn new(base_dir: PathBuf) -> Result<Self> {
203        let registry = ChannelRegistry::load(&base_dir)?;
204        Ok(Self { base_dir, registry })
205    }
206
207    /// Create with a specific registry
208    pub fn with_registry(base_dir: PathBuf, registry: ChannelRegistry) -> Self {
209        Self { base_dir, registry }
210    }
211
212    /// Get the registry
213    pub fn registry(&self) -> &ChannelRegistry {
214        &self.registry
215    }
216
217    /// Get mutable registry
218    pub fn registry_mut(&mut self) -> &mut ChannelRegistry {
219        &mut self.registry
220    }
221
222    /// Save registry
223    pub fn save(&self) -> Result<()> {
224        self.registry.save(&self.base_dir)
225    }
226
227    /// Search across all enabled searchable channels
228    pub fn search(&self, query: &str, options: &SearchOptions) -> Result<Vec<ProfileRef>> {
229        let mut results = Vec::new();
230
231        // Get channels to search
232        let channels: Vec<&Channel> = if options.channels.is_empty() {
233            self.registry.list_searchable()
234        } else {
235            self.registry
236                .list_enabled()
237                .into_iter()
238                .filter(|c| options.channels.contains(&c.name))
239                .collect()
240        };
241
242        for channel in channels {
243            match self.search_channel(channel, query, options) {
244                Ok(refs) => results.extend(refs),
245                Err(e) => {
246                    eprintln!("Warning: {} search failed: {}", channel.name, e);
247                }
248            }
249        }
250
251        // Sort by stars (descending)
252        results.sort_by(|a, b| b.stars.cmp(&a.stars));
253
254        // Apply limit
255        if options.limit > 0 {
256            results.truncate(options.limit);
257        }
258
259        Ok(results)
260    }
261
262    /// Search a specific channel
263    fn search_channel(
264        &self,
265        channel: &Channel,
266        query: &str,
267        options: &SearchOptions,
268    ) -> Result<Vec<ProfileRef>> {
269        match channel.channel_type {
270            ChannelType::GitHubGlobal => self.search_github(channel, query, options),
271            ChannelType::AwesomeList => self.search_awesome_list(channel, query, options),
272            ChannelType::Marketplace => self.search_marketplace(channel, query, options),
273            ChannelType::CodexCatalog => self.search_codex_catalog(channel, query, options),
274            ChannelType::Hub | ChannelType::Direct => {
275                // Not searchable
276                Ok(Vec::new())
277            }
278        }
279    }
280
281    /// Search GitHub globally
282    fn search_github(
283        &self,
284        channel: &Channel,
285        query: &str,
286        options: &SearchOptions,
287    ) -> Result<Vec<ProfileRef>> {
288        // Check if gh CLI is available
289        if !Self::check_gh_available() {
290            return Err(DotAgentError::GitHubCliNotFound);
291        }
292
293        let mut args = vec!["search".to_string(), "repos".to_string()];
294
295        // Build query
296        let search_query = if options.keywords.is_empty() {
297            query.to_string()
298        } else {
299            format!("{} {}", query, options.keywords.join(" "))
300        };
301
302        if !search_query.is_empty() {
303            args.push(search_query);
304        }
305
306        // Topic filter
307        if let Some(topic) = &options.topic {
308            args.push("--topic".to_string());
309            args.push(topic.clone());
310        }
311
312        // Sort order
313        args.push("--sort".to_string());
314        args.push(options.sort.clone().unwrap_or_else(|| "stars".to_string()));
315
316        // Limit (per channel)
317        let limit = if options.limit > 0 { options.limit } else { 10 };
318        args.push("--limit".to_string());
319        args.push(limit.to_string());
320
321        // Stars filter
322        if let Some(min) = options.min_stars {
323            args.push("--stars".to_string());
324            args.push(format!(">={}", min));
325        }
326
327        // JSON output
328        args.push("--json".to_string());
329        args.push("name,owner,description,url,stargazersCount".to_string());
330
331        let output = Command::new("gh").args(&args).output().map_err(|e| {
332            if e.kind() == std::io::ErrorKind::NotFound {
333                DotAgentError::GitHubCliNotFound
334            } else {
335                DotAgentError::Io(e)
336            }
337        })?;
338
339        if !output.status.success() {
340            let stderr = String::from_utf8_lossy(&output.stderr);
341            return Err(DotAgentError::GitHubApiError {
342                message: stderr.to_string(),
343            });
344        }
345
346        let json: serde_json::Value =
347            serde_json::from_slice(&output.stdout).unwrap_or(serde_json::Value::Array(vec![]));
348
349        let repos = json
350            .as_array()
351            .ok_or_else(|| DotAgentError::GitHubApiError {
352                message: "Invalid JSON response".to_string(),
353            })?;
354
355        let mut results = Vec::new();
356
357        for repo in repos {
358            let name = repo["name"].as_str().unwrap_or("").to_string();
359            let owner = repo["owner"]["login"].as_str().unwrap_or("").to_string();
360            let description = repo["description"]
361                .as_str()
362                .unwrap_or("No description")
363                .to_string();
364            let url = repo["url"].as_str().unwrap_or("").to_string();
365            let stars = repo["stargazersCount"].as_u64();
366
367            let id = format!("github:{}/{}", owner, name);
368
369            results.push(ProfileRef {
370                id,
371                name,
372                owner,
373                description,
374                url,
375                stars,
376                channel: channel.name.clone(),
377                metadata: HashMap::new(),
378            });
379        }
380
381        Ok(results)
382    }
383
384    /// Search an Awesome List
385    fn search_awesome_list(
386        &self,
387        channel: &Channel,
388        query: &str,
389        options: &SearchOptions,
390    ) -> Result<Vec<ProfileRef>> {
391        let url = match channel.source.url() {
392            Some(u) => u,
393            None => return Ok(Vec::new()),
394        };
395
396        // Get cached content or fetch
397        let content = self.fetch_awesome_list(url, &channel.name)?;
398
399        // Parse and search
400        let query_lower = query.to_lowercase();
401        let keywords: Vec<String> = options.keywords.iter().map(|k| k.to_lowercase()).collect();
402
403        let mut results = Vec::new();
404
405        for line in content.lines() {
406            if let Some(profile_ref) = Self::parse_awesome_line(line, &channel.name) {
407                // Check if matches query
408                let text = format!(
409                    "{} {} {}",
410                    profile_ref.name, profile_ref.owner, profile_ref.description
411                )
412                .to_lowercase();
413
414                let matches_query = query.is_empty() || text.contains(&query_lower);
415                let matches_keywords =
416                    keywords.is_empty() || keywords.iter().all(|k| text.contains(k));
417
418                if matches_query && matches_keywords {
419                    results.push(profile_ref);
420                }
421            }
422        }
423
424        // Apply limit
425        if options.limit > 0 {
426            results.truncate(options.limit);
427        }
428
429        Ok(results)
430    }
431
432    /// Fetch Awesome List content (with caching)
433    fn fetch_awesome_list(&self, url: &str, channel_name: &str) -> Result<String> {
434        let cache_dir = ChannelRegistry::cache_dir(&self.base_dir, channel_name);
435        let cache_file = cache_dir.join("content.md");
436
437        // Check cache
438        if cache_file.exists() {
439            if let Ok(content) = std::fs::read_to_string(&cache_file) {
440                return Ok(content);
441            }
442        }
443
444        // Fetch from URL, try main first, then master
445        let content = if let Some(c) = Self::fetch_url(&Self::to_raw_url(url, "main"))? {
446            c
447        } else if let Some(c) = Self::fetch_url(&Self::to_raw_url(url, "master"))? {
448            c
449        } else {
450            return Err(DotAgentError::GitHubApiError {
451                message: format!(
452                    "Failed to fetch README from: {} (tried main and master)",
453                    url
454                ),
455            });
456        };
457
458        // Cache it
459        std::fs::create_dir_all(&cache_dir)?;
460        std::fs::write(&cache_file, &content)?;
461
462        Ok(content)
463    }
464
465    /// Parse a line from Awesome List markdown
466    fn parse_awesome_line(line: &str, channel_name: &str) -> Option<ProfileRef> {
467        // Format: - [name](url) - description
468        // or: * [name](url) - description
469        let line = line.trim();
470
471        if !line.starts_with('-') && !line.starts_with('*') {
472            return None;
473        }
474
475        // Find [name](url)
476        let start_bracket = line.find('[')?;
477        let end_bracket = line.find(']')?;
478        let start_paren = line.find('(')?;
479        let end_paren = line.find(')')?;
480
481        if end_bracket >= start_paren || start_paren >= end_paren {
482            return None;
483        }
484
485        let name = &line[start_bracket + 1..end_bracket];
486        let url = &line[start_paren + 1..end_paren];
487
488        // Skip non-http links
489        if !url.starts_with("http://") && !url.starts_with("https://") {
490            return None;
491        }
492
493        // Extract description (after " - " or just after ")")
494        let desc_start = end_paren + 1;
495        let description = if desc_start < line.len() {
496            let desc = &line[desc_start..];
497            let desc = desc.trim_start_matches([' ', '-']);
498            desc.trim().to_string()
499        } else {
500            String::new()
501        };
502
503        // Extract owner from URL
504        let owner = Self::extract_owner_from_url(url);
505
506        let id = format!("awesome:{}#{}", channel_name, name);
507
508        Some(ProfileRef {
509            id,
510            name: name.to_string(),
511            owner,
512            description,
513            url: url.to_string(),
514            stars: None,
515            channel: channel_name.to_string(),
516            metadata: HashMap::new(),
517        })
518    }
519
520    /// Extract owner from GitHub URL
521    fn extract_owner_from_url(url: &str) -> String {
522        // https://github.com/owner/repo -> owner
523        if url.contains("github.com") {
524            let parts: Vec<&str> = url.split('/').collect();
525            if parts.len() >= 4 {
526                return parts[3].to_string();
527            }
528        }
529        "unknown".to_string()
530    }
531
532    /// Convert GitHub URL to raw content URL with specified branch
533    fn to_raw_url(url: &str, branch: &str) -> String {
534        if url.contains("github.com") && !url.contains("raw.githubusercontent.com") {
535            // https://github.com/user/repo -> raw README
536            let url = url
537                .replace("github.com", "raw.githubusercontent.com")
538                .trim_end_matches('/')
539                .to_string();
540            format!("{}/{}/README.md", url, branch)
541        } else {
542            url.to_string()
543        }
544    }
545
546    /// Normalize repo string to owner/repo format
547    /// Accepts: "owner/repo", "https://github.com/owner/repo", "github.com/owner/repo"
548    fn normalize_repo(repo: &str) -> String {
549        let repo = repo.trim_end_matches('/');
550
551        // Already in owner/repo format
552        if !repo.contains("://") && !repo.contains("github.com") {
553            return repo.to_string();
554        }
555
556        // Extract owner/repo from URL
557        // https://github.com/owner/repo -> owner/repo
558        if let Some(pos) = repo.find("github.com/") {
559            let after = &repo[pos + 11..]; // skip "github.com/"
560            return after.to_string();
561        }
562
563        repo.to_string()
564    }
565
566    /// Fetch URL content, returns None for 404
567    fn fetch_url(url: &str) -> Result<Option<String>> {
568        let output = Command::new("curl")
569            .args(["-sL", "-w", "%{http_code}", "-o", "-", url])
570            .output()
571            .map_err(DotAgentError::Io)?;
572
573        let stdout = String::from_utf8_lossy(&output.stdout);
574
575        // Extract HTTP status code (last 3 chars)
576        if stdout.len() >= 3 {
577            let (content, status) = stdout.split_at(stdout.len() - 3);
578            if status == "404" {
579                return Ok(None);
580            }
581            if status.starts_with('2') {
582                return Ok(Some(content.to_string()));
583            }
584        }
585
586        // Fallback: check if output looks like an error
587        if !output.status.success() || stdout.contains("404") {
588            return Ok(None);
589        }
590
591        Ok(Some(stdout.to_string()))
592    }
593
594    /// Check if gh CLI is available
595    fn check_gh_available() -> bool {
596        Command::new("gh")
597            .arg("--version")
598            .output()
599            .map(|o| o.status.success())
600            .unwrap_or(false)
601    }
602
603    /// Refresh a channel's cache
604    pub fn refresh_channel(&self, channel_name: &str) -> Result<()> {
605        let channel =
606            self.registry
607                .get(channel_name)
608                .ok_or_else(|| DotAgentError::ChannelNotFound {
609                    name: channel_name.to_string(),
610                })?;
611
612        match &channel.source {
613            ChannelSource::Marketplace { repo } => {
614                self.fetch_marketplace_catalog(repo, channel_name)?;
615            }
616            ChannelSource::CodexCatalog { repo, base_path } => {
617                self.fetch_codex_catalog(repo, base_path, channel_name)?;
618            }
619            _ => {
620                if let Some(url) = channel.source.url() {
621                    // Try main first, then master
622                    let content = if let Some(c) = Self::fetch_url(&Self::to_raw_url(url, "main"))?
623                    {
624                        c
625                    } else if let Some(c) = Self::fetch_url(&Self::to_raw_url(url, "master"))? {
626                        c
627                    } else {
628                        return Err(DotAgentError::GitHubApiError {
629                            message: format!(
630                                "Failed to fetch README from: {} (tried main and master)",
631                                url
632                            ),
633                        });
634                    };
635
636                    let cache_dir = ChannelRegistry::cache_dir(&self.base_dir, channel_name);
637                    std::fs::create_dir_all(&cache_dir)?;
638                    std::fs::write(cache_dir.join("content.md"), content)?;
639                }
640            }
641        }
642
643        Ok(())
644    }
645
646    /// Fetch marketplace catalog from GitHub repository
647    ///
648    /// Downloads `.claude-plugin/marketplace.json` from the repository
649    /// and caches it locally.
650    fn fetch_marketplace_catalog(&self, repo: &str, channel_name: &str) -> Result<()> {
651        // Normalize repo: extract owner/repo from full URL if needed
652        let repo = Self::normalize_repo(repo);
653
654        // Try main first, then master
655        let content = if let Some(c) = Self::fetch_url(&format!(
656            "https://raw.githubusercontent.com/{}/main/.claude-plugin/marketplace.json",
657            repo
658        ))? {
659            c
660        } else if let Some(c) = Self::fetch_url(&format!(
661            "https://raw.githubusercontent.com/{}/master/.claude-plugin/marketplace.json",
662            repo
663        ))? {
664            c
665        } else {
666            return Err(DotAgentError::GitHubApiError {
667                message: format!(
668                    "Failed to fetch marketplace.json from: {} (tried main and master)",
669                    repo
670                ),
671            });
672        };
673
674        // Validate JSON structure
675        let data: serde_json::Value =
676            serde_json::from_str(&content).map_err(|e| DotAgentError::GitHubApiError {
677                message: format!("Invalid marketplace.json: {}", e),
678            })?;
679
680        // Check required fields
681        if data.get("name").is_none() || data.get("plugins").is_none() {
682            return Err(DotAgentError::GitHubApiError {
683                message: "marketplace.json missing required fields (name, plugins)".to_string(),
684            });
685        }
686
687        // Cache the catalog
688        let cache_dir = ChannelRegistry::cache_dir(&self.base_dir, channel_name);
689        std::fs::create_dir_all(&cache_dir)?;
690        std::fs::write(cache_dir.join("marketplace.json"), content)?;
691
692        Ok(())
693    }
694
695    /// Get a plugin entry from marketplace catalog
696    pub fn get_marketplace_plugin(
697        &self,
698        channel_name: &str,
699        plugin_name: &str,
700    ) -> Result<Option<MarketplacePlugin>> {
701        let cache_dir = ChannelRegistry::cache_dir(&self.base_dir, channel_name);
702        let cache_file = cache_dir.join("marketplace.json");
703
704        if !cache_file.exists() {
705            return Ok(None);
706        }
707
708        let content = std::fs::read_to_string(&cache_file)?;
709        let data: serde_json::Value =
710            serde_json::from_str(&content).map_err(|e| DotAgentError::GitHubApiError {
711                message: format!("Invalid marketplace.json: {}", e),
712            })?;
713
714        let plugins = data
715            .get("plugins")
716            .and_then(|p| p.as_array())
717            .ok_or_else(|| DotAgentError::GitHubApiError {
718                message: "marketplace.json has no plugins array".to_string(),
719            })?;
720
721        for plugin in plugins {
722            let name = plugin.get("name").and_then(|n| n.as_str()).unwrap_or("");
723            if name == plugin_name {
724                return Ok(Some(MarketplacePlugin::from_json(plugin)));
725            }
726        }
727
728        Ok(None)
729    }
730
731    /// Search a Marketplace channel
732    fn search_marketplace(
733        &self,
734        channel: &Channel,
735        query: &str,
736        options: &SearchOptions,
737    ) -> Result<Vec<ProfileRef>> {
738        let repo = match &channel.source {
739            ChannelSource::Marketplace { repo } => repo,
740            _ => return Ok(Vec::new()),
741        };
742
743        let query_lower = query.to_lowercase();
744        let mut results = Vec::new();
745
746        // Check if marketplace content is cached
747        let cache_dir = ChannelRegistry::cache_dir(&self.base_dir, &channel.name);
748        let cache_file = cache_dir.join("marketplace.json");
749
750        // Auto-fetch if cache doesn't exist
751        if !cache_file.exists() {
752            self.fetch_marketplace_catalog(repo, &channel.name)?;
753        }
754
755        // Parse cached marketplace data
756        if let Ok(content) = std::fs::read_to_string(&cache_file) {
757            if let Ok(data) = serde_json::from_str::<serde_json::Value>(&content) {
758                if let Some(plugins) = data.get("plugins").and_then(|p| p.as_array()) {
759                    for plugin in plugins {
760                        let name = plugin
761                            .get("name")
762                            .and_then(|n| n.as_str())
763                            .unwrap_or_default();
764                        let description = plugin
765                            .get("description")
766                            .and_then(|d| d.as_str())
767                            .unwrap_or_default();
768                        let version = plugin
769                            .get("version")
770                            .and_then(|v| v.as_str())
771                            .unwrap_or("unknown");
772                        let category = plugin
773                            .get("category")
774                            .and_then(|c| c.as_str())
775                            .unwrap_or("");
776
777                        // Filter by query (search in name, description, category)
778                        let text = format!("{} {} {}", name, description, category).to_lowercase();
779                        if query.is_empty() || text.contains(&query_lower) {
780                            let mut metadata = HashMap::new();
781                            metadata.insert("version".to_string(), version.to_string());
782                            if !category.is_empty() {
783                                metadata.insert("category".to_string(), category.to_string());
784                            }
785
786                            results.push(ProfileRef {
787                                id: format!("marketplace:{}@{}", name, channel.name),
788                                name: name.to_string(),
789                                owner: repo.split('/').next().unwrap_or("unknown").to_string(),
790                                description: description.to_string(),
791                                url: format!("https://github.com/{}", repo),
792                                stars: None,
793                                channel: channel.name.clone(),
794                                metadata,
795                            });
796                        }
797                    }
798                }
799            }
800        }
801
802        // Apply limit
803        if options.limit > 0 {
804            results.truncate(options.limit);
805        }
806
807        Ok(results)
808    }
809
810    /// Fetch Codex skills catalog from GitHub repository using GitHub API
811    ///
812    /// Scans directory structure like Codex CLI's $skill-installer does:
813    /// - skills/.system (preinstalled system skills)
814    /// - skills/.curated (recommended skills)
815    /// - skills/.experimental (experimental skills)
816    fn fetch_codex_catalog(&self, repo: &str, base_path: &str, channel_name: &str) -> Result<()> {
817        let repo = Self::normalize_repo(repo);
818
819        // Categories to scan (matching Codex CLI's structure)
820        let categories = [".system", ".curated", ".experimental"];
821        let mut all_skills: Vec<CodexSkill> = Vec::new();
822
823        for category in &categories {
824            let path = format!("{}/{}", base_path, category);
825            match self.fetch_github_directory_contents(&repo, &path) {
826                Ok(skills) => {
827                    for skill_name in skills {
828                        all_skills.push(CodexSkill {
829                            name: skill_name.clone(),
830                            category: category.to_string(),
831                            path: format!("{}/{}", path, skill_name),
832                            description: None,
833                        });
834                    }
835                }
836                Err(e) => {
837                    // Log but continue - some categories may not exist
838                    eprintln!("Warning: Failed to fetch {}/{}: {}", repo, path, e);
839                }
840            }
841        }
842
843        if all_skills.is_empty() {
844            return Err(DotAgentError::GitHubApiError {
845                message: format!(
846                    "No skills found in {}/{} (tried .system, .curated, .experimental)",
847                    repo, base_path
848                ),
849            });
850        }
851
852        // Cache the catalog as JSON
853        let cache_dir = ChannelRegistry::cache_dir(&self.base_dir, channel_name);
854        std::fs::create_dir_all(&cache_dir)?;
855
856        let catalog = serde_json::json!({
857            "repo": repo,
858            "base_path": base_path,
859            "skills": all_skills,
860            "fetched_at": chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(),
861        });
862
863        std::fs::write(
864            cache_dir.join("codex_catalog.json"),
865            serde_json::to_string_pretty(&catalog).map_err(|e| DotAgentError::GitHubApiError {
866                message: format!("Failed to serialize catalog: {}", e),
867            })?,
868        )?;
869
870        Ok(())
871    }
872
873    /// Fetch directory contents from GitHub API
874    /// Returns list of directory names (skills)
875    fn fetch_github_directory_contents(&self, repo: &str, path: &str) -> Result<Vec<String>> {
876        // Use gh CLI for GitHub API access
877        if !Self::check_gh_available() {
878            return Err(DotAgentError::GitHubCliNotFound);
879        }
880
881        let api_path = format!("repos/{}/contents/{}", repo, path);
882        let output = Command::new("gh")
883            .args([
884                "api",
885                &api_path,
886                "--jq",
887                "[.[] | select(.type == \"dir\") | .name]",
888            ])
889            .output()
890            .map_err(|e| {
891                if e.kind() == std::io::ErrorKind::NotFound {
892                    DotAgentError::GitHubCliNotFound
893                } else {
894                    DotAgentError::Io(e)
895                }
896            })?;
897
898        if !output.status.success() {
899            let stderr = String::from_utf8_lossy(&output.stderr);
900            return Err(DotAgentError::GitHubApiError {
901                message: format!("GitHub API error for {}/{}: {}", repo, path, stderr),
902            });
903        }
904
905        let json: Vec<String> =
906            serde_json::from_slice(&output.stdout).map_err(|e| DotAgentError::GitHubApiError {
907                message: format!("Invalid JSON response: {}", e),
908            })?;
909
910        Ok(json)
911    }
912
913    /// Search a Codex Catalog channel
914    fn search_codex_catalog(
915        &self,
916        channel: &Channel,
917        query: &str,
918        options: &SearchOptions,
919    ) -> Result<Vec<ProfileRef>> {
920        let (repo, base_path) = match &channel.source {
921            ChannelSource::CodexCatalog { repo, base_path } => (repo.clone(), base_path.clone()),
922            _ => return Ok(Vec::new()),
923        };
924
925        let query_lower = query.to_lowercase();
926        let mut results = Vec::new();
927
928        // Check if catalog is cached
929        let cache_dir = ChannelRegistry::cache_dir(&self.base_dir, &channel.name);
930        let cache_file = cache_dir.join("codex_catalog.json");
931
932        // Auto-fetch if cache doesn't exist
933        if !cache_file.exists() {
934            self.fetch_codex_catalog(&repo, &base_path, &channel.name)?;
935        }
936
937        // Parse cached catalog
938        if let Ok(content) = std::fs::read_to_string(&cache_file) {
939            if let Ok(data) = serde_json::from_str::<serde_json::Value>(&content) {
940                if let Some(skills) = data.get("skills").and_then(|s| s.as_array()) {
941                    for skill in skills {
942                        let name = skill
943                            .get("name")
944                            .and_then(|n| n.as_str())
945                            .unwrap_or_default();
946                        let category = skill
947                            .get("category")
948                            .and_then(|c| c.as_str())
949                            .unwrap_or_default();
950                        let path = skill
951                            .get("path")
952                            .and_then(|p| p.as_str())
953                            .unwrap_or_default();
954                        let description = skill
955                            .get("description")
956                            .and_then(|d| d.as_str())
957                            .unwrap_or("");
958
959                        // Filter by query
960                        let text = format!("{} {} {}", name, category, description).to_lowercase();
961                        if query.is_empty() || text.contains(&query_lower) {
962                            let mut metadata = HashMap::new();
963                            metadata.insert("category".to_string(), category.to_string());
964                            metadata.insert("path".to_string(), path.to_string());
965
966                            results.push(ProfileRef {
967                                id: format!("codex:{}@{}", name, channel.name),
968                                name: name.to_string(),
969                                owner: repo.split('/').next().unwrap_or("openai").to_string(),
970                                description: if description.is_empty() {
971                                    format!("Codex skill ({})", category.trim_start_matches('.'))
972                                } else {
973                                    description.to_string()
974                                },
975                                url: format!("https://github.com/{}/tree/main/{}", repo, path),
976                                stars: None,
977                                channel: channel.name.clone(),
978                                metadata,
979                            });
980                        }
981                    }
982                }
983            }
984        }
985
986        // Apply limit
987        if options.limit > 0 {
988            results.truncate(options.limit);
989        }
990
991        Ok(results)
992    }
993
994    /// Get Codex skills from cached catalog
995    pub fn get_codex_skills(&self, channel_name: &str) -> Result<Vec<CodexSkill>> {
996        let cache_dir = ChannelRegistry::cache_dir(&self.base_dir, channel_name);
997        let cache_file = cache_dir.join("codex_catalog.json");
998
999        if !cache_file.exists() {
1000            return Ok(Vec::new());
1001        }
1002
1003        let content = std::fs::read_to_string(&cache_file)?;
1004        let data: serde_json::Value =
1005            serde_json::from_str(&content).map_err(|e| DotAgentError::GitHubApiError {
1006                message: format!("Invalid codex_catalog.json: {}", e),
1007            })?;
1008
1009        let skills = data
1010            .get("skills")
1011            .and_then(|s| s.as_array())
1012            .ok_or_else(|| DotAgentError::GitHubApiError {
1013                message: "codex_catalog.json has no skills array".to_string(),
1014            })?;
1015
1016        let result: Vec<CodexSkill> = skills
1017            .iter()
1018            .filter_map(|s| serde_json::from_value(s.clone()).ok())
1019            .collect();
1020
1021        Ok(result)
1022    }
1023}
1024
1025#[cfg(test)]
1026mod tests {
1027    use super::*;
1028
1029    #[test]
1030    fn parse_awesome_line_basic() {
1031        let line =
1032            "- [dotbot](https://github.com/anishathalye/dotbot) - A tool for managing dotfiles";
1033        let result = ChannelManager::parse_awesome_line(line, "test");
1034
1035        assert!(result.is_some());
1036        let profile = result.unwrap();
1037        assert_eq!(profile.name, "dotbot");
1038        assert_eq!(profile.owner, "anishathalye");
1039        assert!(profile.description.contains("managing dotfiles"));
1040    }
1041
1042    #[test]
1043    fn parse_awesome_line_star() {
1044        let line = "* [stow](https://github.com/aspiers/stow) - Symlink farm manager";
1045        let result = ChannelManager::parse_awesome_line(line, "test");
1046
1047        assert!(result.is_some());
1048        let profile = result.unwrap();
1049        assert_eq!(profile.name, "stow");
1050    }
1051
1052    #[test]
1053    fn parse_awesome_line_no_description() {
1054        let line = "- [tool](https://github.com/user/tool)";
1055        let result = ChannelManager::parse_awesome_line(line, "test");
1056
1057        assert!(result.is_some());
1058        let profile = result.unwrap();
1059        assert_eq!(profile.name, "tool");
1060        assert!(profile.description.is_empty());
1061    }
1062
1063    #[test]
1064    fn parse_awesome_line_skip_non_http() {
1065        let line = "- [Section](#section)";
1066        let result = ChannelManager::parse_awesome_line(line, "test");
1067        assert!(result.is_none());
1068    }
1069
1070    #[test]
1071    fn to_raw_url_github() {
1072        let url = "https://github.com/webpro/awesome-dotfiles";
1073        let raw = ChannelManager::to_raw_url(url, "main");
1074        assert!(raw.contains("raw.githubusercontent.com"));
1075        assert!(raw.contains("README.md"));
1076        assert!(raw.contains("/main/"));
1077
1078        let raw_master = ChannelManager::to_raw_url(url, "master");
1079        assert!(raw_master.contains("/master/"));
1080    }
1081}