1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct CodexSkill {
19 pub name: String,
21 pub category: String,
23 pub path: String,
25 pub description: Option<String>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct MarketplacePlugin {
32 pub name: String,
34 pub description: Option<String>,
36 pub version: Option<String>,
38 pub source: serde_json::Value,
40 pub category: Option<String>,
42 pub keywords: Option<Vec<String>>,
44 pub author: Option<serde_json::Value>,
46 pub lsp_servers: Option<serde_json::Value>,
48 pub mcp_servers: Option<serde_json::Value>,
50 pub hooks: Option<serde_json::Value>,
52 pub commands: Option<serde_json::Value>,
54 pub agents: Option<serde_json::Value>,
56}
57
58impl MarketplacePlugin {
59 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 pub fn source_path(&self) -> Option<&str> {
101 self.source.as_str()
102 }
103
104 pub fn source_github_repo(&self) -> Option<&str> {
106 self.source.get("repo").and_then(|v| v.as_str())
107 }
108
109 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 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 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 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 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 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 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 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
194pub struct ChannelManager {
196 base_dir: PathBuf,
197 registry: ChannelRegistry,
198}
199
200impl ChannelManager {
201 pub fn new(base_dir: PathBuf) -> Result<Self> {
203 let registry = ChannelRegistry::load(&base_dir)?;
204 Ok(Self { base_dir, registry })
205 }
206
207 pub fn with_registry(base_dir: PathBuf, registry: ChannelRegistry) -> Self {
209 Self { base_dir, registry }
210 }
211
212 pub fn registry(&self) -> &ChannelRegistry {
214 &self.registry
215 }
216
217 pub fn registry_mut(&mut self) -> &mut ChannelRegistry {
219 &mut self.registry
220 }
221
222 pub fn save(&self) -> Result<()> {
224 self.registry.save(&self.base_dir)
225 }
226
227 pub fn search(&self, query: &str, options: &SearchOptions) -> Result<Vec<ProfileRef>> {
229 let mut results = Vec::new();
230
231 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 results.sort_by(|a, b| b.stars.cmp(&a.stars));
253
254 if options.limit > 0 {
256 results.truncate(options.limit);
257 }
258
259 Ok(results)
260 }
261
262 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 Ok(Vec::new())
277 }
278 }
279 }
280
281 fn search_github(
283 &self,
284 channel: &Channel,
285 query: &str,
286 options: &SearchOptions,
287 ) -> Result<Vec<ProfileRef>> {
288 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 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 if let Some(topic) = &options.topic {
308 args.push("--topic".to_string());
309 args.push(topic.clone());
310 }
311
312 args.push("--sort".to_string());
314 args.push(options.sort.clone().unwrap_or_else(|| "stars".to_string()));
315
316 let limit = if options.limit > 0 { options.limit } else { 10 };
318 args.push("--limit".to_string());
319 args.push(limit.to_string());
320
321 if let Some(min) = options.min_stars {
323 args.push("--stars".to_string());
324 args.push(format!(">={}", min));
325 }
326
327 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 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 let content = self.fetch_awesome_list(url, &channel.name)?;
398
399 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 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 if options.limit > 0 {
426 results.truncate(options.limit);
427 }
428
429 Ok(results)
430 }
431
432 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 if cache_file.exists() {
439 if let Ok(content) = std::fs::read_to_string(&cache_file) {
440 return Ok(content);
441 }
442 }
443
444 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 std::fs::create_dir_all(&cache_dir)?;
460 std::fs::write(&cache_file, &content)?;
461
462 Ok(content)
463 }
464
465 fn parse_awesome_line(line: &str, channel_name: &str) -> Option<ProfileRef> {
467 let line = line.trim();
470
471 if !line.starts_with('-') && !line.starts_with('*') {
472 return None;
473 }
474
475 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 if !url.starts_with("http://") && !url.starts_with("https://") {
490 return None;
491 }
492
493 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 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 fn extract_owner_from_url(url: &str) -> String {
522 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 fn to_raw_url(url: &str, branch: &str) -> String {
534 if url.contains("github.com") && !url.contains("raw.githubusercontent.com") {
535 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 fn normalize_repo(repo: &str) -> String {
549 let repo = repo.trim_end_matches('/');
550
551 if !repo.contains("://") && !repo.contains("github.com") {
553 return repo.to_string();
554 }
555
556 if let Some(pos) = repo.find("github.com/") {
559 let after = &repo[pos + 11..]; return after.to_string();
561 }
562
563 repo.to_string()
564 }
565
566 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 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 if !output.status.success() || stdout.contains("404") {
588 return Ok(None);
589 }
590
591 Ok(Some(stdout.to_string()))
592 }
593
594 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 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 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 fn fetch_marketplace_catalog(&self, repo: &str, channel_name: &str) -> Result<()> {
651 let repo = Self::normalize_repo(repo);
653
654 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 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 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 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 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 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 let cache_dir = ChannelRegistry::cache_dir(&self.base_dir, &channel.name);
748 let cache_file = cache_dir.join("marketplace.json");
749
750 if !cache_file.exists() {
752 self.fetch_marketplace_catalog(repo, &channel.name)?;
753 }
754
755 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 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 if options.limit > 0 {
804 results.truncate(options.limit);
805 }
806
807 Ok(results)
808 }
809
810 fn fetch_codex_catalog(&self, repo: &str, base_path: &str, channel_name: &str) -> Result<()> {
817 let repo = Self::normalize_repo(repo);
818
819 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 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 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 fn fetch_github_directory_contents(&self, repo: &str, path: &str) -> Result<Vec<String>> {
876 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 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 let cache_dir = ChannelRegistry::cache_dir(&self.base_dir, &channel.name);
930 let cache_file = cache_dir.join("codex_catalog.json");
931
932 if !cache_file.exists() {
934 self.fetch_codex_catalog(&repo, &base_path, &channel.name)?;
935 }
936
937 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 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 if options.limit > 0 {
988 results.truncate(options.limit);
989 }
990
991 Ok(results)
992 }
993
994 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}