#!/usr/bin/env python3 """Staleness check for the local designbook mirror, backed by llm-connect. `make designbook-sync` can only report what the marker (designbook/.design-sync.json) already knows. This script refreshes the "is the cloud ahead?" half: it asks the Claude Design project for its current `updatedAt` and, if that is newer than the last `/design-sync`, records it so the next report warns the mirror is OUTDATED. Only the **claude-code** llm-connect adapter can answer this — it drives the local `claude` binary, which has the DesignSync tool over your claude.ai login. Gemini/ OpenRouter/OpenAI cannot see your Claude Design project. No secret is placed in the prompt: DesignSync authenticates through the local login, not an API key — see .claude/rules/credential-routing.md. python scripts/check_designbook_staleness.py python scripts/check_designbook_staleness.py --remote-updated # skip the LLM call and use a known value (offline test / manual override) python scripts/check_designbook_staleness.py --fail-if-stale # exit 3 when stale The marker write + RecentChanges.md regeneration are delegated to scripts/designbook-sync.mjs --remote-updated, so the marker format lives in one place. """ from __future__ import annotations import argparse import json import re import subprocess import sys from datetime import datetime, timezone from pathlib import Path REPO = Path(__file__).resolve().parent.parent MARKER = REPO / "designbook" / ".design-sync.json" PIN = REPO / ".design-sync" / "config.json" # the /design-sync skill's recorded target # Strict output contract for the headless claude call. PROMPT = """\ Use the DesignSync tool (read-only) to find this user's writable design-system project and report when it last changed. Steps: 1. Call DesignSync method "list_projects". 2. Pick the project whose projectId is {project_id!r} if that is non-null, otherwise the one whose name best matches {project_name!r}. 3. Output ONLY a single JSON object, no prose, no code fence: {{"projectId": "", "name": "", "updatedAt": ""}} If no matching project is found, output {{"error": ""}}. Do not write, create, or modify anything. Do not request any secret or API key. """ def load_marker() -> dict: if not MARKER.exists(): return {} return json.loads(MARKER.read_text()) def pinned_target() -> tuple[str | None, str | None]: """projectId/projectName recorded by /design-sync in .design-sync/config.json.""" if not PIN.exists(): return None, None pin = json.loads(PIN.read_text()) return pin.get("projectId"), pin.get("projectName") def parse_iso(value: str) -> datetime: # Tolerate a trailing Z. return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc) def fetch_remote_updated_at(project_id: str | None, project_name: str) -> dict: """Ask the claude-code adapter for the project's updatedAt. Returns parsed JSON.""" try: from llm_connect import create_adapter, RunConfig except ImportError as exc: # pragma: no cover - environment guidance sys.exit( f"llm-connect not importable ({exc}). Install it where this runs, e.g.\n" f" pip install -e ~/llm-connect\n" f"or pass --remote-updated to skip the LLM call." ) adapter = create_adapter("claude-code") config = RunConfig(temperature=0, max_tokens=400, timeout_seconds=180) prompt = PROMPT.format(project_id=project_id, project_name=project_name) response = adapter.execute_prompt(prompt, config) return extract_json(response.content) def extract_json(text: str) -> dict: """Pull the JSON object out of the model's reply (tolerant of stray prose).""" match = re.search(r"\{.*\}", text, re.DOTALL) if not match: sys.exit(f"Could not parse a JSON object from the model reply:\n{text[:500]}") return json.loads(match.group(0)) def record_remote_updated(iso: str) -> None: subprocess.run( ["node", "scripts/designbook-sync.mjs", "--remote-updated", iso], cwd=REPO, check=True, ) def main() -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--remote-updated", metavar="ISO", help="Use this timestamp instead of calling the LLM (test/manual).") ap.add_argument("--fail-if-stale", action="store_true", help="Exit 3 when the mirror is outdated (for automation).") ap.add_argument("--dry-run", action="store_true", help="Report only; do not update the marker.") args = ap.parse_args() marker = load_marker() last_sync = marker.get("lastSyncAt") if not last_sync: print("No /design-sync recorded yet — run /design-sync, then " "`node scripts/designbook-sync.mjs --mark-synced`. Nothing to compare against.") return 0 if args.remote_updated: remote = args.remote_updated else: pin_id, pin_name = pinned_target() project_id = marker.get("projectId") or pin_id project_name = marker.get("projectName") or pin_name or "whynot" result = fetch_remote_updated_at(project_id, project_name) if "error" in result: print(f"Could not determine remote state: {result['error']}") return 1 remote = result["updatedAt"] print(f"Claude Design project '{result.get('name', '?')}' last updated: {remote}") stale = parse_iso(remote) > parse_iso(last_sync) if not stale: print(f"Up to date — local designbook matches the project as of {last_sync}.") return 0 print(f"OUTDATED — project changed at {remote}, after the last /design-sync ({last_sync}).") if args.dry_run: print("(--dry-run: marker not updated)") else: record_remote_updated(remote) print("Recorded. `make designbook-sync` will now warn until the next /design-sync.") return 3 if args.fail_if_stale else 0 if __name__ == "__main__": raise SystemExit(main())