steam-backlog-enforcer/steam_backlog_enforcer/_mcp_actions.py
Krzysztof kuhy Rudnicki 1bdf360193 pick keeps already-picked games; fast install reports progress
pick and pick-manual no longer uninstall games that are already allowed:
the outgoing assignment is kept as a released pick (installed, no lock)
until 'pick --replace', done/check, or abandon-pick. The allowed set is
capped at max_manual_picks + 1; a pick over it is refused before the
candidate list / confirmation, naming the games. status and the web
Picks card list kept games with an Abandon button.

Fast install now tracks the installer log from the spawn offset: web
jobs drive the progress bar (per-depot %, phase) and echo a line every
30 s; daemon-started runs are exposed via ctl status / /api/daemon and
shown on the Dashboard.

Also: web deps bumped for dependency freshness (react-query 5.104.5,
typescript-eslint 8.72.0, vite 8.3.5, node 24.22.1 in CI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CUaX4zAa6H4sMu3jd6RFdb
2026-10-23 20:14:12 +02:00

189 lines
6.8 KiB
Python

"""Mutating MCP tools: manual picks, abandonment and gaming blocks.
Every tool here takes an explicit ``confirm`` flag, so a model cannot change
enforcement state on a single unconfirmed call. The read-only half lives in
:mod:`steam_backlog_enforcer._mcp_query`.
"""
from __future__ import annotations
from typing import Any
from steam_backlog_enforcer._actions import (
ABANDON_COOLDOWN_DAYS,
active_manual_picks,
apply_configured_pick,
find_manual_pick,
)
from steam_backlog_enforcer._allowed_games import MANUAL_LOCK_DAYS
from steam_backlog_enforcer._manual_pick_lifecycle import (
abandon_manual_pick,
manual_pick_age_days,
)
from steam_backlog_enforcer._mcp_server import (
_resolve_game_name,
logger,
mcp,
)
from steam_backlog_enforcer._pick_completion import (
retire_completed_manual_picks,
)
from steam_backlog_enforcer._total_block import start_total_block
from steam_backlog_enforcer.config import Config, State
@mcp.tool()
def pick_manual(app_id: int, *, confirm: bool = False) -> dict[str, Any]:
"""Lock the enforcer to a game by Steam app_id (gated write).
With ``confirm=False`` (the default) this performs **no** mutation and
returns a preview of what confirming would do. Call again with
``confirm=True`` to apply. Applying makes it the current assignment
(the games already allowed stay, up to the cap) and
locks all other commands for ``MANUAL_LOCK_DAYS`` days (or until one new
achievement is earned in it). Unlike the CLI's ``pick-manual``, this
mutates **state only** — it never uninstalls, installs, or hides games.
Args:
app_id: The Steam app id to lock in (must exist in the last snapshot).
confirm: Set ``True`` to actually apply the pick; otherwise preview.
"""
game_name = _resolve_game_name(app_id)
if game_name is None:
return {
"ok": False,
"reason": f"AppID={app_id} not found in the snapshot. Run 'scan' first.",
}
if not confirm:
return {
"ok": True,
"preview": True,
"action": "pick_manual",
"app_id": app_id,
"game_name": game_name,
"effect": (
"Makes it the current assignment (the games already allowed "
"stay, up to the cap) and locks all other commands "
f"for {MANUAL_LOCK_DAYS} days or until you earn one new "
"achievement in it."
),
"confirm_required": True,
}
config = Config.load()
state = State.load()
# Free the slots of picks that already earned a new achievement, so the
# cap below cannot refuse on behalf of them. Matches the CLI's pick-manual.
retired = retire_completed_manual_picks(config, state)
refused = apply_configured_pick(config, state, app_id, game_name)
if refused is not None:
return {"ok": False, "reason": refused}
logger.info("pick_manual applied: %s (AppID=%s)", game_name, app_id)
return {
"ok": True,
"applied": True,
"action": "pick_manual",
"app_id": app_id,
"game_name": game_name,
"retired_completed": [
{"app_id": r.app_id, "game_name": r.game_name} for r in retired if r.retired
],
}
@mcp.tool()
def abandon_pick(app_id: int, *, confirm: bool = False) -> dict[str, Any]:
"""Undo a manual pick (gated write).
With ``confirm=False`` (the default) this performs **no** mutation and
returns a preview. A pick can be abandoned at any time, however long ago
it was made. Like ``pick_manual``, this mutates **state only** — the CLI's
``abandon-pick`` additionally uninstalls the abandoned game.
Args:
app_id: The manually-picked app id to back out of.
confirm: Set ``True`` to actually abandon the pick; otherwise preview.
"""
state = State.load()
picks = active_manual_picks(state)
if not picks:
return {"ok": False, "reason": "No manual pick is active."}
pick = find_manual_pick(state, app_id)
if pick is None:
listed = ", ".join(f"AppID={p['app_id']}" for p in picks)
return {
"ok": False,
"reason": (
f"AppID={app_id} is not one of the active manual picks ({listed})."
),
}
age = manual_pick_age_days(state, app_id)
game_name = str(pick["game_name"])
if not confirm:
return {
"ok": True,
"preview": True,
"action": "abandon_pick",
"app_id": app_id,
"game_name": game_name,
"age_days": age,
"effect": (
"Releases the manual pick lock, clears the assignment, and "
f"keeps the game out of auto-assignment for "
f"{ABANDON_COOLDOWN_DAYS} days."
),
"confirm_required": True,
}
abandon_manual_pick(state, app_id)
logger.info("abandon_pick applied: %s (AppID=%s)", game_name, app_id)
return {
"ok": True,
"applied": True,
"action": "abandon_pick",
"app_id": app_id,
"game_name": game_name,
}
@mcp.tool()
def block_gaming(days: int, *, confirm: bool = False) -> dict[str, Any]:
"""Start a total gaming block for ``days`` days (gated, privileged write).
With ``confirm=False`` (the default) returns a preview only. With
``confirm=True`` it attempts the block, which **requires root** (it edits
``/etc/hosts`` and uninstalls Steam). The MCP server runs unprivileged, so
this will normally return ``{"ok": false, "reason": "requires elevated
privileges"}`` rather than succeeding. There is no in-app undo.
Args:
days: Whole number of days to block all gaming (must be >= 1).
confirm: Set ``True`` to attempt the block; otherwise preview.
"""
if days < 1:
return {"ok": False, "reason": "days must be at least 1."}
if not confirm:
return {
"ok": True,
"preview": True,
"action": "block_gaming",
"days": days,
"effect": (
f"For {days} day(s): uninstalls Steam, kills game launchers, and "
"blocks Steam + game-website domains. There is NO in-app undo."
),
"requires_root": True,
"confirm_required": True,
}
try:
applied = start_total_block(days)
except (OSError, RuntimeError) as exc: # never crash the server on a write
logger.warning("block_gaming failed: %s", exc)
return {"ok": False, "reason": "requires elevated privileges"}
if not applied:
return {"ok": False, "reason": "requires elevated privileges"}
logger.info("block_gaming applied: %d day(s)", days)
return {"ok": True, "applied": True, "action": "block_gaming", "days": days}