#!/usr/bin/env python3 """preview-sidecar: detect a newly bound local dev-server port, open a preview pane for it, and capture a timestamped screenshot receipt on demand. Standalone, stdlib-only. No agent/Claude Code modification, no network calls beyond probing 127.0.0.1, no remote tunneling. """ import argparse import hashlib import json import os import shutil import socket import subprocess import sys import tempfile import time import webbrowser from datetime import datetime, timezone from pathlib import Path DEFAULT_PORTS = [3000, 3001, 3333, 4000, 4200, 5000, 5173, 5174, 5500, 6006, 8000, 8080, 8081, 8888, 9000] MAX_PORTS = 200 CHROME_CANDIDATES = [ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", "google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge", ] def now_iso(): return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def now_stamp(): return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") def parse_ports(ports_arg, range_arg): """Build a bounded, deduped, sorted candidate port list from CLI args.""" ports = set() if ports_arg: for chunk in ports_arg.split(","): chunk = chunk.strip() if not chunk: continue port = int(chunk) if not (1 <= port <= 65535): raise ValueError(f"port out of range: {port}") ports.add(port) if range_arg: lo, _, hi = range_arg.partition("-") lo, hi = int(lo), int(hi) if not (1 <= lo <= hi <= 65535): raise ValueError(f"bad port range: {range_arg}") if hi - lo + 1 > MAX_PORTS: raise ValueError(f"port range too wide (max {MAX_PORTS} ports)") ports.update(range(lo, hi + 1)) if not ports_arg and not range_arg: ports.update(DEFAULT_PORTS) if len(ports) > MAX_PORTS: raise ValueError(f"too many candidate ports (max {MAX_PORTS})") return sorted(ports) def probe_port(host, port, timeout=0.3): """True if a TCP connect to host:port succeeds right now.""" sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(timeout) try: return sock.connect_ex((host, port)) == 0 except OSError: return False finally: sock.close() def scan_open_ports(host, ports, timeout=0.3): return {port for port in ports if probe_port(host, port, timeout)} def diff_new(previous, current): return current - previous def render_preview_html(host, port, detected_at): url = f"http://{host}:{port}" return f""" preview-sidecar: {host}:{port}
preview-sidecar detected {host}:{port} at {detected_at} open in new tab (if the frame below is blank, the dev server set X-Frame-Options/CSP — use the link)
""" def write_preview(out_dir, host, port): out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) detected_at = now_iso() path = out_dir / f"preview-{port}-{now_stamp()}.html" path.write_text(render_preview_html(host, port, detected_at)) return path, detected_at def append_event(out_dir, event): out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) with (out_dir / "events.jsonl").open("a") as f: f.write(json.dumps(event) + "\n") def find_chrome(explicit=None): if explicit: return explicit if shutil.which(explicit) or os.path.exists(explicit) else None env = os.environ.get("CHROME_PATH") if env and (os.path.exists(env) or shutil.which(env)): return env for candidate in CHROME_CANDIDATES: if os.path.exists(candidate): return candidate found = shutil.which(candidate) if found: return found return None def cmd_watch(args): host = args.host ports = parse_ports(args.ports, args.port_range) out_dir = Path(args.out) print(f"[preview-sidecar] watching {len(ports)} candidate ports on {host} " f"every {args.interval}s: {ports}") seen = set() cycles = 0 try: while args.max_cycles is None or cycles < args.max_cycles: current = scan_open_ports(host, ports, args.timeout) for port in sorted(diff_new(seen, current)): path, detected_at = write_preview(out_dir, host, port) append_event(out_dir, {"event": "detected", "host": host, "port": port, "at": detected_at, "preview": str(path)}) print(f"[preview-sidecar] detected {host}:{port} at {detected_at} " f"-> {path}") if not args.no_open: webbrowser.open(f"file://{path.resolve()}") for port in sorted(seen - current): print(f"[preview-sidecar] {host}:{port} no longer responding") seen = current cycles += 1 if args.max_cycles is None or cycles < args.max_cycles: time.sleep(args.interval) except KeyboardInterrupt: print("[preview-sidecar] stopped") return 0 def cmd_preview(args): if not probe_port(args.host, args.port, args.timeout): print(f"[preview-sidecar] nothing answered {args.host}:{args.port}", file=sys.stderr) return 1 path, detected_at = write_preview(args.out, args.host, args.port) print(f"[preview-sidecar] preview for {args.host}:{args.port} -> {path}") if not args.no_open: webbrowser.open(f"file://{path.resolve()}") return 0 def make_receipt(host, port, out_dir, chrome_path, timeout): if not probe_port(host, port, 0.5): raise RuntimeError(f"nothing answered {host}:{port}") chrome = find_chrome(chrome_path) if not chrome: raise RuntimeError( "no Chromium-family browser found; pass --chrome or set CHROME_PATH") out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) stamp = now_stamp() png_path = out_dir / f"receipt-{port}-{stamp}.png" url = f"http://{host}:{port}" argv = [chrome, "--headless=new", "--disable-gpu", "--hide-scrollbars", "--window-size=1280,900", f"--screenshot={png_path}", url] result = subprocess.run(argv, capture_output=True, text=True, timeout=timeout) if result.returncode != 0 or not png_path.exists(): raise RuntimeError( f"screenshot failed (exit {result.returncode}): {result.stderr.strip()[:400]}") data = png_path.read_bytes() if len(data) == 0: raise RuntimeError("screenshot produced an empty file") receipt = { "url": url, "host": host, "port": port, "captured_at": now_iso(), "chrome": chrome, "png": str(png_path), "sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data), } json_path = out_dir / f"receipt-{port}-{stamp}.json" json_path.write_text(json.dumps(receipt, indent=2) + "\n") receipt["json"] = str(json_path) return receipt def cmd_screenshot(args): try: receipt = make_receipt(args.host, args.port, args.out, args.chrome, args.timeout) except RuntimeError as exc: print(f"[preview-sidecar] {exc}", file=sys.stderr) return 1 print(json.dumps(receipt, indent=2)) return 0 def build_parser(): p = argparse.ArgumentParser(prog="sidecar.py", description=__doc__) sub = p.add_subparsers(dest="command", required=True) watch = sub.add_parser("watch", help="poll for newly bound local ports and auto-preview them") watch.add_argument("--host", default="127.0.0.1") watch.add_argument("--ports", default=None, help="comma-separated port list") watch.add_argument("--port-range", default=None, help="e.g. 3000-3010") watch.add_argument("--interval", type=float, default=2.0) watch.add_argument("--timeout", type=float, default=0.3) watch.add_argument("--max-cycles", type=int, default=None, help="stop after N poll cycles (default: run until Ctrl+C)") watch.add_argument("--out", default=str(Path(tempfile.gettempdir()) / "preview-sidecar")) watch.add_argument("--no-open", action="store_true", help="don't launch a browser tab") watch.set_defaults(func=cmd_watch) preview = sub.add_parser("preview", help="manually open a preview pane for one port right now") preview.add_argument("--host", default="127.0.0.1") preview.add_argument("--port", type=int, required=True) preview.add_argument("--timeout", type=float, default=0.3) preview.add_argument("--out", default=str(Path(tempfile.gettempdir()) / "preview-sidecar")) preview.add_argument("--no-open", action="store_true") preview.set_defaults(func=cmd_preview) screenshot = sub.add_parser("screenshot", help="capture a timestamped screenshot receipt of one port") screenshot.add_argument("--host", default="127.0.0.1") screenshot.add_argument("--port", type=int, required=True) screenshot.add_argument("--out", default=str(Path(tempfile.gettempdir()) / "preview-sidecar")) screenshot.add_argument("--chrome", default=None, help="path to a Chromium-family browser binary") screenshot.add_argument("--timeout", type=float, default=20.0) screenshot.set_defaults(func=cmd_screenshot) return p def main(argv=None): args = build_parser().parse_args(argv) try: return args.func(args) except ValueError as exc: print(f"[preview-sidecar] {exc}", file=sys.stderr) return 2 if __name__ == "__main__": sys.exit(main())