#!/usr/bin/env python3 """Pull the canonical React designbook from Claude Design into ``designbook/``. WHYNOT-WP-0002 needs a one-way flow: the React designbook in Claude Design is the source of truth, and ``make ir`` extracts the technology-neutral blueprint from a *local mirror* of it (``designbook/``). The bundled ``/design-sync`` skill only goes the other way (it *pushes* a repo up to Claude Design), so it cannot populate ``designbook/``. This script is the missing **pull** half. The only thing that can read your Claude Design project is the local ``claude`` binary, which has the DesignSync tool over your claude.ai login. This script drives it directly in headless mode (``claude --print --permission-mode acceptEdits``): the subprocess fetches AND writes the files itself, in *its* context, and returns only a small JSON manifest. The (potentially large) file contents never pass through the orchestrating agent's context, so the pull is cheap no matter how many files it moves. ``acceptEdits`` is required because a plain ``claude --print`` (e.g. the llm-connect claude-code adapter used by check_designbook_staleness.py) auto-denies ``Write`` in non-interactive mode — fine for that read-only check, but this pull must write. No secret goes in the prompt — DesignSync authenticates through the local login (see .claude/rules/credential-routing.md). python scripts/designbook_pull.py # pull, then stamp freshness python scripts/designbook_pull.py --project # override the target project python scripts/designbook_pull.py --dry-run # print the plan; fetch nothing python scripts/designbook_pull.py --no-stamp # skip the --mark-synced step What is pulled is governed by ``designbook/.design-pull.json`` (created with sane defaults on first run): ``include``/``exclude`` glob lists over the project's paths. The defaults take the React ui-kit, the preview/exemplar cards, the manifest and the style/token layers, and deliberately EXCLUDE ``_whynot-design-seed/**`` (a copy of this very repo that lives in the cloud project and must not shadow the real repo). """ from __future__ import annotations import argparse import json import os import re import subprocess import sys from pathlib import Path REPO = Path(__file__).resolve().parent.parent DESIGNBOOK = REPO / "designbook" MARKER = DESIGNBOOK / ".design-sync.json" PIN = REPO / ".design-sync" / "config.json" # the /design-sync skill's pin PULL_CONFIG = DESIGNBOOK / ".design-pull.json" # what this script mirrors # Sane first-run defaults. Editable; committed so the pull is reproducible. DEFAULT_PULL_CONFIG = { "comment": "Globs (over Claude Design project paths) that designbook_pull.py mirrors " "into designbook/. Exclude _whynot-design-seed/** — it is a copy of THIS " "repo living in the cloud project and must not shadow the real source.", "include": [ "ui_kits/**", "preview/**", "_ds_manifest.json", "_ds_bundle.js", "styles.css", "colors_and_type.css", ], "exclude": [ "_whynot-design-seed/**", "uploads/**", "_check/**", ".thumbnail", "assets/**", ], } # Strict output contract for the headless claude call. The subprocess does ALL the # fetching and writing; it returns only a manifest so file bytes never reach us. PROMPT = """\ You have the DesignSync tool (claude.ai/design) and Write/Bash tools. Mirror selected files from a Claude Design project into a local directory. Do NOT modify the remote project (no write_files/delete_files/create_project/finalize_plan). Do not request any secret or API key. Target project: pick projectId {project_id!r} if non-null, else the writable project whose name best matches {project_name!r} (use DesignSync "list_projects"). Local destination root (absolute): {dest_root!r} Selection — mirror every project path that matches ANY of these include globs: {include} …and matches NONE of these exclude globs: {exclude} (`**` matches any depth, `*` matches within one path segment.) Steps: 1. DesignSync "list_files" on the chosen project. 2. Compute the selected set per the include/exclude globs above. 3. For each selected path: DesignSync "get_file", then Write its content to {dest_root!r} + "/" + path (create parent dirs; preserve the relative path exactly; for base64/binary files decode before writing). 4. Output ONLY a single JSON object, no prose, no code fence: {{"projectId": "", "name": "", "updatedAt": "", "written": ["", ...], "skipped": ["", ...]}} On failure output {{"error": ""}}. """ def load_json(path: Path) -> dict: return json.loads(path.read_text()) if path.exists() else {} def ensure_pull_config() -> dict: if not PULL_CONFIG.exists(): DESIGNBOOK.mkdir(parents=True, exist_ok=True) PULL_CONFIG.write_text(json.dumps(DEFAULT_PULL_CONFIG, indent=2) + "\n") print(f"Wrote default pull config: {PULL_CONFIG.relative_to(REPO)}") return load_json(PULL_CONFIG) def target_project(cli_project: str | None) -> tuple[str | None, str]: """Resolve (projectId, projectName) from CLI > marker > skill pin.""" marker, pin = load_json(MARKER), load_json(PIN) project_id = cli_project or marker.get("projectId") or pin.get("projectId") project_name = marker.get("projectName") or pin.get("projectName") or "WhyNot Design System" return project_id, project_name def extract_json(text: str) -> dict: 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[:600]}") return json.loads(match.group(0)) def resolve_claude_cli() -> str: """Mirror the llm-connect adapter's resolution: env override, else ~/.local/bin.""" configured = os.environ.get("CLAUDE_CLI_PATH") if configured: return configured local_cli = Path.home() / ".local" / "bin" / "claude" return str(local_cli) if local_cli.exists() else "claude" def run_pull(project_id: str | None, project_name: str, cfg: dict) -> dict: prompt = PROMPT.format( project_id=project_id, project_name=project_name, dest_root=str(DESIGNBOOK), include="\n".join(f" - {g}" for g in cfg.get("include", [])), exclude="\n".join(f" - {g}" for g in cfg.get("exclude", [])), ) # acceptEdits auto-approves the subprocess's Write calls (it creates parent dirs); # DesignSync reads are already permitted under default policy. cwd=REPO so any # relative reasoning stays inside the repo, though dest paths are absolute. cmd = [resolve_claude_cli(), "--print", "--permission-mode", "acceptEdits"] try: result = subprocess.run( cmd, input=prompt, cwd=REPO, capture_output=True, text=True, timeout=1800, ) except FileNotFoundError: sys.exit("Could not find the `claude` CLI. Set CLAUDE_CLI_PATH or add it to PATH.") except subprocess.TimeoutExpired: sys.exit("claude CLI timed out after 1800s during the pull.") if result.returncode != 0: sys.exit(f"claude CLI exited {result.returncode}:\n{result.stderr[:600]}") return extract_json(result.stdout) def stamp(project_id: str | None, project_name: str, updated_at: str | None) -> None: cmd = ["node", "scripts/designbook-sync.mjs", "--mark-synced"] if project_id: cmd += ["--project", project_id] if project_name: cmd += ["--project-name", project_name] if updated_at: cmd += ["--remote-updated", updated_at] # Flags match designbook-sync.mjs --mark-synced [--remote-updated] [--project] [--project-name]. subprocess.run(cmd, cwd=REPO, check=False) def main() -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--project", metavar="UUID", help="Override the target project id.") ap.add_argument("--dry-run", action="store_true", help="Print the resolved target + selection plan; fetch nothing.") ap.add_argument("--no-stamp", action="store_true", help="Do not run designbook-sync.mjs --mark-synced afterwards.") args = ap.parse_args() cfg = ensure_pull_config() project_id, project_name = target_project(args.project) print(f"Target : {project_name} ({project_id or 'by name match'})") print(f"Dest : {DESIGNBOOK.relative_to(REPO)}/") print(f"Include: {', '.join(cfg.get('include', [])) or '(none)'}") print(f"Exclude: {', '.join(cfg.get('exclude', [])) or '(none)'}") if args.dry_run: print("\n(--dry-run: no DesignSync calls made, nothing written)") return 0 result = run_pull(project_id, project_name, cfg) if "error" in result: print(f"\nPull failed: {result['error']}") return 1 written = result.get("written", []) skipped = result.get("skipped", []) print(f"\nPulled {len(written)} file(s) into {DESIGNBOOK.relative_to(REPO)}/" f" (skipped {len(skipped)}).") for path in written[:40]: print(f" + {path}") if len(written) > 40: print(f" … and {len(written) - 40} more") if not written: print("Nothing was written — check the include/exclude globs or the project.") return 1 if not args.no_stamp: stamp(result.get("projectId") or project_id, result.get("name") or project_name, result.get("updatedAt")) print("Stamped freshness. Next: review the designbook/ diff, then `make ir`.") return 0 if __name__ == "__main__": raise SystemExit(main())