"""Click-based CLI — all user-facing commands.""" from __future__ import annotations import sys from typing import Optional import click from rich import box from rich.console import Console from rich.panel import Panel from rich.table import Table from . import __version__ from .config import Config from .curseforge import ( CurseForgeAuthError, CurseForgeClient, CurseForgeError, CurseForgeNotFoundError, ) from .database import Database from .notifier import DiscordNotifier, NotificationError console = Console() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _load_db(cfg: Config) -> Database: return Database(cfg.database_path) def _require_client(cfg: Config) -> CurseForgeClient: """Return a configured API client, or exit with a helpful message.""" if not cfg.curseforge_api_key: console.print("[red]Error:[/red] No CurseForge API key configured.") console.print( "Set one with: [bold]modpack-checker config set-key YOUR_KEY[/bold]" ) console.print( "Get a free key at: [dim]https://console.curseforge.com[/dim]" ) sys.exit(1) return CurseForgeClient(cfg.curseforge_api_key) # --------------------------------------------------------------------------- # Root group # --------------------------------------------------------------------------- @click.group(context_settings={"help_option_names": ["-h", "--help"]}) @click.version_option(version=__version__, prog_name="modpack-checker") def cli() -> None: """Modpack Version Checker — monitor CurseForge modpacks for updates. \b Quick start: modpack-checker config set-key YOUR_CURSEFORGE_KEY modpack-checker add 238222 modpack-checker check """ # --------------------------------------------------------------------------- # config sub-group # --------------------------------------------------------------------------- @cli.group("config") def config_group() -> None: """Manage configuration (API key, webhook URL, etc.).""" @config_group.command("set-key") @click.argument("api_key") def config_set_key(api_key: str) -> None: """Save your CurseForge API key and validate it.""" cfg = Config.load() cfg.curseforge_api_key = api_key.strip() cfg.save() client = CurseForgeClient(api_key) with console.status("Validating API key with CurseForge…"): valid = client.validate_api_key() if valid: console.print("[green]✓[/green] API key saved and validated.") else: console.print( "[yellow]⚠[/yellow] API key saved, but validation failed. " "Double-check at [dim]https://console.curseforge.com[/dim]" ) @config_group.command("set-webhook") @click.argument("webhook_url") def config_set_webhook(webhook_url: str) -> None: """Save a Discord webhook URL and send a test message.""" cfg = Config.load() cfg.discord_webhook_url = webhook_url.strip() cfg.save() notifier = DiscordNotifier(webhook_url) try: with console.status("Testing webhook…"): notifier.test() console.print("[green]✓[/green] Webhook saved and test message sent.") except NotificationError as exc: console.print(f"[yellow]⚠[/yellow] Webhook saved, but test failed: {exc}") @config_group.command("set-interval") @click.argument("hours", type=int) def config_set_interval(hours: int) -> None: """Set how often the scheduler checks for updates (in hours, 1–168).""" if not 1 <= hours <= 168: console.print("[red]Error:[/red] Interval must be between 1 and 168 hours.") sys.exit(1) cfg = Config.load() cfg.check_interval_hours = hours cfg.save() console.print(f"[green]✓[/green] Check interval set to {hours} hour(s).") @config_group.command("show") def config_show() -> None: """Display the current configuration.""" cfg = Config.load() key = cfg.curseforge_api_key if key and len(key) > 8: masked = f"{key[:4]}{'*' * (len(key) - 8)}{key[-4:]}" elif key: masked = "****" else: masked = "[red]Not configured[/red]" table = Table(title="Configuration", box=box.ROUNDED, show_header=False) table.add_column("Setting", style="cyan", min_width=22) table.add_column("Value") table.add_row("CurseForge API Key", masked) table.add_row( "Discord Webhook", cfg.discord_webhook_url or "[dim]Not configured[/dim]", ) table.add_row("Database", cfg.database_path) table.add_row("Check Interval", f"{cfg.check_interval_hours} hour(s)") table.add_row( "Notifications", "[green]On[/green]" if cfg.notification_on_update else "[red]Off[/red]", ) console.print(table) # --------------------------------------------------------------------------- # add # --------------------------------------------------------------------------- @cli.command() @click.argument("modpack_id", type=int) def add(modpack_id: int) -> None: """Add a modpack to the watch list by its CurseForge project ID. \b Example: modpack-checker add 238222 # All The Mods 9 """ cfg = Config.load() client = _require_client(cfg) db = _load_db(cfg) with console.status(f"Looking up modpack {modpack_id} on CurseForge…"): try: name = client.get_mod_name(modpack_id) except CurseForgeNotFoundError: console.print( f"[red]Error:[/red] No modpack found with ID {modpack_id} on CurseForge." ) sys.exit(1) except CurseForgeAuthError as exc: console.print(f"[red]Auth error:[/red] {exc}") sys.exit(1) except CurseForgeError as exc: console.print(f"[red]API error:[/red] {exc}") sys.exit(1) try: db.add_modpack(modpack_id, name) except ValueError: console.print( f"[yellow]Already tracked:[/yellow] [bold]{name}[/bold] (ID: {modpack_id})" ) return console.print( f"[green]✓[/green] Now tracking [bold]{name}[/bold] (ID: {modpack_id})" ) console.print("Run [bold]modpack-checker check[/bold] to fetch the current version.") # --------------------------------------------------------------------------- # remove # --------------------------------------------------------------------------- @cli.command() @click.argument("modpack_id", type=int) @click.confirmation_option(prompt="Remove this modpack and all its history?") def remove(modpack_id: int) -> None: """Remove a modpack from the watch list.""" cfg = Config.load() db = _load_db(cfg) modpack = db.get_modpack(modpack_id) if modpack is None: console.print( f"[red]Error:[/red] Modpack ID {modpack_id} is not in your watch list." ) sys.exit(1) db.remove_modpack(modpack_id) console.print(f"[green]✓[/green] Removed [bold]{modpack.name}[/bold].") # --------------------------------------------------------------------------- # list # --------------------------------------------------------------------------- @cli.command(name="list") def list_modpacks() -> None: """Show all watched modpacks and their last known versions.""" cfg = Config.load() db = _load_db(cfg) modpacks = db.get_all_modpacks() if not modpacks: console.print("[dim]Watch list is empty.[/dim]") console.print( "Add a modpack with: [bold]modpack-checker add [/bold]" ) return table = Table( title=f"Watched Modpacks ({len(modpacks)})", box=box.ROUNDED, ) table.add_column("CF ID", style="cyan", justify="right", no_wrap=True) table.add_column("Name", style="bold white") table.add_column("Current Version", style="green") table.add_column("Last Checked", style="dim") table.add_column("Alerts", justify="center") for mp in modpacks: last_checked = ( mp.last_checked.strftime("%Y-%m-%d %H:%M") if mp.last_checked else "[dim]Never[/dim]" ) alerts = "[green]✓[/green]" if mp.notification_enabled else "[red]✗[/red]" version = mp.current_version or "[dim]—[/dim]" table.add_row(str(mp.curseforge_id), mp.name, version, last_checked, alerts) console.print(table) # --------------------------------------------------------------------------- # check # --------------------------------------------------------------------------- @cli.command() @click.option( "--id", "-m", "modpack_id", type=int, default=None, help="Check a single modpack by CurseForge ID.", ) @click.option( "--notify/--no-notify", default=True, help="Send Discord notifications for updates found (default: on).", ) def check(modpack_id: Optional[int], notify: bool) -> None: """Check all (or one) watched modpack(s) for updates.""" cfg = Config.load() client = _require_client(cfg) db = _load_db(cfg) if modpack_id is not None: target = db.get_modpack(modpack_id) if target is None: console.print( f"[red]Error:[/red] Modpack ID {modpack_id} is not in your watch list." ) sys.exit(1) modpacks = [target] else: modpacks = db.get_all_modpacks() if not modpacks: console.print("[dim]No modpacks to check.[/dim]") console.print( "Add one with: [bold]modpack-checker add [/bold]" ) return notifier: Optional[DiscordNotifier] = None if notify and cfg.discord_webhook_url and cfg.notification_on_update: notifier = DiscordNotifier(cfg.discord_webhook_url) updates = 0 errors = 0 for mp in modpacks: with console.status(f"Checking [bold]{mp.name}[/bold]…"): try: file_obj = client.get_latest_file(mp.curseforge_id) except CurseForgeNotFoundError: console.print( f" [red]✗[/red] {mp.name}: not found on CurseForge " f"(ID: {mp.curseforge_id})" ) errors += 1 continue except CurseForgeError as exc: console.print(f" [red]✗[/red] {mp.name}: API error — {exc}") errors += 1 continue if file_obj is None: console.print(f" [yellow]⚠[/yellow] {mp.name}: no files found on CurseForge.") errors += 1 continue new_version = client.extract_version(file_obj) notification_sent = False if mp.current_version == new_version: line = f"[green]✓[/green] {mp.name}: up to date ([bold]{new_version}[/bold])" elif mp.current_version is None: line = ( f"[cyan]→[/cyan] {mp.name}: " f"initial version recorded as [bold]{new_version}[/bold]" ) else: updates += 1 line = ( f"[yellow]↑[/yellow] {mp.name}: " f"[dim]{mp.current_version}[/dim] → [bold green]{new_version}[/bold green]" ) if notifier and mp.notification_enabled: try: notifier.send_update( mp.name, mp.curseforge_id, mp.current_version, new_version ) notification_sent = True line += " [dim](notified)[/dim]" except NotificationError as exc: line += f" [red](notification failed: {exc})[/red]" db.update_version(mp.curseforge_id, new_version, notification_sent) console.print(f" {line}") # Summary line console.print() parts = [] if updates: parts.append(f"[yellow]{updates} update(s) found[/yellow]") if errors: parts.append(f"[red]{errors} error(s)[/red]") if not updates and not errors: parts.append("[green]All modpacks are up to date.[/green]") console.print(" ".join(parts)) # --------------------------------------------------------------------------- # status # --------------------------------------------------------------------------- @cli.command() @click.argument("modpack_id", type=int) @click.option("--limit", "-n", default=10, show_default=True, help="History entries to show.") def status(modpack_id: int, limit: int) -> None: """Show detailed status and check history for a modpack.""" cfg = Config.load() db = _load_db(cfg) mp = db.get_modpack(modpack_id) if mp is None: console.print( f"[red]Error:[/red] Modpack ID {modpack_id} is not in your watch list." ) sys.exit(1) last_checked = ( mp.last_checked.strftime("%Y-%m-%d %H:%M UTC") if mp.last_checked else "Never" ) notif_str = "[green]Enabled[/green]" if mp.notification_enabled else "[red]Disabled[/red]" console.print( Panel( f"[bold white]{mp.name}[/bold white]\n" f"CurseForge ID : [cyan]{mp.curseforge_id}[/cyan]\n" f"Version : [green]{mp.current_version or 'Not checked yet'}[/green]\n" f"Last Checked : [dim]{last_checked}[/dim]\n" f"Notifications : {notif_str}", title="Modpack Status", border_style="cyan", ) ) history = db.get_check_history(modpack_id, limit=limit) if not history: console.print("[dim]No check history yet.[/dim]") return table = Table(title=f"Check History (last {limit})", box=box.SIMPLE) table.add_column("Timestamp", style="dim") table.add_column("Version", style="green") table.add_column("Notified", justify="center") for entry in history: notified = "[green]✓[/green]" if entry.notification_sent else "[dim]—[/dim]" table.add_row( entry.checked_at.strftime("%Y-%m-%d %H:%M"), entry.version_found or "[dim]Unknown[/dim]", notified, ) console.print(table) # --------------------------------------------------------------------------- # notifications (toggle per-modpack alerts) # --------------------------------------------------------------------------- @cli.command() @click.argument("modpack_id", type=int) @click.option( "--enable/--disable", default=True, help="Enable or disable Discord alerts for this modpack.", ) def notifications(modpack_id: int, enable: bool) -> None: """Enable or disable Discord notifications for a specific modpack.""" cfg = Config.load() db = _load_db(cfg) mp = db.get_modpack(modpack_id) if mp is None: console.print( f"[red]Error:[/red] Modpack ID {modpack_id} is not in your watch list." ) sys.exit(1) db.toggle_notifications(modpack_id, enable) state = "[green]enabled[/green]" if enable else "[red]disabled[/red]" console.print( f"[green]✓[/green] Notifications {state} for [bold]{mp.name}[/bold]." ) # --------------------------------------------------------------------------- # schedule (background daemon) # --------------------------------------------------------------------------- @cli.command() @click.option( "--hours", "-h", "hours", type=int, default=None, help="Override the configured check interval (hours).", ) def schedule(hours: Optional[int]) -> None: """Run continuous background checks on a configurable interval. Requires the [scheduler] extra: pip install modpack-version-checker[scheduler] """ try: from apscheduler.schedulers.blocking import BlockingScheduler except ImportError: console.print("[red]Error:[/red] APScheduler is not installed.") console.print( "Install it with: [bold]pip install modpack-version-checker[scheduler][/bold]" ) sys.exit(1) cfg = Config.load() interval = hours or cfg.check_interval_hours def _run_check() -> None: """Inner function executed by the scheduler.""" client = _require_client(cfg) db = _load_db(cfg) modpacks = db.get_all_modpacks() notifier: Optional[DiscordNotifier] = None if cfg.discord_webhook_url and cfg.notification_on_update: notifier = DiscordNotifier(cfg.discord_webhook_url) for mp in modpacks: try: file_obj = client.get_latest_file(mp.curseforge_id) if file_obj is None: continue new_version = client.extract_version(file_obj) notification_sent = False if ( mp.current_version is not None and mp.current_version != new_version and notifier and mp.notification_enabled ): try: notifier.send_update( mp.name, mp.curseforge_id, mp.current_version, new_version ) notification_sent = True except NotificationError: pass db.update_version(mp.curseforge_id, new_version, notification_sent) except CurseForgeError: pass # Log silently in daemon mode; don't crash the scheduler scheduler = BlockingScheduler() scheduler.add_job(_run_check, "interval", hours=interval) console.print( Panel( f"Checking every [bold]{interval}[/bold] hour(s).\n" "Press [bold]Ctrl-C[/bold] to stop.", title="Modpack Checker — Scheduler Running", border_style="green", ) ) # Run immediately so the user gets instant feedback _run_check() try: scheduler.start() except KeyboardInterrupt: console.print("\n[dim]Scheduler stopped.[/dim]") # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- def main() -> None: cli() if __name__ == "__main__": main()