7f0ab9a635
One `logging_enabled` toggle (default off) gates everything (D25): - core/applog.py: rotating app.log (no-op unless enabled); setup() at GUI/CLI start. - core/diagstore.py: each diagnostic stored in DATA_DIR/diagnostics/<id>/ (capture, result.json, report.txt, scoped gamelogs, ai/ records of exactly what was sent to the model + which model + the reply). make_report() zips a diagnostic (+ app.log) into DATA_DIR/reports/. - diagnostic.finish()/analyze_crash() store when enabled; DiagnosticResult.dir. - GUI: Settings → Logging toggle; "Report" button on the diagnostic dialog; AI interactions recorded into the diagnostic dir on "Explain with AI". - CLI: `rigdoctor bundle` (report is taken by the M4 health report). - Tests for store/record_ai/make_report + applog gating; docs (D25, M15, Phase 8). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
722 lines
27 KiB
Python
722 lines
27 KiB
Python
"""RigDoctor command-line interface."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import signal
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from . import __version__, config
|
|
from .config import load_config
|
|
from .core import reccontrol
|
|
from .core.sampler import Sampler
|
|
from .core.sources import available_sources
|
|
from .render import format_headline, render_snapshot, render_summary
|
|
|
|
|
|
def _sampler() -> Sampler:
|
|
return Sampler(available_sources())
|
|
|
|
|
|
def cmd_sources(args) -> int:
|
|
srcs = available_sources()
|
|
if not srcs:
|
|
print("No sensor sources detected.")
|
|
return 1
|
|
print("Detected sources:")
|
|
for s in srcs:
|
|
print(f" - {s.name} ({type(s).__name__})")
|
|
return 0
|
|
|
|
|
|
def cmd_snapshot(args) -> int:
|
|
sample = _sampler().sample()
|
|
if args.json:
|
|
payload = {"ts": sample.ts, "readings": sample.to_rows()}
|
|
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
else:
|
|
print(render_snapshot(sample))
|
|
return 0
|
|
|
|
|
|
def cmd_monitor(args) -> int:
|
|
from .tui import run
|
|
|
|
interval = args.interval or load_config()["interval"]
|
|
return run(interval, plain=getattr(args, "plain", False))
|
|
|
|
|
|
def cmd_gui(args) -> int:
|
|
try:
|
|
from .gui.app import main as gui_main
|
|
except ImportError as exc:
|
|
print("The GUI needs PySide6, which isn't installed.")
|
|
print(" Install it with: pip install 'rigdoctor[gui]'")
|
|
print(" or on Ubuntu: sudo apt install python3-pyside6")
|
|
print(f" ({exc})")
|
|
return 2
|
|
return gui_main([sys.argv[0]])
|
|
|
|
|
|
# --- M3 crash-capture logger ---------------------------------------------------
|
|
|
|
def cmd_record_run(args) -> int:
|
|
cfg = load_config()
|
|
interval = args.interval or cfg["interval"]
|
|
log_path = Path(args.out) if args.out else config.LOG_FILE
|
|
config.STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
config.PID_FILE.write_text(str(os.getpid()))
|
|
|
|
from .core.recorder import Recorder
|
|
|
|
recorder = Recorder(
|
|
interval=interval,
|
|
log_path=log_path,
|
|
max_bytes=cfg["log_max_bytes"],
|
|
backups=cfg["log_backups"],
|
|
status_path=config.STATUS_FILE,
|
|
game=getattr(args, "game", None),
|
|
)
|
|
|
|
def _handle(_sig, _frame):
|
|
recorder.stop()
|
|
|
|
signal.signal(signal.SIGTERM, _handle)
|
|
signal.signal(signal.SIGINT, _handle)
|
|
|
|
print(f"Recording to {log_path} every {interval:g}s — stop with Ctrl-C or `rigdoctor record stop`.")
|
|
try:
|
|
recorder.run()
|
|
finally:
|
|
try:
|
|
config.PID_FILE.unlink()
|
|
except OSError:
|
|
pass
|
|
print(f"Stopped after {recorder.samples} samples.")
|
|
return 0
|
|
|
|
|
|
def cmd_record_start(args) -> int:
|
|
if reccontrol.running_pid():
|
|
print(f"Recorder already running (pid {reccontrol.running_pid()}).")
|
|
return 0
|
|
pid = reccontrol.start_background(args.interval, args.out)
|
|
time.sleep(1.0) # let it come up
|
|
if pid and reccontrol.pid_alive(pid):
|
|
print(f"Recording started in the background (pid {pid}).")
|
|
print(f" log: {args.out or config.LOG_FILE}")
|
|
print(" status: rigdoctor record status · stop: rigdoctor record stop")
|
|
return 0
|
|
print(f"Recorder failed to start; see {config.SPAWN_LOG}")
|
|
return 1
|
|
|
|
|
|
def cmd_record_stop(args) -> int:
|
|
pid = reccontrol.running_pid()
|
|
if not pid:
|
|
print("Recorder is not running.")
|
|
return 0
|
|
if not reccontrol.stop_background():
|
|
print(f"Could not stop recorder (pid {pid}).")
|
|
return 1
|
|
for _ in range(50):
|
|
if not reccontrol.pid_alive(pid):
|
|
break
|
|
time.sleep(0.1)
|
|
print(f"Recorder stopped (pid {pid}).")
|
|
return 0
|
|
|
|
|
|
def cmd_record_status(args) -> int:
|
|
pid = reccontrol.running_pid()
|
|
status = reccontrol.read_status()
|
|
print(f"● recording (pid {pid})" if pid else "○ not recording")
|
|
if status:
|
|
print(f" log: {status.get('log')}")
|
|
print(f" samples: {status.get('samples')}")
|
|
if status.get("started"):
|
|
print(f" started: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(status['started']))}")
|
|
if status.get("updated"):
|
|
print(f" updated: {time.strftime('%H:%M:%S', time.localtime(status['updated']))}")
|
|
if status.get("gpu_lost"):
|
|
print(" ⚠ a GPU-lost event was recorded this session")
|
|
if status.get("latest"):
|
|
print(f" latest: {format_headline(status['latest'])}")
|
|
return 0
|
|
|
|
|
|
def cmd_record_report(args) -> int:
|
|
from .core.crashlog import summarize
|
|
|
|
log_path = Path(args.log) if args.log else config.LOG_FILE
|
|
summary = summarize(log_path, last_n=args.last)
|
|
print(render_summary(summary, log_path=log_path))
|
|
return 0
|
|
|
|
|
|
def cmd_install(args) -> int:
|
|
from .core import installer, sysenv
|
|
|
|
print(f"Distro: {sysenv.distro_name()}")
|
|
pm = sysenv.package_manager()
|
|
print(f"Package manager: {pm or 'none (only apt is supported)'}")
|
|
print(f"GPU: {', '.join(sysenv.gpu_vendors()) or 'unknown'}\n")
|
|
|
|
status = installer.component_status()
|
|
print("Optional components:")
|
|
for component, present in status:
|
|
mark = "✓" if present else "✗"
|
|
print(f" [{mark}] {component.name:<22} — {component.enables}")
|
|
if not present:
|
|
print(f" apt: {' '.join(component.apt)}")
|
|
|
|
missing = [c for c, present in status if not present]
|
|
if not missing:
|
|
print("\nAll optional components are installed. ✔")
|
|
return 0
|
|
|
|
packages = installer.missing_packages(missing)
|
|
print(f"\nMissing packages: {' '.join(packages)}")
|
|
if args.check:
|
|
return 0
|
|
if pm != "apt":
|
|
print(f"Automatic install needs apt. Install manually:\n sudo apt install {' '.join(packages)}")
|
|
return 1
|
|
if not args.yes:
|
|
try:
|
|
reply = input(f"\nInstall {len(packages)} package(s) now? [y/N] ").strip().lower()
|
|
except EOFError:
|
|
reply = "n"
|
|
if reply not in ("y", "yes"):
|
|
print("Aborted.")
|
|
return 1
|
|
|
|
print("Installing (you may be prompted for your password)…")
|
|
rc, out = installer.install_packages(packages)
|
|
print(out[-2000:])
|
|
if rc == 0:
|
|
still = [c.name for c, present in installer.component_status() if not present]
|
|
print("\nStill missing: " + (", ".join(still) if still else "none ✔"))
|
|
else:
|
|
print(f"\nInstall failed (exit {rc}).")
|
|
return rc
|
|
|
|
|
|
def cmd_login(args) -> int:
|
|
from getpass import getpass
|
|
|
|
from .core import updates
|
|
|
|
token = args.token
|
|
if not token:
|
|
print(f"Create a token (scope read:repository) at: {updates.TOKEN_PAGE}")
|
|
try:
|
|
token = getpass("Paste token: ").strip()
|
|
except (EOFError, KeyboardInterrupt):
|
|
token = ""
|
|
if not token:
|
|
print("No token provided.")
|
|
return 1
|
|
config.save_token(token)
|
|
state, tag, _notes = updates.update_state()
|
|
if state == updates.AUTH:
|
|
print("Token saved, but the server rejected it (check scope/permissions).")
|
|
return 1
|
|
if state in (updates.UP_TO_DATE, updates.AVAILABLE):
|
|
print(f"Token saved and verified. Latest release: {tag}.")
|
|
return 0
|
|
print("Token saved (couldn't reach the server to verify right now).")
|
|
return 0
|
|
|
|
|
|
def cmd_logout(args) -> int:
|
|
config.clear_token()
|
|
print("Update token removed.")
|
|
return 0
|
|
|
|
|
|
def cmd_update(args) -> int:
|
|
from .core import updates
|
|
|
|
state, tag, notes = updates.update_state()
|
|
if state == updates.NO_TOKEN:
|
|
print("No update token. Run `rigdoctor login` after creating one at:")
|
|
print(f" {updates.TOKEN_PAGE}")
|
|
return 1
|
|
if state == updates.AUTH:
|
|
print("The update server rejected your token (check scope/permissions).")
|
|
return 1
|
|
if state == updates.NETWORK:
|
|
print("Couldn't reach the update server.")
|
|
return 1
|
|
if state == updates.UP_TO_DATE:
|
|
print(f"Up to date (v{__version__}).")
|
|
return 0
|
|
# AVAILABLE
|
|
print(f"Update available: {tag} (current v{__version__}).")
|
|
if notes:
|
|
print("\nWhat's new:\n" + "\n".join(" " + ln for ln in notes.splitlines()) + "\n")
|
|
if args.check:
|
|
return 0
|
|
print(f"Installing {tag}…")
|
|
rc, out = updates.apply_update(tag)
|
|
print(out[-2000:])
|
|
if rc == 0:
|
|
print(f"\nUpdated to {tag}. Restart RigDoctor to use the new version.")
|
|
return 0
|
|
print(f"\nUpdate failed (exit {rc}).")
|
|
return rc
|
|
|
|
|
|
def cmd_uninstall(args) -> int:
|
|
from .core import uninstall as uninstaller
|
|
|
|
scope = "everything (app + settings, token, and logs)" if args.purge else "the app (settings/logs kept)"
|
|
if not args.yes:
|
|
try:
|
|
reply = input(f"Uninstall RigDoctor — remove {scope}? [y/N] ").strip().lower()
|
|
except EOFError:
|
|
reply = "n"
|
|
if reply not in ("y", "yes"):
|
|
print("Aborted.")
|
|
return 1
|
|
uninstaller.uninstall(purge=args.purge)
|
|
print("Uninstalling… RigDoctor will be removed momentarily.")
|
|
return 0
|
|
|
|
|
|
def cmd_collect_priv(args) -> int:
|
|
"""Internal: emit root-only data (SMART + dmidecode) as JSON, run via pkexec at launch."""
|
|
from dataclasses import asdict
|
|
|
|
from .core.health import check_smart
|
|
from .core.inventory import _dmidecode
|
|
|
|
data = {"smart": [asdict(f) for f in check_smart()], "dmidecode": _dmidecode()}
|
|
print(json.dumps(data))
|
|
return 0
|
|
|
|
|
|
def cmd_inventory(args) -> int:
|
|
from .core import inventory
|
|
|
|
sections = inventory.collect()
|
|
if args.json:
|
|
text = inventory.render_json(sections)
|
|
elif args.markdown:
|
|
text = inventory.render_markdown(sections)
|
|
else:
|
|
text = inventory.render_text(sections)
|
|
if args.output:
|
|
Path(args.output).write_text(text)
|
|
print(f"Wrote {args.output}")
|
|
else:
|
|
print(text)
|
|
return 0
|
|
|
|
|
|
def cmd_report(args) -> int:
|
|
from dataclasses import asdict
|
|
|
|
from .core.health import run_health_checks
|
|
from .render import render_health
|
|
|
|
findings = run_health_checks()
|
|
if args.json:
|
|
print(json.dumps([asdict(f) for f in findings], indent=2, ensure_ascii=False))
|
|
else:
|
|
print(render_health(findings))
|
|
return 0
|
|
|
|
|
|
def _resolve_game(args) -> str | None:
|
|
"""Game name from --game, or looked up from --appid via the Steam scan."""
|
|
if getattr(args, "game", None):
|
|
return args.game
|
|
if getattr(args, "appid", None):
|
|
from .core import steam
|
|
|
|
for g in steam.scan_games(steam.selected_library_paths()):
|
|
if g.appid == str(args.appid):
|
|
return g.name
|
|
return None
|
|
return None
|
|
|
|
|
|
def cmd_diagnose(args) -> int:
|
|
from .core import diagnostic, reccontrol, steam
|
|
|
|
sub = args.diagnose_cmd or "status"
|
|
|
|
if sub == "start":
|
|
if reccontrol.running_pid():
|
|
print("A capture is already running — finish it with: rigdoctor diagnose finish")
|
|
return 1
|
|
game = _resolve_game(args)
|
|
if game is None and (args.game or args.appid):
|
|
print("Couldn't match that game in your selected Steam libraries.")
|
|
return 1
|
|
if game is None:
|
|
games = steam.cached_games() or steam.scan_games(steam.selected_library_paths())
|
|
if games:
|
|
print("Pick a game to focus on, then re-run with --game:")
|
|
for g in games:
|
|
print(f" --game {g.name!r}")
|
|
else:
|
|
print("No games detected. Select a library: rigdoctor games libraries --all")
|
|
return 1
|
|
pid = diagnostic.start(game=game, interval=args.interval)
|
|
time.sleep(1.0)
|
|
if pid and reccontrol.pid_alive(pid):
|
|
print(f"Diagnostic capture started for {game!r} (pid {pid}).")
|
|
print(" Play your game. When you're done (or after a crash + reboot):")
|
|
print(" rigdoctor diagnose finish")
|
|
return 0
|
|
print(f"Capture failed to start; see {config.SPAWN_LOG}")
|
|
return 1
|
|
|
|
if sub == "status":
|
|
status = diagnostic.active()
|
|
if not status:
|
|
print("No diagnostic capture is running.")
|
|
return 0
|
|
game = status.get("game") or "—"
|
|
print(f"Capturing for {game!r}: {status.get('samples', 0)} samples"
|
|
+ (" · GPU-lost seen" if status.get("gpu_lost") else ""))
|
|
return 0
|
|
|
|
# finish
|
|
if not reccontrol.running_pid() and not config.DIAG_LOG.exists():
|
|
print("No diagnostic to analyze. Start one with: rigdoctor diagnose start --game <name>")
|
|
return 1
|
|
print("Stopping capture and analyzing…\n")
|
|
result = diagnostic.finish(last_n=args.last)
|
|
from .render import render_health, render_summary
|
|
|
|
if result.game:
|
|
print(f"Diagnostic — {result.game}\n")
|
|
print(render_summary(result.summary, log_path=config.DIAG_LOG))
|
|
print("\n" + render_health(result.findings, title="Findings"))
|
|
return 0
|
|
|
|
|
|
def cmd_wrap(args) -> int:
|
|
from .core import wrap
|
|
|
|
return wrap.run(args.command)
|
|
|
|
|
|
def cmd_watch(args) -> int:
|
|
from .core import watcher
|
|
|
|
interval = args.interval or load_config().get("interval", 1.0)
|
|
print("Watching for a running Steam game (Ctrl-C to stop)…")
|
|
return watcher.watch(interval=max(2.0, interval))
|
|
|
|
|
|
def cmd_service(args) -> int:
|
|
from .core import service
|
|
|
|
sub = args.service_cmd or "status"
|
|
if sub == "mode":
|
|
ok, msg = service.apply_mode(args.mode)
|
|
print(f"Trigger mode set to '{args.mode}'.")
|
|
if not ok and msg:
|
|
print(f" note: {msg}")
|
|
return 0 if ok or not service.available() else 1
|
|
|
|
info = service.status()
|
|
print(f"Trigger mode: {info['mode']}")
|
|
print(f"systemd --user: {'available' if info['available'] else 'not available'}")
|
|
if info["available"]:
|
|
print(f" recorder service: {'active' if info.get('recorder_active') else 'inactive'}")
|
|
print(f" watcher service: {'active' if info.get('watch_active') else 'inactive'}")
|
|
return 0
|
|
|
|
|
|
def cmd_ai(args) -> int:
|
|
"""AI assistant (M14) — opt-in; only contacts a provider on `test`/`explain`."""
|
|
from .core import ai
|
|
|
|
sub = args.ai_cmd or "status"
|
|
if sub == "status":
|
|
print(f"Provider: {ai.provider() or 'not configured'}")
|
|
if ai.provider():
|
|
print(f" {ai.provider_label()}")
|
|
print(f" ready: {'yes' if ai.is_configured() else 'no'}")
|
|
else:
|
|
print(" Configure it in the GUI: Settings → AI assistant.")
|
|
return 0
|
|
|
|
if not ai.is_configured():
|
|
print("AI is not configured. Set it up in the GUI (Settings → AI assistant).")
|
|
return 1
|
|
|
|
if sub == "test":
|
|
ok, msg = ai.explain("Connectivity test — reply exactly: RigDoctor AI is working.")
|
|
print(msg)
|
|
return 0 if ok else 1
|
|
|
|
# explain: gather the current health findings and ask the provider to explain them.
|
|
from .core import health
|
|
|
|
findings = health.run_health_checks()
|
|
text = ai.format_findings(findings)
|
|
print(f"Asking {ai.provider_label()} to explain the current health findings…\n")
|
|
ok, msg = ai.explain(text)
|
|
print(msg)
|
|
return 0 if ok else 1
|
|
|
|
|
|
def cmd_bundle(args) -> int:
|
|
"""Zip the latest stored diagnostic into a report bundle (M15) — needs logging enabled."""
|
|
from .core import diagstore
|
|
|
|
if not diagstore.enabled():
|
|
print("Logging is off. Enable it (Settings → Logging, or set logging_enabled) so "
|
|
"diagnostics are stored and can be reported.")
|
|
return 1
|
|
directory = diagstore.latest_dir()
|
|
if directory is None:
|
|
print("No stored diagnostics yet — run a diagnostic first.")
|
|
return 1
|
|
out = diagstore.make_report(directory)
|
|
print(f"Report written: {out}")
|
|
return 0
|
|
|
|
|
|
def cmd_gameenv(args) -> int:
|
|
from dataclasses import asdict
|
|
|
|
from .core.gameenv import run_gameenv_checks
|
|
from .render import render_health
|
|
|
|
findings = run_gameenv_checks()
|
|
if args.json:
|
|
print(json.dumps([asdict(f) for f in findings], indent=2, ensure_ascii=False))
|
|
else:
|
|
print(render_health(findings, title="Gaming environment"))
|
|
return 0
|
|
|
|
|
|
def cmd_games(args) -> int:
|
|
from dataclasses import asdict
|
|
|
|
from .core import launchers, steam
|
|
|
|
selected = steam.selected_library_paths()
|
|
result = steam.rescan() if selected else None
|
|
steam_games = result.games if result else []
|
|
extra = launchers.scan() # non-Steam (Lutris/Heroic)
|
|
all_games = list(steam_games) + list(extra)
|
|
|
|
if args.json:
|
|
print(json.dumps({
|
|
"scanned_at": result.scanned_at if result else None,
|
|
"new_appids": result.new_appids if result else [],
|
|
"games": [asdict(g) for g in all_games],
|
|
}, indent=2, ensure_ascii=False))
|
|
return 0
|
|
|
|
if not all_games:
|
|
if not selected:
|
|
print("No Steam libraries selected and no non-Steam games found.")
|
|
print(" Pick a Steam library: rigdoctor games libraries --enable <path> (or --all)")
|
|
return 1
|
|
print("No games found.")
|
|
return 0
|
|
|
|
new = set(result.new_appids) if result else set()
|
|
print(f"{len(all_games)} game(s):\n")
|
|
for g in all_games:
|
|
tag = " NEW" if g.appid in new else ""
|
|
src = "" if g.launcher == "steam" else f" [{g.launcher}]"
|
|
size = steam.human_size(g.size_bytes) if g.size_bytes else ""
|
|
print(f" {g.name:<46}{src:<10} {size:>9}{tag}")
|
|
if not selected:
|
|
print("\n(no Steam libraries selected — `rigdoctor games libraries --all` to add them)")
|
|
return 0
|
|
|
|
|
|
def cmd_games_libraries(args) -> int:
|
|
from .core import steam
|
|
|
|
discovered = steam.discover_libraries()
|
|
selected = {os.path.realpath(p) for p in steam.selected_library_paths()}
|
|
|
|
# --all / --enable / --disable adjust the selection, then we list the result.
|
|
if args.all or args.enable or args.disable:
|
|
if args.all:
|
|
selected = {lib.path for lib in discovered}
|
|
for raw in args.enable or []:
|
|
selected.add(os.path.realpath(os.path.expanduser(raw)))
|
|
for raw in args.disable or []:
|
|
selected.discard(os.path.realpath(os.path.expanduser(raw)))
|
|
config.update_config(steam_libraries=sorted(selected))
|
|
|
|
if not discovered:
|
|
print("No Steam libraries detected (is Steam installed?).")
|
|
return 1
|
|
if args.json:
|
|
print(json.dumps([
|
|
{"path": lib.path, "label": lib.label, "selected": lib.path in selected,
|
|
"games": len(steam.scan_library(lib.path))}
|
|
for lib in discovered
|
|
], indent=2, ensure_ascii=False))
|
|
return 0
|
|
print("Steam libraries (checked = scanned for games):\n")
|
|
for lib in discovered:
|
|
mark = "x" if lib.path in selected else " "
|
|
count = len(steam.scan_library(lib.path))
|
|
label = f" [{lib.label}]" if lib.label else ""
|
|
print(f" [{mark}] {lib.path}{label} ({count} games)")
|
|
return 0
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
p = argparse.ArgumentParser(
|
|
prog="rigdoctor",
|
|
description="Hardware monitoring & crash diagnostics for Linux gamers.",
|
|
)
|
|
p.add_argument("-V", "--version", action="version", version=f"rigdoctor {__version__}")
|
|
sub = p.add_subparsers(dest="command", required=True)
|
|
|
|
sp = sub.add_parser("snapshot", help="print a one-shot reading of all sensors")
|
|
sp.add_argument("--json", action="store_true", help="output JSON instead of text")
|
|
sp.set_defaults(func=cmd_snapshot)
|
|
|
|
mp = sub.add_parser("monitor", help="live monitor TUI (current/min/max, M2)")
|
|
mp.add_argument("-n", "--interval", type=float, default=None, help="refresh interval (s)")
|
|
mp.add_argument("--plain", action="store_true", help="plain redraw instead of the curses UI")
|
|
mp.set_defaults(func=cmd_monitor)
|
|
|
|
sub.add_parser("gui", help="launch the desktop GUI (needs PySide6)").set_defaults(func=cmd_gui)
|
|
sub.add_parser("sources", help="list detected sensor sources").set_defaults(func=cmd_sources)
|
|
|
|
inst = sub.add_parser("install", help="set up optional system dependencies (M9)")
|
|
inst.add_argument("--check", action="store_true", help="report status only; install nothing")
|
|
inst.add_argument("-y", "--yes", action="store_true", help="install without confirmation")
|
|
inst.set_defaults(func=cmd_install)
|
|
|
|
login = sub.add_parser("login", help="save a Gitea token for updates (M13)")
|
|
login.add_argument("--token", default=None, help="token (prompted if omitted)")
|
|
login.set_defaults(func=cmd_login)
|
|
sub.add_parser("logout", help="remove the saved update token").set_defaults(func=cmd_logout)
|
|
|
|
upd = sub.add_parser("update", help="check for / apply a newer version (M13)")
|
|
upd.add_argument("--check", action="store_true", help="only report, don't apply")
|
|
upd.set_defaults(func=cmd_update)
|
|
|
|
unin = sub.add_parser("uninstall", help="remove the user-local install")
|
|
unin.add_argument("--purge", action="store_true", help="also remove settings, token, and logs")
|
|
unin.add_argument("-y", "--yes", action="store_true", help="don't ask for confirmation")
|
|
unin.set_defaults(func=cmd_uninstall)
|
|
|
|
rec = sub.add_parser("record", help="crash-capture logger (M3)")
|
|
rec_sub = rec.add_subparsers(dest="record_cmd", required=True)
|
|
|
|
run_p = rec_sub.add_parser("run", help="run the capture loop in the foreground (systemd-friendly)")
|
|
run_p.add_argument("-n", "--interval", type=float, default=None, help="sampling interval (s)")
|
|
run_p.add_argument("-o", "--out", default=None, help="log file path")
|
|
run_p.add_argument("--game", default=None, help="tag the capture with a game name (M6/diagnose)")
|
|
run_p.set_defaults(func=cmd_record_run)
|
|
|
|
start_p = rec_sub.add_parser("start", help="start recording in the background")
|
|
start_p.add_argument("-n", "--interval", type=float, default=None, help="sampling interval (s)")
|
|
start_p.add_argument("-o", "--out", default=None, help="log file path")
|
|
start_p.set_defaults(func=cmd_record_start)
|
|
|
|
rec_sub.add_parser("stop", help="stop background recording").set_defaults(func=cmd_record_stop)
|
|
rec_sub.add_parser("status", help="show recorder status").set_defaults(func=cmd_record_status)
|
|
|
|
report_p = rec_sub.add_parser("report", help="summarize the captured log (post-crash)")
|
|
report_p.add_argument("--last", type=int, default=10, help="recent samples to show")
|
|
report_p.add_argument("--log", default=None, help="path to a capture log")
|
|
report_p.set_defaults(func=cmd_record_report)
|
|
|
|
rep = sub.add_parser("report", help="health report (M4): scan logs/SMART/driver for issues")
|
|
rep.add_argument("--json", action="store_true", help="output JSON instead of text")
|
|
rep.set_defaults(func=cmd_report)
|
|
|
|
cp = sub.add_parser("collect-priv", help=argparse.SUPPRESS) # internal: run via pkexec
|
|
cp.set_defaults(func=cmd_collect_priv)
|
|
|
|
inv = sub.add_parser("inventory", help="system inventory (M5): export hardware/OS details")
|
|
inv.add_argument("--json", action="store_true", help="output JSON")
|
|
inv.add_argument("--markdown", action="store_true", help="output Markdown (for forum/bug reports)")
|
|
inv.add_argument("-o", "--output", default=None, help="write to a file instead of stdout")
|
|
inv.set_defaults(func=cmd_inventory)
|
|
|
|
games_p = sub.add_parser("games", help="Steam game & library detection (M6)")
|
|
games_p.add_argument("--json", action="store_true", help="output JSON")
|
|
games_p.set_defaults(func=cmd_games)
|
|
games_sub = games_p.add_subparsers(dest="games_cmd")
|
|
lib_p = games_sub.add_parser("libraries", help="list/select Steam libraries to scan")
|
|
lib_p.add_argument("--enable", action="append", metavar="PATH", help="scan this library (repeatable)")
|
|
lib_p.add_argument("--disable", action="append", metavar="PATH", help="stop scanning this library (repeatable)")
|
|
lib_p.add_argument("--all", action="store_true", help="scan all detected libraries")
|
|
lib_p.add_argument("--json", action="store_true", help="output JSON")
|
|
lib_p.set_defaults(func=cmd_games_libraries)
|
|
|
|
env_p = sub.add_parser("gameenv", help="gaming environment checks (M6): flag stability/perf settings")
|
|
env_p.add_argument("--json", action="store_true", help="output JSON instead of text")
|
|
env_p.set_defaults(func=cmd_gameenv)
|
|
|
|
diag_p = sub.add_parser("diagnose", help="guided diagnostic: capture while gaming, then analyze")
|
|
diag_sub = diag_p.add_subparsers(dest="diagnose_cmd")
|
|
diag_start = diag_sub.add_parser("start", help="start a focused capture for a game")
|
|
diag_start.add_argument("--game", default=None, help="game name to focus on")
|
|
diag_start.add_argument("--appid", default=None, help="Steam appid to focus on (resolved to a name)")
|
|
diag_start.add_argument("-n", "--interval", type=float, default=None, help="sampling interval (s)")
|
|
diag_start.set_defaults(func=cmd_diagnose)
|
|
diag_sub.add_parser("status", help="show the in-progress diagnostic").set_defaults(func=cmd_diagnose)
|
|
diag_finish = diag_sub.add_parser("finish", help="stop the capture and analyze it")
|
|
diag_finish.add_argument("--last", type=int, default=10, help="recent samples to show")
|
|
diag_finish.set_defaults(func=cmd_diagnose)
|
|
diag_p.set_defaults(func=cmd_diagnose, diagnose_cmd=None, last=10)
|
|
|
|
wrap_p = sub.add_parser(
|
|
"wrap", help="run a game with automatic crash-capture (Steam launch option, D12)")
|
|
wrap_p.add_argument("command", nargs=argparse.REMAINDER,
|
|
help="the game command — use `rigdoctor wrap %%command%%` in Steam")
|
|
wrap_p.set_defaults(func=cmd_wrap)
|
|
|
|
watch_p = sub.add_parser("watch", help="auto-capture while a Steam game runs (game-launch trigger)")
|
|
watch_p.add_argument("-n", "--interval", type=float, default=None, help="poll interval (s)")
|
|
watch_p.set_defaults(func=cmd_watch)
|
|
|
|
svc_p = sub.add_parser("service", help="crash-logger trigger mode + systemd --user service (M9/D6)")
|
|
svc_sub = svc_p.add_subparsers(dest="service_cmd")
|
|
svc_sub.add_parser("status", help="show the trigger mode and service state").set_defaults(func=cmd_service)
|
|
mode_p = svc_sub.add_parser("mode", help="set the trigger mode")
|
|
mode_p.add_argument("mode", choices=("manual", "always-on", "game-launch"))
|
|
mode_p.set_defaults(func=cmd_service)
|
|
svc_p.set_defaults(func=cmd_service, service_cmd=None)
|
|
|
|
ai_p = sub.add_parser("ai", help="AI assistant (M14): explain diagnostics — opt-in, never automatic")
|
|
ai_sub = ai_p.add_subparsers(dest="ai_cmd")
|
|
ai_sub.add_parser("status", help="show the configured provider (contacts nothing)").set_defaults(func=cmd_ai)
|
|
ai_sub.add_parser("test", help="send a tiny probe to verify connectivity").set_defaults(func=cmd_ai)
|
|
ai_sub.add_parser("explain", help="explain the current health findings with AI").set_defaults(func=cmd_ai)
|
|
ai_p.set_defaults(func=cmd_ai, ai_cmd=None)
|
|
|
|
bundle_p = sub.add_parser("bundle", help="zip the latest stored diagnostic into a report bundle (M15)")
|
|
bundle_p.set_defaults(func=cmd_bundle)
|
|
return p
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
from .core import applog
|
|
|
|
applog.setup() # opt-in app logging (M15); no-op unless logging_enabled
|
|
args = build_parser().parse_args(argv)
|
|
return args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|