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 MarketplacePlugin {
19 pub name: String,
21 pub description: Option<String>,
23 pub version: Option<String>,
25 pub source: serde_json::Value,
27 pub category: Option<String>,
29 pub keywords: Option<Vec<String>>,
31 pub author: Option<serde_json::Value>,
33 pub lsp_servers: Option<serde_json::Value>,
35 pub mcp_servers: Option<serde_json::Value>,
37 pub hooks: Option<serde_json::Value>,
39 pub commands: Option<serde_json::Value>,
41 pub agents: Option<serde_json::Value>,
43}
44
45impl MarketplacePlugin {
46 pub fn from_json(value: &serde_json::Value) -> Self {
48 Self {
49 name: value
50 .get("name")
51 .and_then(|v| v.as_str())
52 .unwrap_or_default()
53 .to_string(),
54 description: value
55 .get("description")
56 .and_then(|v| v.as_str())
57 .map(|s| s.to_string()),
58 version: value
59 .get("version")
60 .and_then(|v| v.as_str())
61 .map(|s| s.to_string()),
62 source: value.get("source").cloned().unwrap_or(serde_json::Value::Null),
63 category: value
64 .get("category")
65 .and_then(|v| v.as_str())
66 .map(|s| s.to_string()),
67 keywords: value.get("keywords").and_then(|v| {
68 v.as_array().map(|arr| {
69 arr.iter()
70 .filter_map(|k| k.as_str().map(|s| s.to_string()))
71 .collect()
72 })
73 }),
74 author: value.get("author").cloned(),
75 lsp_servers: value.get("lspServers").cloned(),
76 mcp_servers: value.get("mcpServers").cloned(),
77 hooks: value.get("hooks").cloned(),
78 commands: value.get("commands").cloned(),
79 agents: value.get("agents").cloned(),
80 }
81 }
82
83 pub fn source_path(&self) -> Option<&str> {
85 self.source.as_str()
86 }
87
88 pub fn source_github_repo(&self) -> Option<&str> {
90 self.source.get("repo").and_then(|v| v.as_str())
91 }
92
93 pub fn source_url(&self) -> Option<&str> {
95 if self.source.get("source").and_then(|v| v.as_str()) == Some("url") {
96 self.source.get("url").and_then(|v| v.as_str())
97 } else {
98 None
99 }
100 }
101
102 pub fn has_inline_config(&self) -> bool {
104 self.lsp_servers.is_some()
105 || self.mcp_servers.is_some()
106 || self.hooks.is_some()
107 || self.commands.is_some()
108 || self.agents.is_some()
109 }
110
111 pub fn write_config_files(&self, target_dir: &std::path::Path) -> Result<Vec<String>> {
113 use std::fs;
114
115 let mut written_files = Vec::new();
116
117 if let Some(lsp) = &self.lsp_servers {
119 let lsp_path = target_dir.join(".lsp.json");
120 let content = serde_json::to_string_pretty(lsp).map_err(|e| {
121 DotAgentError::GitHubApiError {
122 message: format!("Failed to serialize lspServers: {}", e),
123 }
124 })?;
125 fs::write(&lsp_path, content)?;
126 written_files.push(".lsp.json".to_string());
127 }
128
129 if let Some(mcp) = &self.mcp_servers {
131 let mcp_path = target_dir.join(".mcp.json");
132 let content = serde_json::to_string_pretty(mcp).map_err(|e| {
133 DotAgentError::GitHubApiError {
134 message: format!("Failed to serialize mcpServers: {}", e),
135 }
136 })?;
137 fs::write(&mcp_path, content)?;
138 written_files.push(".mcp.json".to_string());
139 }
140
141 if let Some(hooks) = &self.hooks {
143 let hooks_path = target_dir.join("hooks.json");
144 let content = serde_json::to_string_pretty(hooks).map_err(|e| {
145 DotAgentError::GitHubApiError {
146 message: format!("Failed to serialize hooks: {}", e),
147 }
148 })?;
149 fs::write(&hooks_path, content)?;
150 written_files.push("hooks.json".to_string());
151 }
152
153 if let Some(commands) = &self.commands {
155 let commands_path = target_dir.join("commands.json");
156 let content = serde_json::to_string_pretty(commands).map_err(|e| {
157 DotAgentError::GitHubApiError {
158 message: format!("Failed to serialize commands: {}", e),
159 }
160 })?;
161 fs::write(&commands_path, content)?;
162 written_files.push("commands.json".to_string());
163 }
164
165 if let Some(agents) = &self.agents {
167 let agents_path = target_dir.join("agents.json");
168 let content = serde_json::to_string_pretty(agents).map_err(|e| {
169 DotAgentError::GitHubApiError {
170 message: format!("Failed to serialize agents: {}", e),
171 }
172 })?;
173 fs::write(&agents_path, content)?;
174 written_files.push("agents.json".to_string());
175 }
176
177 Ok(written_files)
178 }
179}
180
181pub struct ChannelManager {
183 base_dir: PathBuf,
184 registry: ChannelRegistry,
185}
186
187impl ChannelManager {
188 pub fn new(base_dir: PathBuf) -> Result<Self> {
190 let registry = ChannelRegistry::load(&base_dir)?;
191 Ok(Self { base_dir, registry })
192 }
193
194 pub fn with_registry(base_dir: PathBuf, registry: ChannelRegistry) -> Self {
196 Self { base_dir, registry }
197 }
198
199 pub fn registry(&self) -> &ChannelRegistry {
201 &self.registry
202 }
203
204 pub fn registry_mut(&mut self) -> &mut ChannelRegistry {
206 &mut self.registry
207 }
208
209 pub fn save(&self) -> Result<()> {
211 self.registry.save(&self.base_dir)
212 }
213
214 pub fn search(&self, query: &str, options: &SearchOptions) -> Result<Vec<ProfileRef>> {
216 let mut results = Vec::new();
217
218 let channels: Vec<&Channel> = if options.channels.is_empty() {
220 self.registry.list_searchable()
221 } else {
222 self.registry
223 .list_enabled()
224 .into_iter()
225 .filter(|c| options.channels.contains(&c.name))
226 .collect()
227 };
228
229 for channel in channels {
230 match self.search_channel(channel, query, options) {
231 Ok(refs) => results.extend(refs),
232 Err(e) => {
233 eprintln!("Warning: {} search failed: {}", channel.name, e);
234 }
235 }
236 }
237
238 results.sort_by(|a, b| b.stars.cmp(&a.stars));
240
241 if options.limit > 0 {
243 results.truncate(options.limit);
244 }
245
246 Ok(results)
247 }
248
249 fn search_channel(
251 &self,
252 channel: &Channel,
253 query: &str,
254 options: &SearchOptions,
255 ) -> Result<Vec<ProfileRef>> {
256 match channel.channel_type {
257 ChannelType::GitHubGlobal => self.search_github(channel, query, options),
258 ChannelType::AwesomeList => self.search_awesome_list(channel, query, options),
259 ChannelType::Marketplace => self.search_marketplace(channel, query, options),
260 ChannelType::Hub | ChannelType::Direct => {
261 Ok(Vec::new())
263 }
264 }
265 }
266
267 fn search_github(
269 &self,
270 channel: &Channel,
271 query: &str,
272 options: &SearchOptions,
273 ) -> Result<Vec<ProfileRef>> {
274 if !Self::check_gh_available() {
276 return Err(DotAgentError::GitHubCliNotFound);
277 }
278
279 let mut args = vec!["search".to_string(), "repos".to_string()];
280
281 let search_query = if options.keywords.is_empty() {
283 query.to_string()
284 } else {
285 format!("{} {}", query, options.keywords.join(" "))
286 };
287
288 if !search_query.is_empty() {
289 args.push(search_query);
290 }
291
292 if let Some(topic) = &options.topic {
294 args.push("--topic".to_string());
295 args.push(topic.clone());
296 }
297
298 args.push("--sort".to_string());
300 args.push(options.sort.clone().unwrap_or_else(|| "stars".to_string()));
301
302 let limit = if options.limit > 0 { options.limit } else { 10 };
304 args.push("--limit".to_string());
305 args.push(limit.to_string());
306
307 if let Some(min) = options.min_stars {
309 args.push("--stars".to_string());
310 args.push(format!(">={}", min));
311 }
312
313 args.push("--json".to_string());
315 args.push("name,owner,description,url,stargazersCount".to_string());
316
317 let output = Command::new("gh").args(&args).output().map_err(|e| {
318 if e.kind() == std::io::ErrorKind::NotFound {
319 DotAgentError::GitHubCliNotFound
320 } else {
321 DotAgentError::Io(e)
322 }
323 })?;
324
325 if !output.status.success() {
326 let stderr = String::from_utf8_lossy(&output.stderr);
327 return Err(DotAgentError::GitHubApiError {
328 message: stderr.to_string(),
329 });
330 }
331
332 let json: serde_json::Value =
333 serde_json::from_slice(&output.stdout).unwrap_or(serde_json::Value::Array(vec![]));
334
335 let repos = json
336 .as_array()
337 .ok_or_else(|| DotAgentError::GitHubApiError {
338 message: "Invalid JSON response".to_string(),
339 })?;
340
341 let mut results = Vec::new();
342
343 for repo in repos {
344 let name = repo["name"].as_str().unwrap_or("").to_string();
345 let owner = repo["owner"]["login"].as_str().unwrap_or("").to_string();
346 let description = repo["description"]
347 .as_str()
348 .unwrap_or("No description")
349 .to_string();
350 let url = repo["url"].as_str().unwrap_or("").to_string();
351 let stars = repo["stargazersCount"].as_u64();
352
353 let id = format!("github:{}/{}", owner, name);
354
355 results.push(ProfileRef {
356 id,
357 name,
358 owner,
359 description,
360 url,
361 stars,
362 channel: channel.name.clone(),
363 metadata: HashMap::new(),
364 });
365 }
366
367 Ok(results)
368 }
369
370 fn search_awesome_list(
372 &self,
373 channel: &Channel,
374 query: &str,
375 options: &SearchOptions,
376 ) -> Result<Vec<ProfileRef>> {
377 let url = match channel.source.url() {
378 Some(u) => u,
379 None => return Ok(Vec::new()),
380 };
381
382 let content = self.fetch_awesome_list(url, &channel.name)?;
384
385 let query_lower = query.to_lowercase();
387 let keywords: Vec<String> = options.keywords.iter().map(|k| k.to_lowercase()).collect();
388
389 let mut results = Vec::new();
390
391 for line in content.lines() {
392 if let Some(profile_ref) = Self::parse_awesome_line(line, &channel.name) {
393 let text = format!(
395 "{} {} {}",
396 profile_ref.name, profile_ref.owner, profile_ref.description
397 )
398 .to_lowercase();
399
400 let matches_query = query.is_empty() || text.contains(&query_lower);
401 let matches_keywords =
402 keywords.is_empty() || keywords.iter().all(|k| text.contains(k));
403
404 if matches_query && matches_keywords {
405 results.push(profile_ref);
406 }
407 }
408 }
409
410 if options.limit > 0 {
412 results.truncate(options.limit);
413 }
414
415 Ok(results)
416 }
417
418 fn fetch_awesome_list(&self, url: &str, channel_name: &str) -> Result<String> {
420 let cache_dir = ChannelRegistry::cache_dir(&self.base_dir, channel_name);
421 let cache_file = cache_dir.join("content.md");
422
423 if cache_file.exists() {
425 if let Ok(content) = std::fs::read_to_string(&cache_file) {
426 return Ok(content);
427 }
428 }
429
430 let content = if let Some(c) = Self::fetch_url(&Self::to_raw_url(url, "main"))? {
432 c
433 } else if let Some(c) = Self::fetch_url(&Self::to_raw_url(url, "master"))? {
434 c
435 } else {
436 return Err(DotAgentError::GitHubApiError {
437 message: format!("Failed to fetch README from: {} (tried main and master)", url),
438 });
439 };
440
441 std::fs::create_dir_all(&cache_dir)?;
443 std::fs::write(&cache_file, &content)?;
444
445 Ok(content)
446 }
447
448 fn parse_awesome_line(line: &str, channel_name: &str) -> Option<ProfileRef> {
450 let line = line.trim();
453
454 if !line.starts_with('-') && !line.starts_with('*') {
455 return None;
456 }
457
458 let start_bracket = line.find('[')?;
460 let end_bracket = line.find(']')?;
461 let start_paren = line.find('(')?;
462 let end_paren = line.find(')')?;
463
464 if end_bracket >= start_paren || start_paren >= end_paren {
465 return None;
466 }
467
468 let name = &line[start_bracket + 1..end_bracket];
469 let url = &line[start_paren + 1..end_paren];
470
471 if !url.starts_with("http://") && !url.starts_with("https://") {
473 return None;
474 }
475
476 let desc_start = end_paren + 1;
478 let description = if desc_start < line.len() {
479 let desc = &line[desc_start..];
480 let desc = desc.trim_start_matches([' ', '-']);
481 desc.trim().to_string()
482 } else {
483 String::new()
484 };
485
486 let owner = Self::extract_owner_from_url(url);
488
489 let id = format!("awesome:{}#{}", channel_name, name);
490
491 Some(ProfileRef {
492 id,
493 name: name.to_string(),
494 owner,
495 description,
496 url: url.to_string(),
497 stars: None,
498 channel: channel_name.to_string(),
499 metadata: HashMap::new(),
500 })
501 }
502
503 fn extract_owner_from_url(url: &str) -> String {
505 if url.contains("github.com") {
507 let parts: Vec<&str> = url.split('/').collect();
508 if parts.len() >= 4 {
509 return parts[3].to_string();
510 }
511 }
512 "unknown".to_string()
513 }
514
515 fn to_raw_url(url: &str, branch: &str) -> String {
517 if url.contains("github.com") && !url.contains("raw.githubusercontent.com") {
518 let url = url
520 .replace("github.com", "raw.githubusercontent.com")
521 .trim_end_matches('/')
522 .to_string();
523 format!("{}/{}/README.md", url, branch)
524 } else {
525 url.to_string()
526 }
527 }
528
529 fn normalize_repo(repo: &str) -> String {
532 let repo = repo.trim_end_matches('/');
533
534 if !repo.contains("://") && !repo.contains("github.com") {
536 return repo.to_string();
537 }
538
539 if let Some(pos) = repo.find("github.com/") {
542 let after = &repo[pos + 11..]; return after.to_string();
544 }
545
546 repo.to_string()
547 }
548
549 fn fetch_url(url: &str) -> Result<Option<String>> {
551 let output = Command::new("curl")
552 .args(["-sL", "-w", "%{http_code}", "-o", "-", url])
553 .output()
554 .map_err(DotAgentError::Io)?;
555
556 let stdout = String::from_utf8_lossy(&output.stdout);
557
558 if stdout.len() >= 3 {
560 let (content, status) = stdout.split_at(stdout.len() - 3);
561 if status == "404" {
562 return Ok(None);
563 }
564 if status.starts_with('2') {
565 return Ok(Some(content.to_string()));
566 }
567 }
568
569 if !output.status.success() || stdout.contains("404") {
571 return Ok(None);
572 }
573
574 Ok(Some(stdout.to_string()))
575 }
576
577 fn check_gh_available() -> bool {
579 Command::new("gh")
580 .arg("--version")
581 .output()
582 .map(|o| o.status.success())
583 .unwrap_or(false)
584 }
585
586 pub fn refresh_channel(&self, channel_name: &str) -> Result<()> {
588 let channel =
589 self.registry
590 .get(channel_name)
591 .ok_or_else(|| DotAgentError::ChannelNotFound {
592 name: channel_name.to_string(),
593 })?;
594
595 match &channel.source {
596 ChannelSource::Marketplace { repo } => {
597 self.fetch_marketplace_catalog(repo, channel_name)?;
598 }
599 _ => {
600 if let Some(url) = channel.source.url() {
601 let content =
603 if let Some(c) = Self::fetch_url(&Self::to_raw_url(url, "main"))? {
604 c
605 } else if let Some(c) = Self::fetch_url(&Self::to_raw_url(url, "master"))? {
606 c
607 } else {
608 return Err(DotAgentError::GitHubApiError {
609 message: format!(
610 "Failed to fetch README from: {} (tried main and master)",
611 url
612 ),
613 });
614 };
615
616 let cache_dir = ChannelRegistry::cache_dir(&self.base_dir, channel_name);
617 std::fs::create_dir_all(&cache_dir)?;
618 std::fs::write(cache_dir.join("content.md"), content)?;
619 }
620 }
621 }
622
623 Ok(())
624 }
625
626 fn fetch_marketplace_catalog(&self, repo: &str, channel_name: &str) -> Result<()> {
631 let repo = Self::normalize_repo(repo);
633
634 let content = if let Some(c) = Self::fetch_url(&format!(
636 "https://raw.githubusercontent.com/{}/main/.claude-plugin/marketplace.json",
637 repo
638 ))? {
639 c
640 } else if let Some(c) = Self::fetch_url(&format!(
641 "https://raw.githubusercontent.com/{}/master/.claude-plugin/marketplace.json",
642 repo
643 ))? {
644 c
645 } else {
646 return Err(DotAgentError::GitHubApiError {
647 message: format!(
648 "Failed to fetch marketplace.json from: {} (tried main and master)",
649 repo
650 ),
651 });
652 };
653
654 let data: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
656 DotAgentError::GitHubApiError {
657 message: format!("Invalid marketplace.json: {}", e),
658 }
659 })?;
660
661 if data.get("name").is_none() || data.get("plugins").is_none() {
663 return Err(DotAgentError::GitHubApiError {
664 message: "marketplace.json missing required fields (name, plugins)".to_string(),
665 });
666 }
667
668 let cache_dir = ChannelRegistry::cache_dir(&self.base_dir, channel_name);
670 std::fs::create_dir_all(&cache_dir)?;
671 std::fs::write(cache_dir.join("marketplace.json"), content)?;
672
673 Ok(())
674 }
675
676 pub fn get_marketplace_plugin(
678 &self,
679 channel_name: &str,
680 plugin_name: &str,
681 ) -> Result<Option<MarketplacePlugin>> {
682 let cache_dir = ChannelRegistry::cache_dir(&self.base_dir, channel_name);
683 let cache_file = cache_dir.join("marketplace.json");
684
685 if !cache_file.exists() {
686 return Ok(None);
687 }
688
689 let content = std::fs::read_to_string(&cache_file)?;
690 let data: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
691 DotAgentError::GitHubApiError {
692 message: format!("Invalid marketplace.json: {}", e),
693 }
694 })?;
695
696 let plugins = data
697 .get("plugins")
698 .and_then(|p| p.as_array())
699 .ok_or_else(|| DotAgentError::GitHubApiError {
700 message: "marketplace.json has no plugins array".to_string(),
701 })?;
702
703 for plugin in plugins {
704 let name = plugin.get("name").and_then(|n| n.as_str()).unwrap_or("");
705 if name == plugin_name {
706 return Ok(Some(MarketplacePlugin::from_json(plugin)));
707 }
708 }
709
710 Ok(None)
711 }
712
713 fn search_marketplace(
715 &self,
716 channel: &Channel,
717 query: &str,
718 options: &SearchOptions,
719 ) -> Result<Vec<ProfileRef>> {
720 let repo = match &channel.source {
721 ChannelSource::Marketplace { repo } => repo,
722 _ => return Ok(Vec::new()),
723 };
724
725 let query_lower = query.to_lowercase();
726 let mut results = Vec::new();
727
728 let cache_dir = ChannelRegistry::cache_dir(&self.base_dir, &channel.name);
730 let cache_file = cache_dir.join("marketplace.json");
731
732 if !cache_file.exists() {
734 self.fetch_marketplace_catalog(repo, &channel.name)?;
735 }
736
737 if let Ok(content) = std::fs::read_to_string(&cache_file) {
739 if let Ok(data) = serde_json::from_str::<serde_json::Value>(&content) {
740 if let Some(plugins) = data.get("plugins").and_then(|p| p.as_array()) {
741 for plugin in plugins {
742 let name = plugin
743 .get("name")
744 .and_then(|n| n.as_str())
745 .unwrap_or_default();
746 let description = plugin
747 .get("description")
748 .and_then(|d| d.as_str())
749 .unwrap_or_default();
750 let version = plugin
751 .get("version")
752 .and_then(|v| v.as_str())
753 .unwrap_or("unknown");
754 let category = plugin
755 .get("category")
756 .and_then(|c| c.as_str())
757 .unwrap_or("");
758
759 let text = format!("{} {} {}", name, description, category).to_lowercase();
761 if query.is_empty() || text.contains(&query_lower) {
762 let mut metadata = HashMap::new();
763 metadata.insert("version".to_string(), version.to_string());
764 if !category.is_empty() {
765 metadata.insert("category".to_string(), category.to_string());
766 }
767
768 results.push(ProfileRef {
769 id: format!("marketplace:{}@{}", name, channel.name),
770 name: name.to_string(),
771 owner: repo.split('/').next().unwrap_or("unknown").to_string(),
772 description: description.to_string(),
773 url: format!("https://github.com/{}", repo),
774 stars: None,
775 channel: channel.name.clone(),
776 metadata,
777 });
778 }
779 }
780 }
781 }
782 }
783
784 if options.limit > 0 {
786 results.truncate(options.limit);
787 }
788
789 Ok(results)
790 }
791}
792
793#[cfg(test)]
794mod tests {
795 use super::*;
796
797 #[test]
798 fn parse_awesome_line_basic() {
799 let line =
800 "- [dotbot](https://github.com/anishathalye/dotbot) - A tool for managing dotfiles";
801 let result = ChannelManager::parse_awesome_line(line, "test");
802
803 assert!(result.is_some());
804 let profile = result.unwrap();
805 assert_eq!(profile.name, "dotbot");
806 assert_eq!(profile.owner, "anishathalye");
807 assert!(profile.description.contains("managing dotfiles"));
808 }
809
810 #[test]
811 fn parse_awesome_line_star() {
812 let line = "* [stow](https://github.com/aspiers/stow) - Symlink farm manager";
813 let result = ChannelManager::parse_awesome_line(line, "test");
814
815 assert!(result.is_some());
816 let profile = result.unwrap();
817 assert_eq!(profile.name, "stow");
818 }
819
820 #[test]
821 fn parse_awesome_line_no_description() {
822 let line = "- [tool](https://github.com/user/tool)";
823 let result = ChannelManager::parse_awesome_line(line, "test");
824
825 assert!(result.is_some());
826 let profile = result.unwrap();
827 assert_eq!(profile.name, "tool");
828 assert!(profile.description.is_empty());
829 }
830
831 #[test]
832 fn parse_awesome_line_skip_non_http() {
833 let line = "- [Section](#section)";
834 let result = ChannelManager::parse_awesome_line(line, "test");
835 assert!(result.is_none());
836 }
837
838 #[test]
839 fn to_raw_url_github() {
840 let url = "https://github.com/webpro/awesome-dotfiles";
841 let raw = ChannelManager::to_raw_url(url, "main");
842 assert!(raw.contains("raw.githubusercontent.com"));
843 assert!(raw.contains("README.md"));
844 assert!(raw.contains("/main/"));
845
846 let raw_master = ChannelManager::to_raw_url(url, "master");
847 assert!(raw_master.contains("/master/"));
848 }
849}