Skip to content

Commit 1c35d37

Browse files
committed
feat(plugins): add fetch, browse commands and marketplace type support
- Add 'cam plugin fetch' command to detect repo type (plugin/marketplace) from GitHub - Add 'cam plugin browse' command to list plugins in a configured marketplace - Add 'type' field to PluginRepo model (plugin or marketplace) - Add user plugin repos management (save fetched repos to ~/.config/code-assistant-manager/plugin_repos.json) - Update 'cam plugin repos' to show both builtin and user repos with (user) tag - Update 'cam plugin install' to handle marketplace type repos - Fix scan_marketplace_plugins to recursively scan category subdirectories - Add claude-code-plugins-plus marketplace to builtin repos
1 parent e6a3bb5 commit 1c35d37

7 files changed

Lines changed: 641 additions & 35 deletions

File tree

code_assistant_manager/cli/plugin_commands.py

Lines changed: 326 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
BUILTIN_PLUGIN_REPOS,
1515
VALID_APP_TYPES,
1616
PluginManager,
17+
PluginRepo,
18+
fetch_repo_info_from_url,
19+
parse_github_url,
1720
)
1821
from code_assistant_manager.plugins.claude import ClaudePluginHandler
1922

@@ -165,7 +168,7 @@ def marketplace_update(
165168
def install_plugin(
166169
plugin: str = typer.Argument(
167170
...,
168-
help="Plugin name or plugin@marketplace",
171+
help="Plugin name, plugin@marketplace, or marketplace name",
169172
),
170173
marketplace: Optional[str] = typer.Option(
171174
None,
@@ -174,26 +177,54 @@ def install_plugin(
174177
help="Marketplace name (alternative to plugin@marketplace format)",
175178
),
176179
):
177-
"""Install a plugin from available marketplaces."""
180+
"""Install a plugin from available marketplaces or add a built-in marketplace."""
178181
_check_claude_cli()
179182
handler = _get_handler()
183+
manager = PluginManager()
180184

181-
# Check if it's a built-in repo that needs marketplace added first
182-
builtin = BUILTIN_PLUGIN_REPOS.get(plugin)
183-
if builtin and builtin.repo_owner and builtin.repo_name:
184-
# Check if marketplace already exists
185+
# Check if it's a configured repo (user repos take precedence over builtin)
186+
configured_repo = manager.get_repo(plugin)
187+
if configured_repo and configured_repo.repo_owner and configured_repo.repo_name:
188+
repo_url = f"https://github.com/{configured_repo.repo_owner}/{configured_repo.repo_name}"
189+
190+
# Handle marketplace type - just add the marketplace
191+
if configured_repo.type == "marketplace":
192+
typer.echo(f"{Colors.CYAN}Adding marketplace: {plugin}...{Colors.RESET}")
193+
success, msg = handler.marketplace_add(repo_url)
194+
if success:
195+
typer.echo(f"{Colors.GREEN}✓ Marketplace added: {plugin}{Colors.RESET}")
196+
typer.echo(
197+
f"\n{Colors.CYAN}Browse plugins with:{Colors.RESET} cam plugin search --marketplace {plugin}"
198+
)
199+
typer.echo(
200+
f"{Colors.CYAN}Install plugins with:{Colors.RESET} cam plugin install <plugin-name>@{plugin}"
201+
)
202+
elif "already installed" in msg.lower():
203+
typer.echo(
204+
f"{Colors.YELLOW}Marketplace '{plugin}' is already installed.{Colors.RESET}"
205+
)
206+
typer.echo(
207+
f"\n{Colors.CYAN}Browse plugins with:{Colors.RESET} cam plugin search --marketplace {plugin}"
208+
)
209+
else:
210+
typer.echo(
211+
f"{Colors.RED}✗ Failed to add marketplace: {msg}{Colors.RESET}"
212+
)
213+
raise typer.Exit(1)
214+
return
215+
216+
# Handle plugin type - add marketplace first if needed, then install plugin
185217
known_marketplaces = handler.get_known_marketplaces()
186218
marketplace_exists = any(
187-
builtin.repo_owner.lower() in name.lower()
188-
or builtin.repo_name.lower() in name.lower()
219+
configured_repo.repo_owner.lower() in name.lower()
220+
or configured_repo.repo_name.lower() in name.lower()
189221
for name in known_marketplaces
190222
)
191223

192224
if not marketplace_exists:
193225
typer.echo(
194-
f"{Colors.CYAN}Adding marketplace for built-in plugin: {plugin}...{Colors.RESET}"
226+
f"{Colors.CYAN}Adding marketplace for plugin: {plugin}...{Colors.RESET}"
195227
)
196-
repo_url = f"https://github.com/{builtin.repo_owner}/{builtin.repo_name}"
197228
success, msg = handler.marketplace_add(repo_url)
198229
if not success and "already installed" not in msg.lower():
199230
typer.echo(
@@ -377,21 +408,32 @@ def list_plugins(
377408

378409
@plugin_app.command("repos")
379410
def list_repos():
380-
"""List available built-in plugin repositories."""
381-
if not BUILTIN_PLUGIN_REPOS:
411+
"""List available plugin repositories and marketplaces (built-in + user)."""
412+
manager = PluginManager()
413+
414+
# Get all repos (builtin + user)
415+
all_repos = manager.get_all_repos()
416+
user_repos = manager.get_user_repos()
417+
418+
if not all_repos:
419+
typer.echo(f"{Colors.YELLOW}No plugin repositories available.{Colors.RESET}")
382420
typer.echo(
383-
f"{Colors.YELLOW}No built-in plugin repositories available.{Colors.RESET}"
421+
f"\n{Colors.CYAN}Add a repo with:{Colors.RESET} cam plugin fetch <github-url> --save"
384422
)
385423
return
386424

387-
typer.echo(f"\n{Colors.BOLD}Built-in Plugin Repositories:{Colors.RESET}\n")
388-
for name, repo in sorted(BUILTIN_PLUGIN_REPOS.items()):
425+
# Separate plugins and marketplaces
426+
plugins = {k: v for k, v in all_repos.items() if v.type == "plugin"}
427+
marketplaces = {k: v for k, v in all_repos.items() if v.type == "marketplace"}
428+
429+
def _print_repo(name: str, repo: PluginRepo, is_user: bool = False):
389430
status = (
390431
f"{Colors.GREEN}{Colors.RESET}"
391432
if repo.enabled
392433
else f"{Colors.RED}{Colors.RESET}"
393434
)
394-
typer.echo(f"{status} {Colors.BOLD}{name}{Colors.RESET}")
435+
user_tag = f" {Colors.YELLOW}(user){Colors.RESET}" if is_user else ""
436+
typer.echo(f"{status} {Colors.BOLD}{name}{Colors.RESET}{user_tag}")
395437
if repo.description:
396438
typer.echo(f" {Colors.CYAN}Description:{Colors.RESET} {repo.description}")
397439
if repo.repo_owner and repo.repo_name:
@@ -400,7 +442,274 @@ def list_repos():
400442
)
401443
typer.echo()
402444

403-
typer.echo(f"{Colors.CYAN}Install with:{Colors.RESET} cam plugin install <name>")
445+
if plugins:
446+
typer.echo(f"\n{Colors.BOLD}Plugins:{Colors.RESET}\n")
447+
for name, repo in sorted(plugins.items()):
448+
_print_repo(name, repo, name in user_repos)
449+
typer.echo(
450+
f"{Colors.CYAN}Install with:{Colors.RESET} cam plugin install <name>"
451+
)
452+
453+
if marketplaces:
454+
typer.echo(f"\n{Colors.BOLD}Marketplaces:{Colors.RESET}\n")
455+
for name, repo in sorted(marketplaces.items()):
456+
_print_repo(name, repo, name in user_repos)
457+
typer.echo(
458+
f"{Colors.CYAN}Add marketplace with:{Colors.RESET} cam plugin install <marketplace-name>"
459+
)
460+
461+
typer.echo(
462+
f"\n{Colors.CYAN}Add new repo:{Colors.RESET} cam plugin fetch <github-url> --save"
463+
)
464+
typer.echo()
465+
466+
467+
@plugin_app.command("browse")
468+
def browse_marketplace(
469+
marketplace: str = typer.Argument(
470+
...,
471+
help="Marketplace name to browse (from 'cam plugin repos')",
472+
),
473+
query: Optional[str] = typer.Option(
474+
None,
475+
"--query",
476+
"-q",
477+
help="Filter plugins by name or description",
478+
),
479+
category: Optional[str] = typer.Option(
480+
None,
481+
"--category",
482+
"-c",
483+
help="Filter plugins by category",
484+
),
485+
limit: int = typer.Option(
486+
50,
487+
"--limit",
488+
"-n",
489+
help="Maximum number of plugins to show",
490+
),
491+
):
492+
"""Browse plugins in a configured marketplace.
493+
494+
Fetches the marketplace manifest from GitHub and lists all available plugins.
495+
Use --query to search by name/description, --category to filter by category.
496+
"""
497+
manager = PluginManager()
498+
499+
# Get the marketplace repo config
500+
repo = manager.get_repo(marketplace)
501+
if not repo:
502+
typer.echo(
503+
f"{Colors.RED}✗ Marketplace '{marketplace}' not found.{Colors.RESET}"
504+
)
505+
typer.echo(f"\n{Colors.CYAN}Available repos:{Colors.RESET}")
506+
for name in manager.get_all_repos():
507+
typer.echo(f" • {name}")
508+
raise typer.Exit(1)
509+
510+
if repo.type != "marketplace":
511+
typer.echo(
512+
f"{Colors.RED}✗ '{marketplace}' is a plugin, not a marketplace.{Colors.RESET}"
513+
)
514+
typer.echo(
515+
f"\n{Colors.CYAN}To install:{Colors.RESET} cam plugin install {marketplace}"
516+
)
517+
raise typer.Exit(1)
518+
519+
typer.echo(f"{Colors.CYAN}Fetching plugins from {marketplace}...{Colors.RESET}")
520+
521+
# Fetch marketplace info
522+
if not repo.repo_owner or not repo.repo_name:
523+
typer.echo(f"{Colors.RED}✗ Marketplace missing repo info.{Colors.RESET}")
524+
raise typer.Exit(1)
525+
526+
from code_assistant_manager.plugins.fetch import fetch_repo_info
527+
528+
info = fetch_repo_info(repo.repo_owner, repo.repo_name, repo.repo_branch)
529+
if not info or not info.plugins:
530+
typer.echo(f"{Colors.RED}✗ Could not fetch marketplace plugins.{Colors.RESET}")
531+
raise typer.Exit(1)
532+
533+
# Filter plugins
534+
plugins = info.plugins
535+
if query:
536+
query_lower = query.lower()
537+
plugins = [
538+
p
539+
for p in plugins
540+
if query_lower in p.get("name", "").lower()
541+
or query_lower in p.get("description", "").lower()
542+
]
543+
544+
if category:
545+
category_lower = category.lower()
546+
plugins = [
547+
p for p in plugins if category_lower in p.get("category", "").lower()
548+
]
549+
550+
# Display results
551+
total = len(plugins)
552+
plugins = plugins[:limit]
553+
554+
typer.echo(
555+
f"\n{Colors.BOLD}{info.name}{Colors.RESET} - {info.description or 'No description'}"
556+
)
557+
if info.version:
558+
typer.echo(f"Version: {info.version}")
559+
typer.echo(f"Total plugins: {info.plugin_count}")
560+
561+
if query or category:
562+
typer.echo(f"Matching: {total}")
563+
564+
typer.echo(f"\n{Colors.BOLD}Plugins:{Colors.RESET}\n")
565+
566+
# Get unique categories for reference
567+
categories = set()
568+
for p in info.plugins:
569+
if p.get("category"):
570+
categories.add(p["category"])
571+
572+
for p in plugins:
573+
name = p.get("name", "unknown")
574+
version = p.get("version", "")
575+
desc = p.get("description", "")
576+
cat = p.get("category", "")
577+
578+
version_str = f" v{version}" if version else ""
579+
cat_str = f" [{cat}]" if cat else ""
580+
581+
typer.echo(
582+
f" {Colors.BOLD}{name}{Colors.RESET}{version_str}{Colors.CYAN}{cat_str}{Colors.RESET}"
583+
)
584+
if desc:
585+
# Truncate long descriptions
586+
if len(desc) > 80:
587+
desc = desc[:77] + "..."
588+
typer.echo(f" {desc}")
589+
590+
if total > limit:
591+
typer.echo(f"\n ... and {total - limit} more")
592+
593+
# Show categories if available
594+
if categories:
595+
typer.echo(
596+
f"\n{Colors.CYAN}Categories:{Colors.RESET} {', '.join(sorted(categories))}"
597+
)
598+
599+
typer.echo(
600+
f"\n{Colors.CYAN}Install with:{Colors.RESET} cam plugin install <plugin-name>@{marketplace}"
601+
)
602+
typer.echo()
603+
604+
605+
@plugin_app.command("fetch")
606+
def fetch_repo(
607+
url: str = typer.Argument(
608+
...,
609+
help="GitHub URL or owner/repo (e.g., https://github.com/owner/repo or owner/repo)",
610+
),
611+
save: bool = typer.Option(
612+
False,
613+
"--save",
614+
"-s",
615+
help="Save the fetched repo to user config",
616+
),
617+
):
618+
"""Fetch and detect repo type (plugin or marketplace) from GitHub.
619+
620+
Analyzes a GitHub repository to determine if it's a single plugin
621+
or a marketplace with multiple plugins, then optionally saves it
622+
to your local configuration.
623+
"""
624+
typer.echo(f"{Colors.CYAN}Fetching repository info...{Colors.RESET}")
625+
626+
# Parse and validate URL
627+
parsed = parse_github_url(url)
628+
if not parsed:
629+
typer.echo(f"{Colors.RED}✗ Invalid GitHub URL: {url}{Colors.RESET}")
630+
raise typer.Exit(1)
631+
632+
owner, repo, branch = parsed
633+
typer.echo(f" Repository: {Colors.BOLD}{owner}/{repo}{Colors.RESET}")
634+
635+
# Fetch repo info
636+
info = fetch_repo_info_from_url(url)
637+
if not info:
638+
typer.echo(
639+
f"{Colors.RED}✗ Could not fetch repository info. "
640+
f"Make sure the repo has .claude-plugin/marketplace.json{Colors.RESET}"
641+
)
642+
raise typer.Exit(1)
643+
644+
# Display results
645+
typer.echo(f"\n{Colors.BOLD}Repository Information:{Colors.RESET}\n")
646+
typer.echo(f" {Colors.CYAN}Name:{Colors.RESET} {info.name}")
647+
typer.echo(f" {Colors.CYAN}Type:{Colors.RESET} {info.type}")
648+
typer.echo(f" {Colors.CYAN}Description:{Colors.RESET} {info.description or 'N/A'}")
649+
typer.echo(f" {Colors.CYAN}Branch:{Colors.RESET} {info.branch}")
650+
651+
if info.version:
652+
typer.echo(f" {Colors.CYAN}Version:{Colors.RESET} {info.version}")
653+
654+
if info.type == "marketplace":
655+
typer.echo(f" {Colors.CYAN}Plugin Count:{Colors.RESET} {info.plugin_count}")
656+
if info.plugins and len(info.plugins) <= 10:
657+
typer.echo(f"\n {Colors.CYAN}Plugins:{Colors.RESET}")
658+
for p in info.plugins[:10]:
659+
typer.echo(f" • {p.get('name', 'unknown')}")
660+
elif info.plugins:
661+
typer.echo(f"\n {Colors.CYAN}Plugins:{Colors.RESET} (showing first 10)")
662+
for p in info.plugins[:10]:
663+
typer.echo(f" • {p.get('name', 'unknown')}")
664+
typer.echo(f" ... and {len(info.plugins) - 10} more")
665+
else:
666+
if info.plugin_path:
667+
typer.echo(f" {Colors.CYAN}Plugin Path:{Colors.RESET} {info.plugin_path}")
668+
669+
# Save if requested
670+
if save:
671+
manager = PluginManager()
672+
673+
# Check if already exists
674+
existing = manager.get_repo(info.name)
675+
if existing:
676+
typer.echo(
677+
f"\n{Colors.YELLOW}Repository '{info.name}' already exists in config.{Colors.RESET}"
678+
)
679+
if not typer.confirm("Overwrite?"):
680+
raise typer.Exit(0)
681+
682+
# Create PluginRepo and save
683+
plugin_repo = PluginRepo(
684+
name=info.name,
685+
description=info.description,
686+
repo_owner=info.owner,
687+
repo_name=info.repo,
688+
repo_branch=info.branch,
689+
plugin_path=info.plugin_path,
690+
type=info.type,
691+
enabled=True,
692+
)
693+
manager.add_user_repo(plugin_repo)
694+
typer.echo(
695+
f"\n{Colors.GREEN}✓ Saved '{info.name}' to user config as {info.type}{Colors.RESET}"
696+
)
697+
typer.echo(f" Config file: {manager.plugin_repos_file}")
698+
699+
# Show next steps
700+
if info.type == "marketplace":
701+
typer.echo(
702+
f"\n{Colors.CYAN}Next:{Colors.RESET} cam plugin install {info.name}"
703+
)
704+
else:
705+
typer.echo(
706+
f"\n{Colors.CYAN}Next:{Colors.RESET} cam plugin install {info.name}"
707+
)
708+
else:
709+
typer.echo(
710+
f"\n{Colors.CYAN}To save:{Colors.RESET} cam plugin fetch '{url}' --save"
711+
)
712+
404713
typer.echo()
405714

406715

0 commit comments

Comments
 (0)