"""Main CLI for Steam Backlog Enforcer.""" from __future__ import annotations import logging import sys import time from typing import TYPE_CHECKING from python_pkg.steam_backlog_enforcer._cmd_done import cmd_done from python_pkg.steam_backlog_enforcer._enforce_loop import ( do_enforce, get_all_owned_app_ids, ) from python_pkg.steam_backlog_enforcer._hltb_types import load_hltb_cache from python_pkg.steam_backlog_enforcer._whitelist import ( WHITELIST_COOLDOWN_SECONDS, add_pending_exception, list_pending_exceptions, validate_reason, ) from python_pkg.steam_backlog_enforcer.config import ( Config, State, interactive_setup, load_snapshot, ) from python_pkg.steam_backlog_enforcer.game_install import ( _echo, get_installed_games, install_game, is_game_installed, is_protected_app, uninstall_other_games, ) from python_pkg.steam_backlog_enforcer.library_hider import ( hide_other_games, restart_steam, unhide_all_games, ) from python_pkg.steam_backlog_enforcer.scanning import ( do_check, do_scan, pick_next_game, ) from python_pkg.steam_backlog_enforcer.steam_api import GameInfo from python_pkg.steam_backlog_enforcer.store_blocker import ( block_store, is_store_blocked, unblock_store, ) if TYPE_CHECKING: from collections.abc import Callable logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) logger = logging.getLogger(__name__) _LIST_DISPLAY_LIMIT = 50 _MIN_CLI_ARGS = 2 # ────────────────────────────────────────────────────────────── # CLI commands # ────────────────────────────────────────────────────────────── def cmd_status(_config: Config, state: State) -> None: """Show current status.""" _echo("=== Steam Backlog Enforcer ===\n") if state.current_app_id: _echo( f"Assigned game: {state.current_game_name} (AppID={state.current_app_id})" ) else: _echo("No game currently assigned.") _echo(f"Finished games: {len(state.finished_app_ids)}") _echo(f"Store blocked: {is_store_blocked()}") # Show installed games. installed = get_installed_games() real_games = [(aid, n) for aid, n in installed if not is_protected_app(aid)] _echo(f"Installed games: {len(real_games)}") if state.current_app_id: is_assigned_installed = any(aid == state.current_app_id for aid, _ in installed) _echo(f"Assigned game installed: {is_assigned_installed}") def cmd_list(_config: Config, state: State) -> None: """List games from the last snapshot.""" snapshot = load_snapshot() if snapshot is None: _echo("No snapshot found. Run 'scan' first.") return games = [GameInfo.from_snapshot(d) for d in snapshot] incomplete = [g for g in games if not g.is_complete] complete = [g for g in games if g.is_complete] # Sort incomplete by completionist hours. def sort_key(g: GameInfo) -> tuple[int, float]: if g.completionist_hours > 0: return (0, g.completionist_hours) return (1, 0.0) incomplete.sort(key=sort_key) _echo(f"\n{'─' * 70}") _echo(f" INCOMPLETE ({len(incomplete)} games)") _echo(f"{'─' * 70}") for i, g in enumerate(incomplete[:_LIST_DISPLAY_LIMIT], 1): marker = " <<< ASSIGNED" if g.app_id == state.current_app_id else "" hrs = f" [{g.completionist_hours:.0f}h]" if g.completionist_hours > 0 else "" pct = f"{g.completion_pct:.0f}%" _echo(f" {i:3d}. {g.name[:40]:<40s} {pct:>5s}{hrs}{marker}") if len(incomplete) > _LIST_DISPLAY_LIMIT: _echo(f" ... and {len(incomplete) - _LIST_DISPLAY_LIMIT} more") _echo(f"\n COMPLETE: {len(complete)} games") def cmd_unblock(_config: Config, _state: State) -> None: """Remove store blocking.""" if unblock_store(): _echo("Steam store unblocked.") else: _echo("Failed to unblock. Run with sudo.") def cmd_buy_dlc(config: Config, state: State) -> None: """Temporarily unblock the store so the user can buy DLC.""" if state.current_app_id is None: _echo("No game currently assigned.") return _echo(f"Current game: {state.current_game_name} (AppID={state.current_app_id})") _echo("Unblocking Steam store for DLC purchase...") if not unblock_store(): _echo("Failed to unblock store. Run with sudo.") return _echo("\nStore UNBLOCKED — buy your DLC now.") _echo("Press Enter when you're done to re-block the store...") input() if config.block_store: if block_store(): _echo("Store re-blocked. Restarting Steam to clear DNS cache...") restart_steam() _echo("Done.") else: _echo("Warning: failed to re-block store.") def cmd_reset(config: Config, state: State) -> None: """Reset all state (unblock, unhide, clear assignment).""" unblock_store() # Unhide all games in the library. try: owned = get_all_owned_app_ids(config) if owned: count = unhide_all_games(owned) if count: _echo(f"Unhidden {count} games.") except (OSError, RuntimeError, ValueError) as exc: _echo(f"Warning: could not unhide games: {exc}") state.current_app_id = None state.current_game_name = "" state.finished_app_ids = [] state.save() _echo("State reset. Store unblocked.") def cmd_installed(_config: Config, state: State) -> None: """Show installed games.""" installed = get_installed_games() _echo(f"\nInstalled games ({len(installed)}):\n") for app_id, name in installed: protected = " [PROTECTED]" if is_protected_app(app_id) else "" assigned = " <<< ASSIGNED" if app_id == state.current_app_id else "" _echo(f" {app_id:>8d} {name}{protected}{assigned}") def cmd_uninstall(_config: Config, state: State) -> None: """Uninstall all games except the assigned one.""" if state.current_app_id is None: _echo("No game assigned. Run 'scan' first.") return installed = get_installed_games() to_remove = [ (aid, n) for aid, n in installed if aid != state.current_app_id and not is_protected_app(aid) ] if not to_remove: _echo("No games to uninstall (only assigned game and runtimes installed).") return _echo(f"\nWill uninstall {len(to_remove)} games, keeping:") _echo(f" - {state.current_game_name} (AppID={state.current_app_id})") _echo(" - Steam runtimes and Proton versions\n") _echo("Games to remove:") for aid, name in to_remove: _echo(f" - {name} (AppID={aid})") _echo() confirm = input("Type YES to confirm: ").strip() if confirm != "YES": _echo("Aborted.") return count = uninstall_other_games(state.current_app_id) _echo(f"\nUninstalled {count} games.") def cmd_setup(_config: Config, _state: State) -> None: """Run interactive setup.""" interactive_setup() _MIN_ADD_EXCEPTION_ARGS = 3 _ADD_EXCEPTION_USAGE = ( 'Usage: add-exception --reason ""\n' " app_id : numeric Steam application ID\n" " --reason : genuine justification (>= 5 words)\n\n" "Example:\n" " add-exception 440 --reason " '"TF2 is needed for a community event this weekend"\n\n' f"Exceptions become active after a {WHITELIST_COOLDOWN_SECONDS // 3600}h " "cooldown." ) def cmd_add_exception(args: list[str]) -> None: """Request a time-locked whitelist exception. Usage: add-exception --reason "" The exception becomes active after a 24-hour cooldown. The reason must be a genuine justification of at least 5 words with sufficient entropy. Args: args: CLI argument list after the command name. """ if len(args) < _MIN_ADD_EXCEPTION_ARGS or "--reason" not in args: _echo(_ADD_EXCEPTION_USAGE) sys.exit(1) try: app_id = int(args[0]) except ValueError: _echo(f"Error: app_id must be a number, got '{args[0]}'.") sys.exit(1) reason_idx = args.index("--reason") reason_parts = args[reason_idx + 1 :] if not reason_parts: _echo("Error: --reason requires a value.") sys.exit(1) reason = " ".join(reason_parts) # Show validation feedback before attempting to add. err = validate_reason(reason) if err is not None: _echo(f"Invalid reason: {err}") sys.exit(1) try: msg = add_pending_exception(app_id, reason) except ValueError as exc: _echo(f"Error: {exc}") sys.exit(1) _echo(msg) # Show current pending list. pending = list_pending_exceptions() if pending: _echo(f"\nPending exceptions ({len(pending)}):") now = time.time() for entry in pending: aid = int(entry["app_id"]) elapsed = now - float(entry["requested_at"]) remaining = max(0.0, WHITELIST_COOLDOWN_SECONDS - elapsed) hrs = int(remaining // 3600) mins = int((remaining % 3600) // 60) status = "ready" if remaining == 0.0 else f"approves in {hrs}h {mins}m" _echo(f" AppID={aid} [{status}]") def cmd_install(config: Config, state: State) -> None: """Manually trigger install of the assigned game.""" if state.current_app_id is None: _echo("No game currently assigned. Run 'scan' first.") return if is_game_installed(state.current_app_id): _echo(f"{state.current_game_name} is already installed.") return _echo(f"Installing {state.current_game_name} (AppID={state.current_app_id})...") if install_game( state.current_app_id, state.current_game_name, config.steam_id, use_steam_protocol=True, ): _echo("Done!") else: _echo("Failed to create install manifest.") def cmd_hide(config: Config, state: State) -> None: """Hide all non-assigned games in the Steam library.""" if state.current_app_id is None: _echo("No game assigned. Run 'scan' first.") return owned_ids = get_all_owned_app_ids(config) if not owned_ids: _echo("No owned game list available. Run 'scan' first.") return _echo(f"Hiding all games except {state.current_game_name}...") hidden = hide_other_games(owned_ids, state.current_app_id) _echo(f"Hidden {hidden} games.") if hidden > 0: _echo("Done! Only the assigned game should be visible in your library.") def cmd_unhide(config: Config, _state: State) -> None: """Unhide all games in the Steam library.""" owned_ids = get_all_owned_app_ids(config) if not owned_ids: _echo("No owned game list available. Run 'scan' first.") return _echo("Unhiding all games...") count = unhide_all_games(owned_ids) _echo(f"Unhidden {count} games.") if count > 0: _echo("Done!") def cmd_pick(config: Config, state: State) -> None: """Manually pick a new game from the shortest-first candidate list.""" snapshot_data = load_snapshot() if not snapshot_data: _echo("No snapshot found. Run 'scan' first.") return games = [GameInfo.from_snapshot(d) for d in snapshot_data] hltb_cache = load_hltb_cache() for game in games: if game.app_id in hltb_cache: game.completionist_hours = hltb_cache[game.app_id] pick_next_game(games, state, config) if state.current_app_id is not None: owned_ids = get_all_owned_app_ids(config) if owned_ids: hidden = hide_other_games(owned_ids, state.current_app_id) if hidden > 0: _echo(f"\n Library: hid {hidden} games") COMMANDS: dict[str, tuple[str, Callable[[Config, State], object]]] = { "scan": ("Scan library & assign a game", do_scan), "check": ("Check assigned game completion", do_check), "status": ("Show current status", cmd_status), "list": ("List games from snapshot", cmd_list), "enforce": ("Run enforcer: block, uninstall, kill, hide", do_enforce), "install": ("Install the assigned game", cmd_install), "hide": ("Hide all non-assigned games in library", cmd_hide), "unhide": ("Unhide all games in library", cmd_unhide), "unblock": ("Remove store blocking", cmd_unblock), "buy-dlc": ("Temporarily unblock store to buy DLC", cmd_buy_dlc), "reset": ("Reset all state", cmd_reset), "installed": ("List installed games", cmd_installed), "uninstall": ("Uninstall all non-assigned games", cmd_uninstall), "setup": ("Run first-time setup", cmd_setup), "done": ("Finish game, open HLTB, pick next", cmd_done), "pick": ("Manually pick your next game from candidates", cmd_pick), } # Extra commands with non-standard arg handling (shown in help but not in COMMANDS). _EXTRA_COMMAND_DESCRIPTIONS: dict[str, str] = { "add-exception": "Request 24h-locked whitelist exception (use --reason)", } _ALL_COMMANDS: dict[str, str] = { name: desc for name, (desc, _) in COMMANDS.items() } | _EXTRA_COMMAND_DESCRIPTIONS def main() -> None: """CLI entry point.""" if len(sys.argv) < _MIN_CLI_ARGS or sys.argv[1] not in _ALL_COMMANDS: _echo("Steam Backlog Enforcer\n") _echo("Usage: python -m python_pkg.steam_backlog_enforcer.main \n") _echo("Commands:") for name, desc in _ALL_COMMANDS.items(): _echo(f" {name:<14s} {desc}") sys.exit(1) command = sys.argv[1] # add-exception has its own argument structure; handle before config load. if command == "add-exception": cmd_add_exception(sys.argv[2:]) return config = Config.load() if command != "setup" and not config.steam_api_key: _echo("Not configured. Run 'setup' first.") sys.exit(1) state = State.load() _, func = COMMANDS[command] func(config, state) if __name__ == "__main__": main()