Author the design language once in the canonical React designbook and project it
one-way onto each stack: React -> designbook/ -> ir/ -> adapters/<stack>/.
Phase 0 — contracts & governance (T01-T03):
- ir/SCHEMA.md + ir/schema/{component,tokens}.schema.json — neutral IR contract
(W3C DTCG tokens; React prop -> HTML attribute mapping; non-portable props flagged).
- adapters/ADAPTER_CONTRACT.md — inputs, drift-report + parity-result shapes,
idempotency rules, CI exit codes (0 ok / 2 usage / 3 drift / 4 parity / 5 internal).
- .claude/rules/designbook-propagation.md + DesignSystemIntroduction.md §5.1 —
one-way directionality + drift-resolution workflow.
T04 — canonical React designbook + the missing pull tool:
- The bundled /design-sync skill only PUSHES repo->cloud; it cannot populate
designbook/. Added scripts/designbook_pull.py + `make designbook-pull`, which drives
the local claude binary headless (acceptEdits) so DesignSync fetch+write runs in a
subprocess (contents never hit the orchestrator's context). Pulled 44 files;
excludes the _whynot-design-seed/ self-copy. Corrected the docs that wrongly called
/design-sync the pull.
T05 — IR extractor (scripts/ir-extract.mjs + `make ir`):
- ir/tokens.json (80 tokens, DTCG, var() -> {ref} alias resolution); ir/components/*.json
(10 contracts parsed from .jsx signatures: enum/boolean/number inference, prop->attr
map, style/callback marked non-portable); ir/exemplars/*.
T06 — Lit token adapter (adapters/lit/ + `make adapt-lit`):
- Full-gen tokens into src/styles/colors_and_type.css :root (marker-bounded, idempotent
no-op on re-run; hand-authored type CSS preserved).
NOTE: token regen synced Lit to canonical React — fonts IBM Plex -> system stacks and 8
status tokens added. This is a VISUAL change: review and run `pnpm test:visual:update`
before merge. Remaining: T07 scaffold+drift, T08 parity, T09 runbook, T10 2nd-adapter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
227 lines
9.6 KiB
Python
227 lines
9.6 KiB
Python
#!/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 <uuid> # 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": "<id>", "name": "<name>", "updatedAt": "<ISO-8601>",
|
|
"written": ["<relpath>", ...], "skipped": ["<relpath>", ...]}}
|
|
On failure output {{"error": "<short reason>"}}.
|
|
"""
|
|
|
|
|
|
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())
|