mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
Fifth and last app onto freedays (~/utils/freedays). screen-locker is the one that already had this idea: scheduled_skips.json was a bare list of dates, checked first in the lock chain, that only this app could see. That is exactly what the shared pool generalises, so the chain now asks freedays and a day taken here also stands down diet-guard, wake-alarm, leetcode-guard and home-guard. Sick days are deliberately untouched. Their rolling windows (1/7d, 3/30d, 10/90d), the 120-character justification and the escalating countdown all exist to make a sick day *hard* to take. Free days are the opposite -- declared ahead, no reason, consecutive runs allowed. Merging the two policies would have had to ruin one of them, so only the storage of the frictionless half moved. This fixes a real bug in passing. is_scheduled_skip_today compared against a UTC day while add_scheduled_skip.py wrote a local one, so a skip added near midnight could name a date the chain never matched. freedays owns "what is today" for both sides now, in local time, and the disagreement cannot recur. scripts/migrate_scheduled_skips.py moves the existing dates across; it is idempotent and has a --dry-run. Already run here: the 8 real skips (2026-05-19/20/21, 05-29, 05-30, 08-17, 08-22, 08-29) are in the pool and count against 2026, leaving 27 of 35. The original file is gitignored and was left on disk untouched. add_scheduled_skip.py becomes a thin wrapper over freedays rather than being deleted, because arm.sh calls it and the name is in muscle memory. Left as-is it would have kept writing to a file nothing reads. conftest.py went over the 250-line cap, so the two fixtures that keep the suite off real user state moved to _real_state_fixtures.py. They belong together and neither subsumes the other: _isolate_home covers paths resolved from Path.home() at call time, while the freedays redirect covers paths bound into module constants at import time, which redirecting home afterwards cannot reach. requirements.txt also takes pytest-randomly 5.0.0, ruff 0.16.6 and types-requests 2.33.0.20260906, and the pylint hooks' gatelock/crdt-sync pins move off v0.7.1/v0.9.0 -- pip refuses two direct URLs for one distribution, so those stale pins would have made the hook environments unresolvable rather than merely old. 1256 tests, 100% branch coverage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
153 lines
6.1 KiB
Python
153 lines
6.1 KiB
Python
"""Read-only MCP (Model Context Protocol) server for screen-locker.
|
|
|
|
Exposes the locker's *read-only* status surface as typed MCP tools, so an MCP
|
|
client (Claude Code and its subagents) can query today's/this week's workout
|
|
compliance, the shutdown projection, and the lock-decision trace without
|
|
shelling out to the ``screen-locker-status`` CLI or opening the Tk window.
|
|
|
|
Run via the dedicated venv that has the ``mcp`` extra installed::
|
|
|
|
~/.venvs/screen-locker-mcp/bin/python -m screen_locker._mcp
|
|
|
|
(see ``scripts/setup_mcp.sh`` and the repo-root ``.mcp.json``).
|
|
|
|
Safety invariants (do not break when adding tools):
|
|
* **READ-ONLY.** There are no write/action tools here, by design. In
|
|
particular there is deliberately **no** ``log_workout`` and no tool that
|
|
mutates workout/compliance state — the project rule is that workouts are
|
|
logged only from RunnerUp-verified TCX data, never from a caller's claim.
|
|
Every tool below calls only side-effect-free leaf helpers that *read*
|
|
on-disk state (``gather_status`` and the ``_compliance_state`` predicates),
|
|
never a mutating CLI/daemon entry point.
|
|
* **stdout is the JSON-RPC channel.** This module and every function a tool
|
|
calls must never write to stdout. All logging is routed to STDERR below,
|
|
and no tool calls ``screen_lock.py`` / ``status_view.main`` (which
|
|
``print`` / open Tk windows / ``sys.exit``).
|
|
* **No secret ever leaves.** No tool returns the sync token
|
|
(``~/.config/screen_locker/sync_token``) or any HMAC key; the predicates
|
|
load only status/state and return booleans, and the status snapshot
|
|
carries no secret material.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import asdict
|
|
import logging
|
|
from pathlib import Path
|
|
import sys
|
|
from typing import Any
|
|
|
|
from mcp.server import MCPServer
|
|
from mcp.types import ToolAnnotations
|
|
|
|
from screen_locker._compliance_state import (
|
|
has_logged_today,
|
|
is_early_bird_pending,
|
|
is_scheduled_skip_today,
|
|
is_sick_day_today,
|
|
)
|
|
from screen_locker._constants import (
|
|
EARLY_BIRD_PENDING_FILE,
|
|
)
|
|
from screen_locker._sick_tracker import load_history
|
|
from screen_locker._status_data import format_summary_line, gather_status
|
|
from screen_locker._status_projection import compliance_state_word
|
|
|
|
# Log to STDERR only — STDOUT carries the MCP JSON-RPC protocol frames, so a
|
|
# single stray stdout write would corrupt the stream and kill the session.
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
stream=sys.stderr,
|
|
format="%(asctime)s [%(levelname)s] screen-locker-mcp: %(message)s",
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
mcp = MCPServer("screen-locker")
|
|
|
|
# Every tool here only reads local state: the workout log, the sick history
|
|
# and the clock. Saying so lets a client run them without asking -- and the
|
|
# day one of them stops being read-only, this annotation is the thing that
|
|
# has to change with it. (See the section banner below: "never mutate" was
|
|
# already the rule; this is the machine-readable half of it.)
|
|
_READS_ONLY = ToolAnnotations(
|
|
read_only_hint=True,
|
|
idempotent_hint=True,
|
|
open_world_hint=False,
|
|
)
|
|
|
|
# The workout log lives beside the package, same default as
|
|
# ``_status_data._DEFAULT_LOG_FILE``; declared here to avoid importing a
|
|
# private name across modules.
|
|
_LOG_FILE = Path(__file__).resolve().parent / "log.json"
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Read tools (state-only; never expose secrets, never mutate)
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
@mcp.tool(title="Workout gate status", annotations=_READS_ONLY)
|
|
def get_status() -> dict[str, Any]:
|
|
"""Return the full read-only status snapshot.
|
|
|
|
A JSON-friendly projection of ``gather_status()``: today's and this week's
|
|
workout outcomes, the shutdown-time projection, the lock-decision trace,
|
|
and the rolling sick-day / manual-workout budgets. Reads on-disk state
|
|
only (no ADB, sudo, or network) and never mutates anything.
|
|
"""
|
|
return asdict(gather_status())
|
|
|
|
|
|
@mcp.tool(title="One-line status summary", annotations=_READS_ONLY)
|
|
def get_summary() -> dict[str, Any]:
|
|
"""Return the one-line i3blocks summary plus the compliance-state word.
|
|
|
|
``summary_line`` is the same string the status bar shows
|
|
(``format_summary_line``); ``compliance_state`` is one of ``ok`` / ``warn``
|
|
/ ``lock`` (``compliance_state_word``).
|
|
"""
|
|
snapshot = gather_status()
|
|
return {
|
|
"summary_line": format_summary_line(snapshot),
|
|
"compliance_state": compliance_state_word(snapshot),
|
|
}
|
|
|
|
|
|
@mcp.tool(title="Why the screen is locked", annotations=_READS_ONLY)
|
|
def explain_lock() -> dict[str, Any]:
|
|
"""Return why the screen is or is not being locked right now.
|
|
|
|
A JSON-friendly projection of the ``LockExplanation`` reconstruction of the
|
|
lock-decision chain (which skip condition fired, the full ordered trace,
|
|
and any pending auto-upgrade opportunity). Read-only: the heat-skip stage
|
|
is reported as not-evaluated rather than triggering a live ``wttr.in`` call.
|
|
"""
|
|
return asdict(gather_status().lock_explanation)
|
|
|
|
|
|
@mcp.tool(title="Lock-decision predicates", annotations=_READS_ONLY)
|
|
def get_flags() -> dict[str, bool]:
|
|
"""Return the individual boolean lock-decision predicates for today.
|
|
|
|
Each predicate reads its own on-disk state file and degrades to ``False``
|
|
when that file is missing or unreadable — none of them mutate state or
|
|
expose secrets.
|
|
"""
|
|
history = load_history()
|
|
return {
|
|
"has_logged_today": has_logged_today(_LOG_FILE),
|
|
"is_scheduled_skip_today": is_scheduled_skip_today(),
|
|
"is_early_bird_pending": is_early_bird_pending(EARLY_BIRD_PENDING_FILE),
|
|
"is_sick_day_today": is_sick_day_today(history),
|
|
}
|
|
|
|
|
|
def main() -> None:
|
|
"""Run the MCP server over stdio (STDOUT = JSON-RPC, STDERR = logs)."""
|
|
logger.info("Starting screen-locker MCP server (python=%s)", sys.executable)
|
|
mcp.run() # pragma: no cover
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|