from __future__ import annotations import argparse import sys from pathlib import Path from syncgames.config import ( AgentConfig, GameConfig, default_config_root, list_games, load_agent_config, load_game, slugify, write_game_toml, ) from syncgames.errors import SyncGamesError from syncgames.lease import break_lease from syncgames.session import ( ensure_game_remote, end_session, restore_session, start_session, status_text, ) from syncgames.store import build_store from syncgames.watchers import watch_loop def _cfg(args: argparse.Namespace) -> AgentConfig: root = Path(args.config_root).expanduser() if getattr(args, "config_root", None) else None return load_agent_config(root) def cmd_add(args: argparse.Namespace) -> int: cfg = _cfg(args) store = build_store(cfg) gid = args.id or slugify(args.name) paths = args.paths or [] if not paths: raise SyncGamesError("At least one --paths is required", key="config_error") procs = args.process_name or [] game = GameConfig( id=gid, name=args.name, platform=args.platform, paths=paths, versions_to_keep=args.versions, steam_appid=args.steam_appid, process_names=procs, ) path = write_game_toml(cfg, game) ensure_game_remote(store, game) print(f"Added game {gid} → {path}") return 0 def cmd_remove(args: argparse.Namespace) -> int: cfg = _cfg(args) store = build_store(cfg) game = load_game(cfg, args.game_id) if args.purge_remote: confirm = input(f"Type {game.id} to permanently purge remote: ") if confirm != game.id: print("Aborted") return 1 # still retire first for safety unless --purge-remote means delete dest = store.retire_game(game.id) print(f"Retired then leaving retired/ in place at {dest} (manual purge from console)") else: dest = store.retire_game(game.id) print(f"Remote moved to {dest}") src = cfg.games_dir / f"{game.id}.toml" if src.exists(): src.rename(src.with_suffix(".toml.removed")) print(f"Local config renamed for {game.id}") return 0 def cmd_edit(args: argparse.Namespace) -> int: """Update an existing game.toml; game id stays stable for MinIO prefixes.""" cfg = _cfg(args) game = load_game(cfg, args.game_id) if args.name: game.name = args.name if args.platform: game.platform = args.platform if args.paths: game.paths = args.paths if args.add_path: for p in args.add_path: if p not in game.paths: game.paths.append(p) if args.versions is not None: game.versions_to_keep = args.versions if args.steam_appid is not None: game.steam_appid = args.steam_appid if args.process_name is not None: game.process_names = args.process_name path = write_game_toml(cfg, game) print(f"Updated {game.id} → {path}") for p in game.paths: print(f" path: {p}") return 0 def cmd_start(args: argparse.Namespace) -> int: cfg = _cfg(args) store = build_store(cfg) game = load_game(cfg, args.game_id) meta = start_session(cfg, store, game) print(f"Started session {game.id}; lease={meta.lease}; live_hash={meta.live_hash}") return 0 def cmd_end(args: argparse.Namespace) -> int: cfg = _cfg(args) store = build_store(cfg) game = load_game(cfg, args.game_id) meta = end_session( cfg, store, game, force=args.force, force_hash=args.force_restore ) print(f"Ended session {game.id}; new live_hash={meta.live_hash}") return 0 def cmd_status(args: argparse.Namespace) -> int: cfg = _cfg(args) store = build_store(cfg) game = load_game(cfg, args.game_id) if args.game_id else None print(status_text(cfg, store, game)) return 0 def cmd_history(args: argparse.Namespace) -> int: cfg = _cfg(args) store = build_store(cfg) game = load_game(cfg, args.game_id) for item in store.list_history(game.id): print(item) return 0 def cmd_restore(args: argparse.Namespace) -> int: cfg = _cfg(args) store = build_store(cfg) game = load_game(cfg, args.game_id) confirm = input( f"Restore {game.id} live from history {args.from_} ? Type YES: " ) if confirm != "YES": print("Aborted") return 1 meta = restore_session(cfg, store, game, args.from_) print(f"Restored {args.from_}; live_hash={meta.live_hash}. Lease held — end when done.") return 0 def cmd_doctor(args: argparse.Namespace) -> int: cfg = _cfg(args) store = build_store(cfg) print(f"device_id={cfg.device_id}") print(f"store={cfg.store}") print(f"config_root={cfg.config_root}") print(f"state_root={cfg.state_root}") if args.break_lease: game = load_game(cfg, args.break_lease) break_lease(store, game.id) print(f"Broke lease for {game.id}") try: digest = store.probe() print(f"probe_ok hash={digest}") except Exception as e: # noqa: BLE001 print(f"probe_FAILED: {e}", file=sys.stderr) return 1 for g in list_games(cfg): meta = store.get_meta(g.id) print(f"game {g.id}: meta={'yes' if meta else 'missing'}") return 0 def cmd_export_ssot(args: argparse.Namespace) -> int: cfg = _cfg(args) store = build_store(cfg) out = Path(args.out).expanduser().resolve() store.export_ssot(out) print(f"Exported SSOT to {out}") return 0 def cmd_watch(args: argparse.Namespace) -> int: cfg = _cfg(args) store = build_store(cfg) game = load_game(cfg, args.game_id) def on_start() -> None: print(f"[watch] start {game.id}") try: start_session(cfg, store, game) except SyncGamesError as e: print(f"[watch] start skipped: {e}", file=sys.stderr) def on_end() -> None: print(f"[watch] end {game.id}") try: end_session(cfg, store, game) except SyncGamesError as e: print(f"[watch] end failed: {e}", file=sys.stderr) print(f"Watching {game.id} (Ctrl+C to stop)") watch_loop(game, on_start, on_end) return 0 def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(prog="syncgames", description="SyncGames session agent") p.add_argument( "--config-root", default=None, help=f"Config directory (default {default_config_root()})", ) sub = p.add_subparsers(dest="cmd", required=True) add = sub.add_parser("add", help="Declare a new game") add.add_argument("--name", required=True) add.add_argument("--id", default=None, help="Slug override") add.add_argument("--platform", default="steam", choices=["steam", "eden", "yuzu", "other"]) add.add_argument("--paths", action="append", default=[]) add.add_argument("--versions", type=int, default=5) add.add_argument("--steam-appid", type=int, default=None) add.add_argument("--process-name", action="append", default=[]) add.set_defaults(func=cmd_add) rem = sub.add_parser("remove", help="Retire a game") rem.add_argument("game_id") rem.add_argument("--purge-remote", action="store_true") rem.set_defaults(func=cmd_remove) ed = sub.add_parser("edit", help="Update an existing game config (id stays stable)") ed.add_argument("game_id") ed.add_argument("--name", default=None) ed.add_argument("--platform", default=None, choices=["steam", "eden", "yuzu", "other"]) ed.add_argument("--paths", action="append", default=None, help="Replace all paths") ed.add_argument("--add-path", action="append", default=None, help="Append a path") ed.add_argument("--versions", type=int, default=None) ed.add_argument("--steam-appid", type=int, default=None) ed.add_argument("--process-name", action="append", default=None) ed.set_defaults(func=cmd_edit) st = sub.add_parser("start", help="Pull SSOT and acquire lease") st.add_argument("game_id") st.set_defaults(func=cmd_start) en = sub.add_parser("end", help="Push WIP and release lease") en.add_argument("game_id") en.add_argument("--force", action="store_true", help="Allow end while process still seen") en.add_argument( "--force-restore", action="store_true", help="Bypass hash gate (dangerous)", ) en.set_defaults(func=cmd_end) su = sub.add_parser("status", help="Show leases and sessions") su.add_argument("game_id", nargs="?") su.set_defaults(func=cmd_status) hi = sub.add_parser("history", help="List remote history prefixes") hi.add_argument("game_id") hi.set_defaults(func=cmd_history) rs = sub.add_parser("restore", help="Promote history → live") rs.add_argument("game_id") rs.add_argument("--from", dest="from_", required=True, help="device/ts") rs.set_defaults(func=cmd_restore) doc = sub.add_parser("doctor", help="Connectivity + config probe") doc.add_argument("--break-lease", metavar="GAME_ID", default=None) doc.set_defaults(func=cmd_doctor) ex = sub.add_parser("export-ssot", help="Export bucket/tree for Path 1 fallback") ex.add_argument("--out", required=True) ex.set_defaults(func=cmd_export_ssot) wa = sub.add_parser("watch", help="Auto start/end around process lifetime") wa.add_argument("game_id") wa.set_defaults(func=cmd_watch) gui = sub.add_parser("gui", help="Open thin setup / management GUI") gui.set_defaults(func=cmd_gui) return p def cmd_gui(_args: argparse.Namespace) -> int: from syncgames.gui.app import main as gui_main gui_main() return 0 def main(argv: list[str] | None = None) -> None: parser = build_parser() args = parser.parse_args(argv) try: code = args.func(args) except SyncGamesError as e: print(f"error[{e.key}]: {e}", file=sys.stderr) raise SystemExit(2) from e raise SystemExit(code) if __name__ == "__main__": main()