Build standalone preview-sidecar tool and receipt-styled landing page

Localhost port watcher: detects a newly bound dev-server port via
bounded TCP probing, opens an iframe preview pane, and captures a
one-click timestamped screenshot receipt via headless Chrome. No
Claude Code changes, stdlib-only, 16 passing tests plus a real
end-to-end dogfood (demo.py) with a genuine recorded screenshot.

Landing page at ventures/preview-sidecar/site/index.html uses a
receipt/ticket visual identity (narrow thermal-paper column, dotted
rules, wax stamp, monospace totals) distinct from the finishline
venture, with one above-the-fold signup CTA and an honest free/paid
pricing section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Sonnet 5 committed 2026-10-10 10:36:31 -07:00
1 parent e25b051a16
commit 029323fe97
14 files changed
+1356

No files matched your search

+1
View File
@@ -0,0 +1 @@
__pycache__/
+109
View File
@@ -0,0 +1,109 @@
# preview-sidecar
A dependency-free localhost port watcher: it notices when you start a dev
server, opens a preview pane pointed at it, and can stamp a timestamped
screenshot receipt of what was actually on screen.
Inspired by [anthropics/claude-code#53997](https://github.com/anthropics/claude-code/issues/53997)
("Bring back local preview for agent-built apps"), closed as not planned.
This does not modify Claude Code or any agent CLI. It is a standalone
sidecar: a Python script you run next to whatever started the dev server.
## Problem
An agent (or you) runs `npm run dev`, it binds `localhost:5173`, and nothing
shows you that it's there. You tab-hunt for the port, open it by hand, and if
you want proof of what rendered, you take a screenshot yourself. This watches
a bounded list of common dev ports, opens a preview the moment one answers,
and gives you a one-command way to capture dated evidence of what it showed.
## Install
Requires Python 3.9+. No dependencies, no install step — copy `sidecar.py`
anywhere. The `screenshot` command additionally needs a Chromium-family
browser (Chrome, Chromium, or Edge) already installed on the machine; it is
not bundled.
```sh
# From the notplanned checkout:
python3 ventures/preview-sidecar/demo.py
python3 -m unittest discover -s ventures/preview-sidecar -p 'test_*.py' -v
```
For your own project, copy `sidecar.py` to a trusted location and run it
from there, e.g. alongside `npm run dev`.
## Usage
```sh
# Watch the default common dev ports (3000, 5173, 8080, ...) and auto-open a
# preview pane the moment one starts answering.
python3 sidecar.py watch
# Watch specific ports or a range instead.
python3 sidecar.py watch --ports 3000,5173
python3 sidecar.py watch --port-range 3000-3010
# One-click screenshot receipt of whatever is on a port right now: a dated
# PNG plus a JSON sidecar (url, timestamp, sha256, browser used).
python3 sidecar.py screenshot --port 3000
# Manually open a preview pane for a port without waiting for detection.
python3 sidecar.py preview --port 3000
```
`watch` polls candidate ports with a plain TCP connect (no `lsof`/`netstat`
dependency). The first time a port starts answering, it writes a small local
HTML file with an iframe pointed at `http://127.0.0.1:<port>` and opens it in
your default browser (`--no-open` to just log the event instead), plus
appends a JSON line to `events.jsonl` in `--out` so detections are
inspectable after the fact.
`screenshot` launches the browser headless, saves `receipt-<port>-<time>.png`
and a matching `.json` receipt, and prints the receipt. See
[`demo-receipt.png`](demo-receipt.png) / [`demo-receipt.json`](demo-receipt.json)
for a real example produced by `demo.py`.
## Recorded dogfood
`demo.py` starts a throwaway one-page site, runs `sidecar.py watch` against
it (no browser opened, so it's safe to run unattended), confirms the
detection event and preview file, then runs `sidecar.py screenshot` with a
real headless Chrome and verifies the PNG and its sha256. No mocks.
[Recorded output](demo.txt).
The demo's throwaway server runs as an in-process thread rather than a
`python -m http.server` subprocess. This repo is itself built and tested
inside Claude Code's own sandboxed Bash tool, which puts a freshly spawned
subprocess's listening socket in a network namespace its own parent can't
reach back into — a property of *this* build environment, not of normal
terminals. A thread-based listener sidesteps that quirk so the demo can still
exercise the real detect → preview → screenshot path end to end, including a
real TCP probe from a separate process and a real headless-Chrome screenshot.
On an ordinary dev machine, `npm run dev` / `vite` / etc. are plain reachable
subprocesses and this workaround doesn't apply.
## Limits and trust
- Detection is TCP-connect probing of a bounded candidate port list (default
15 common dev ports, or an explicit `--ports`/`--port-range` up to 200
ports), not a system-wide socket enumeration. It cannot see which process
owns a port — "something answered a connect" is the whole signal. A
non-HTTP service on a watched port will still trigger a preview attempt.
- Polling has a detection delay (`--interval`, default 2s) and will miss a
port that opens and closes between polls.
- The preview iframe can be blocked by a dev server's own
`X-Frame-Options`/CSP; the generated preview page always includes a plain
"open in new tab" link as a fallback.
- `screenshot` requires a Chromium-family browser found via `--chrome`,
`CHROME_PATH`, or a short list of common install paths. No browser means a
clear error, not a silent skip.
- Loopback (`127.0.0.1`) only by default. No remote tunneling, no ngrok-style
exposure, no orchestration of multiple dev servers — one detected port
gets one preview and, on request, one screenshot.
- `watch` is a foreground poll loop; background it yourself (`&`, tmux,
`nohup`, a launchd/systemd unit) if you want it to persist. There is no
daemon manager here.
- Nothing is uploaded anywhere. Screenshots and events stay in `--out`
(default a `preview-sidecar` directory under the OS temp dir) until you
move or delete them.
@@ -0,0 +1,10 @@
{
"url": "http://127.0.0.1:61125",
"host": "127.0.0.1",
"port": 61125,
"captured_at": "2026-10-10T17:29:17Z",
"chrome": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"png": "demo-receipt.png",
"sha256": "6e13265f215eb1e39245011165192d703d99501ffd9c08499b513b06682fb3d5",
"bytes": 16208
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Real end-to-end dogfood: start a throwaway dev server, detect it with
sidecar.py watch (no browser opened), then capture a real screenshot receipt
with Chromium headless. No mocks. Cleans up only its own temp resources.
The throwaway server runs as an in-process thread rather than a `python -m
http.server` subprocess: this project is itself built and tested inside
Claude Code's own sandboxed Bash tool, which puts a fresh subprocess's
listening socket in a network namespace its own parent can't reach -- a
sandbox artifact of *this* harness, not of real terminals. A thread-based
listener sidesteps that so the demo can prove the real detect -> preview ->
screenshot path with an actual TCP probe from a separate process and an
actual headless-Chrome screenshot. On a normal dev machine, `npm run dev` /
`vite` / etc. are plain reachable subprocesses and this workaround is moot.
"""
import http.server
import json
import shutil
import subprocess
import sys
import tempfile
import threading
from pathlib import Path
ROOT = Path(__file__).parent.resolve()
SIDECAR = ROOT / "sidecar.py"
class _QuietHandler(http.server.SimpleHTTPRequestHandler):
def log_message(self, *args):
pass
def start_demo_server(site_dir):
handler = lambda *a, **kw: _QuietHandler(*a, directory=site_dir, **kw)
httpd = http.server.HTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
return httpd
def main():
with tempfile.TemporaryDirectory(prefix="preview-sidecar-demo-") as site_dir, \
tempfile.TemporaryDirectory(prefix="preview-sidecar-out-") as out_dir:
(Path(site_dir) / "index.html").write_text(
"<html><body style='font-family:sans-serif;margin:0;background:#1d4ed8;color:#fff'>"
"<div style='padding:48px 40px'>"
"<h1 style='margin:0 0 12px;font-size:40px'>Hello from dev server</h1>"
"<p style='margin:0;font-size:18px;opacity:.9'>Started by preview-sidecar demo.py</p>"
"</div></body></html>")
httpd = start_demo_server(site_dir)
port = httpd.server_port
print(f"$ (throwaway dev server listening on 127.0.0.1:{port}, serving a one-page site)")
try:
print(f"$ python3 sidecar.py watch --ports {port} --interval 0.3 --max-cycles 3 --no-open --out {out_dir}")
watch = subprocess.run(
[sys.executable, str(SIDECAR), "watch", "--ports", str(port),
"--interval", "0.3", "--max-cycles", "3", "--no-open", "--out", out_dir],
capture_output=True, text=True, timeout=30)
print(watch.stdout.strip())
assert f"detected 127.0.0.1:{port}" in watch.stdout, watch.stdout
events = (Path(out_dir) / "events.jsonl").read_text().strip().splitlines()
assert len(events) == 1, events
event = json.loads(events[0])
assert event["port"] == port and event["event"] == "detected", event
preview_path = Path(event["preview"])
assert preview_path.exists()
assert f"http://127.0.0.1:{port}" in preview_path.read_text()
print(f" preview pane written: {preview_path.name} (iframe -> http://127.0.0.1:{port})")
chrome = shutil.which("google-chrome") or shutil.which("chromium") or \
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
print(f"$ python3 sidecar.py screenshot --port {port} --out {out_dir} --chrome '{chrome}'")
shot = subprocess.run(
[sys.executable, str(SIDECAR), "screenshot", "--port", str(port),
"--out", out_dir, "--chrome", chrome],
capture_output=True, text=True, timeout=30)
print(shot.stdout.strip() or shot.stderr.strip())
if shot.returncode == 0:
receipt = json.loads(shot.stdout)
assert Path(receipt["png"]).stat().st_size > 0
print(f" receipt verified: {receipt['bytes']} bytes, sha256={receipt['sha256'][:12]}...")
else:
print(" (no Chromium-family browser on this host; screenshot step skipped honestly)")
finally:
httpd.shutdown()
httpd.server_close()
print(f"$ (dev server on {port} stopped)")
if __name__ == "__main__":
main()
+27
View File
@@ -0,0 +1,27 @@
$ python3 demo.py
$ (throwaway dev server listening on 127.0.0.1:61125, serving a one-page site)
$ python3 sidecar.py watch --ports 61125 --interval 0.3 --max-cycles 3 --no-open --out /tmp/preview-sidecar-out
[preview-sidecar] watching 1 candidate ports on 127.0.0.1 every 0.3s: [61125]
[preview-sidecar] detected 127.0.0.1:61125 at 2026-10-10T17:29:17Z -> /tmp/preview-sidecar-out/preview-61125-20261010T172917Z.html
preview pane written: preview-61125-20261010T172917Z.html (iframe -> http://127.0.0.1:61125)
$ python3 sidecar.py screenshot --port 61125 --out /tmp/preview-sidecar-out --chrome '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
{
"url": "http://127.0.0.1:61125",
"host": "127.0.0.1",
"port": 61125,
"captured_at": "2026-10-10T17:29:17Z",
"chrome": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"png": "/tmp/preview-sidecar-out/receipt-61125-20261010T172915Z.png",
"sha256": "6e13265f215eb1e39245011165192d703d99501ffd9c08499b513b06682fb3d5",
"bytes": 16208,
"json": "/tmp/preview-sidecar-out/receipt-61125-20261010T172915Z.json"
}
receipt verified: 16208 bytes, sha256=6e13265f215e...
$ (dev server on 61125 stopped)
$ python3 -m unittest discover -s ventures/preview-sidecar -p 'test_*.py' -v
...
----------------------------------------------------------------------
Ran 16 tests in 0.003s
OK
+276
View File
@@ -0,0 +1,276 @@
#!/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"""<!doctype html>
<html><head><meta charset="utf-8">
<title>preview-sidecar: {host}:{port}</title>
<style>
html,body{{margin:0;height:100%;font-family:ui-monospace,Menlo,monospace}}
.bar{{display:flex;gap:12px;align-items:center;padding:8px 12px;background:#111;color:#eee;font-size:13px}}
.bar a{{color:#9fd;text-decoration:none}}
iframe{{display:block;width:100%;height:calc(100% - 37px);border:0}}
</style></head>
<body>
<div class="bar">
<strong>preview-sidecar</strong>
<span>detected {host}:{port} at {detected_at}</span>
<a href="{url}" target="_blank" rel="noopener">open in new tab</a>
<span>(if the frame below is blank, the dev server set X-Frame-Options/CSP — use the link)</span>
</div>
<iframe src="{url}" sandbox="allow-scripts allow-same-origin allow-forms"></iframe>
</body></html>
"""
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())
+109
View File
@@ -0,0 +1,109 @@
# preview-sidecar
A dependency-free localhost port watcher: it notices when you start a dev
server, opens a preview pane pointed at it, and can stamp a timestamped
screenshot receipt of what was actually on screen.
Inspired by [anthropics/claude-code#53997](https://github.com/anthropics/claude-code/issues/53997)
("Bring back local preview for agent-built apps"), closed as not planned.
This does not modify Claude Code or any agent CLI. It is a standalone
sidecar: a Python script you run next to whatever started the dev server.
## Problem
An agent (or you) runs `npm run dev`, it binds `localhost:5173`, and nothing
shows you that it's there. You tab-hunt for the port, open it by hand, and if
you want proof of what rendered, you take a screenshot yourself. This watches
a bounded list of common dev ports, opens a preview the moment one answers,
and gives you a one-command way to capture dated evidence of what it showed.
## Install
Requires Python 3.9+. No dependencies, no install step — copy `sidecar.py`
anywhere. The `screenshot` command additionally needs a Chromium-family
browser (Chrome, Chromium, or Edge) already installed on the machine; it is
not bundled.
```sh
# From the notplanned checkout:
python3 ventures/preview-sidecar/demo.py
python3 -m unittest discover -s ventures/preview-sidecar -p 'test_*.py' -v
```
For your own project, copy `sidecar.py` to a trusted location and run it
from there, e.g. alongside `npm run dev`.
## Usage
```sh
# Watch the default common dev ports (3000, 5173, 8080, ...) and auto-open a
# preview pane the moment one starts answering.
python3 sidecar.py watch
# Watch specific ports or a range instead.
python3 sidecar.py watch --ports 3000,5173
python3 sidecar.py watch --port-range 3000-3010
# One-click screenshot receipt of whatever is on a port right now: a dated
# PNG plus a JSON sidecar (url, timestamp, sha256, browser used).
python3 sidecar.py screenshot --port 3000
# Manually open a preview pane for a port without waiting for detection.
python3 sidecar.py preview --port 3000
```
`watch` polls candidate ports with a plain TCP connect (no `lsof`/`netstat`
dependency). The first time a port starts answering, it writes a small local
HTML file with an iframe pointed at `http://127.0.0.1:<port>` and opens it in
your default browser (`--no-open` to just log the event instead), plus
appends a JSON line to `events.jsonl` in `--out` so detections are
inspectable after the fact.
`screenshot` launches the browser headless, saves `receipt-<port>-<time>.png`
and a matching `.json` receipt, and prints the receipt. See
[`demo-receipt.png`](demo-receipt.png) / [`demo-receipt.json`](demo-receipt.json)
for a real example produced by `demo.py`.
## Recorded dogfood
`demo.py` starts a throwaway one-page site, runs `sidecar.py watch` against
it (no browser opened, so it's safe to run unattended), confirms the
detection event and preview file, then runs `sidecar.py screenshot` with a
real headless Chrome and verifies the PNG and its sha256. No mocks.
[Recorded output](demo.txt).
The demo's throwaway server runs as an in-process thread rather than a
`python -m http.server` subprocess. This repo is itself built and tested
inside Claude Code's own sandboxed Bash tool, which puts a freshly spawned
subprocess's listening socket in a network namespace its own parent can't
reach back into — a property of *this* build environment, not of normal
terminals. A thread-based listener sidesteps that quirk so the demo can still
exercise the real detect → preview → screenshot path end to end, including a
real TCP probe from a separate process and a real headless-Chrome screenshot.
On an ordinary dev machine, `npm run dev` / `vite` / etc. are plain reachable
subprocesses and this workaround doesn't apply.
## Limits and trust
- Detection is TCP-connect probing of a bounded candidate port list (default
15 common dev ports, or an explicit `--ports`/`--port-range` up to 200
ports), not a system-wide socket enumeration. It cannot see which process
owns a port — "something answered a connect" is the whole signal. A
non-HTTP service on a watched port will still trigger a preview attempt.
- Polling has a detection delay (`--interval`, default 2s) and will miss a
port that opens and closes between polls.
- The preview iframe can be blocked by a dev server's own
`X-Frame-Options`/CSP; the generated preview page always includes a plain
"open in new tab" link as a fallback.
- `screenshot` requires a Chromium-family browser found via `--chrome`,
`CHROME_PATH`, or a short list of common install paths. No browser means a
clear error, not a silent skip.
- Loopback (`127.0.0.1`) only by default. No remote tunneling, no ngrok-style
exposure, no orchestration of multiple dev servers — one detected port
gets one preview and, on request, one screenshot.
- `watch` is a foreground poll loop; background it yourself (`&`, tmux,
`nohup`, a launchd/systemd unit) if you want it to persist. There is no
daemon manager here.
- Nothing is uploaded anywhere. Screenshots and events stay in `--out`
(default a `preview-sidecar` directory under the OS temp dir) until you
move or delete them.
@@ -0,0 +1,10 @@
{
"url": "http://127.0.0.1:61125",
"host": "127.0.0.1",
"port": 61125,
"captured_at": "2026-10-10T17:29:17Z",
"chrome": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"png": "demo-receipt.png",
"sha256": "6e13265f215eb1e39245011165192d703d99501ffd9c08499b513b06682fb3d5",
"bytes": 16208
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+27
View File
@@ -0,0 +1,27 @@
$ python3 demo.py
$ (throwaway dev server listening on 127.0.0.1:61125, serving a one-page site)
$ python3 sidecar.py watch --ports 61125 --interval 0.3 --max-cycles 3 --no-open --out /tmp/preview-sidecar-out
[preview-sidecar] watching 1 candidate ports on 127.0.0.1 every 0.3s: [61125]
[preview-sidecar] detected 127.0.0.1:61125 at 2026-10-10T17:29:17Z -> /tmp/preview-sidecar-out/preview-61125-20261010T172917Z.html
preview pane written: preview-61125-20261010T172917Z.html (iframe -> http://127.0.0.1:61125)
$ python3 sidecar.py screenshot --port 61125 --out /tmp/preview-sidecar-out --chrome '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
{
"url": "http://127.0.0.1:61125",
"host": "127.0.0.1",
"port": 61125,
"captured_at": "2026-10-10T17:29:17Z",
"chrome": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"png": "/tmp/preview-sidecar-out/receipt-61125-20261010T172915Z.png",
"sha256": "6e13265f215eb1e39245011165192d703d99501ffd9c08499b513b06682fb3d5",
"bytes": 16208,
"json": "/tmp/preview-sidecar-out/receipt-61125-20261010T172915Z.json"
}
receipt verified: 16208 bytes, sha256=6e13265f215e...
$ (dev server on 61125 stopped)
$ python3 -m unittest discover -s ventures/preview-sidecar -p 'test_*.py' -v
...
----------------------------------------------------------------------
Ran 16 tests in 0.003s
OK
+305
View File
@@ -0,0 +1,305 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="description" content="preview-sidecar: a localhost port watcher that opens a preview the moment your dev server binds, and stamps a timestamped screenshot receipt on demand.">
<title>preview-sidecar · notplanned</title>
<style>
:root{
--counter:#1d1915; --counter-2:#272019;
--paper:#faf6ec; --paper-2:#f2ecdd;
--ink:#231f19; --fade:#8d8371; --line:#c9bfa8;
--stamp:#a8392b; --ok:#3c6e4f;
}
*{box-sizing:border-box}
html,body{margin:0;background:var(--counter);color:var(--ink);overflow-x:hidden}
body{
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;
background:
radial-gradient(1200px 500px at 50% -10%, var(--counter-2), transparent),
var(--counter);
padding:48px 14px 80px;
}
a{color:inherit}
a.plain{text-decoration-color:var(--fade)}
.receipt{
position:relative;
max-width:420px;
margin:0 auto;
background:var(--paper);
background-image:repeating-linear-gradient(0deg, rgba(0,0,0,.015) 0 1px, transparent 1px 3px);
color:var(--ink);
padding:34px 26px 26px;
box-shadow:0 30px 60px -20px rgba(0,0,0,.65), 0 2px 0 rgba(0,0,0,.2);
-webkit-mask-image:
radial-gradient(circle 7px at 10px 0, transparent 7px, #000 7.5px),
radial-gradient(circle 7px at 30px 0, transparent 7px, #000 7.5px),
radial-gradient(circle 7px at 50px 0, transparent 7px, #000 7.5px),
radial-gradient(circle 7px at 70px 0, transparent 7px, #000 7.5px),
radial-gradient(circle 7px at 90px 0, transparent 7px, #000 7.5px);
mask-image:
radial-gradient(circle 7px at 10px 0, transparent 7px, #000 7.5px),
radial-gradient(circle 7px at 30px 0, transparent 7px, #000 7.5px),
radial-gradient(circle 7px at 50px 0, transparent 7px, #000 7.5px),
radial-gradient(circle 7px at 70px 0, transparent 7px, #000 7.5px),
radial-gradient(circle 7px at 90px 0, transparent 7px, #000 7.5px);
}
.torn{
position:relative; height:16px; margin:0 -26px 18px;
background:
linear-gradient(135deg, var(--paper) 25%, transparent 25.5%) 0 0,
linear-gradient(-135deg, var(--paper) 25%, transparent 25.5%) 0 0;
background-size:16px 16px; background-position:0 0, 0 0;
filter:drop-shadow(0 -2px 0 rgba(0,0,0,0));
}
.torn.bottom{margin:18px -26px 0;transform:scaleY(-1)}
header.shop{text-align:center;margin-bottom:14px}
header.shop .name{font-weight:800;letter-spacing:.14em;font-size:15px}
header.shop .tag{color:var(--fade);font-size:11px;letter-spacing:.08em;margin-top:4px}
.rule{border:0;border-top:1px dashed var(--line);margin:18px 0}
.rule.bold{border-top-style:solid;border-top-width:2px;border-color:var(--ink)}
.stamp{
position:absolute; top:20px; right:16px;
width:82px;height:82px;border-radius:50%;
border:2px dashed var(--stamp); color:var(--stamp);
display:flex;align-items:center;justify-content:center;text-align:center;
font-size:8.3px;font-weight:800;letter-spacing:.03em;line-height:1.4;
transform:rotate(-12deg);
opacity:.9; mix-blend-mode:multiply;
animation:thud .4s ease-out both;
}
@keyframes thud{0%{transform:rotate(-12deg) scale(1.5)}100%{transform:rotate(-12deg) scale(1)}}
.title{text-align:center;margin:6px 0 2px}
.title h1{
font-size:26px;letter-spacing:.02em;margin:0;
text-transform:uppercase;
}
.title p{color:var(--fade);font-size:12.5px;margin:8px 0 0;line-height:1.5}
.meta{font-size:11px;color:var(--fade);line-height:1.9;overflow-wrap:break-word}
.meta b{color:var(--ink);font-weight:700}
.cta{margin:20px 0 6px}
.cta .label{font-size:11px;letter-spacing:.14em;color:var(--fade);text-align:center;margin-bottom:10px}
form.join{display:flex;flex-direction:column;gap:8px}
.field{display:flex;flex-direction:column;gap:3px}
.field label{font-size:10px;letter-spacing:.08em;color:var(--fade)}
input[type=email],input[type=text],select,textarea{
font-family:inherit;font-size:13px;padding:10px 10px;
border:1px solid var(--ink);background:var(--paper-2);color:var(--ink);
border-radius:0;
}
textarea{resize:vertical;min-height:44px}
.hp{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}
button.buy{
margin-top:4px;padding:13px 10px;border:2px solid var(--ink);background:var(--ink);
color:var(--paper);font-family:inherit;font-weight:800;letter-spacing:.1em;font-size:12.5px;
text-transform:uppercase;cursor:pointer;
}
button.buy:hover{background:var(--stamp);border-color:var(--stamp)}
.snip{display:flex;align-items:center;gap:8px;color:var(--fade);font-size:11px;margin:16px 0 2px}
.snip .dash{flex:1;border-top:1px dashed var(--fade)}
.thanks{display:none;text-align:center;padding:16px 8px;border:1px dashed var(--ok);color:var(--ok);font-size:12.5px}
.receipt.joined .thanks{display:block}
.receipt.joined form.join{display:none}
.receipt.joined .cta .label{display:none}
.items{font-size:12.5px;line-height:2}
.item{display:flex;gap:8px;align-items:baseline}
.item>span:first-child{min-width:0}
.item .dots{flex:1;border-bottom:1px dotted var(--line);transform:translateY(-3px)}
.item .ok{color:var(--ok);font-weight:800}
h2.sec{font-size:12px;letter-spacing:.14em;text-transform:uppercase;margin:0 0 10px;text-align:center;color:var(--fade)}
.ba{display:grid;grid-template-columns:1fr;gap:10px;margin-bottom:4px}
.ba pre{margin:0}
.ba .cap{font-size:10px;letter-spacing:.1em;color:var(--fade);margin-bottom:4px}
pre{
background:var(--paper-2);border:1px solid var(--line);padding:12px;
font-size:11.5px;line-height:1.55;overflow:auto;white-space:pre-wrap;word-break:break-word;
margin:0;
}
code{font-family:inherit}
.exhibit{margin:14px 0 4px;text-align:center}
.exhibit img{
max-width:88%;border:6px solid #fff;outline:1px solid var(--line);
box-shadow:0 10px 22px -10px rgba(0,0,0,.45);transform:rotate(-1.3deg);
}
.exhibit .cap{font-size:10.5px;color:var(--fade);margin-top:10px;letter-spacing:.03em}
.exhibit .cap code{font-size:9.5px}
.totals{font-size:12.5px;line-height:1.9}
.trow{display:flex;gap:8px;align-items:baseline}
.trow>span:first-child{min-width:0}
.trow .dots{flex:1;border-bottom:1px dotted var(--line);transform:translateY(-3px)}
.trow .price{font-weight:800}
.tfoot{display:flex;justify-content:space-between;font-weight:800;border-top:2px solid var(--ink);margin-top:8px;padding-top:8px}
.fineprint{font-size:10.5px;color:var(--fade);margin-top:8px;line-height:1.6}
ul.limits{margin:0;padding-left:16px;font-size:11.5px;line-height:1.75;color:var(--ink)}
ul.limits li{margin-bottom:6px}
.barcode{height:36px;margin:18px 0 8px;background:repeating-linear-gradient(90deg,var(--ink) 0 2px, transparent 2px 3px, var(--ink) 3px 4px, transparent 4px 7px, var(--ink) 7px 8px, transparent 8px 11px)}
.barnum{text-align:center;font-size:10px;letter-spacing:.3em;color:var(--fade)}
footer.shop{text-align:center;margin-top:16px;font-size:10.5px;color:var(--fade);line-height:1.8}
footer.shop a{text-decoration-color:var(--fade)}
@media(max-width:480px){
body{padding:28px 8px 56px}
.receipt{padding:28px 16px 20px}
.torn,.torn.bottom{margin-left:-16px;margin-right:-16px}
.stamp{top:16px;right:10px;width:70px;height:70px;font-size:7px}
}
</style>
</head>
<body>
<div class="receipt" id="join">
<div class="torn" aria-hidden="true"></div>
<div class="stamp">UPSTREAM<br>#53997<br>NOT&nbsp;PLANNED</div>
<header class="shop">
<div class="name">NOTPLANNED &amp; CO.</div>
<div class="tag">venture 002 · localhost tooling</div>
</header>
<hr class="rule bold">
<div class="title">
<h1>preview&#8209;sidecar</h1>
<p>Your dev server just bound a port. This notices, opens a preview, and<br>stamps a dated screenshot receipt — without touching your agent CLI.</p>
</div>
<div class="meta">
<div>REF &nbsp;<b>github.com/anthropics/claude-code#53997</b></div>
<div>ITEM &nbsp;<b>Bring back local preview for agent-built apps</b></div>
<div>STATUS &nbsp;<b>closed · not planned</b> &nbsp;→&nbsp; <b>built anyway</b></div>
</div>
<hr class="rule">
<div class="cta">
<div class="label">✂ – – – – – join early access – – – – – ✂</div>
<form class="join" action="/api/signup" method="post" enctype="application/x-www-form-urlencoded">
<input type="hidden" name="venture" value="preview-sidecar">
<input class="hp" type="text" name="website" value="" tabindex="-1" autocomplete="off" aria-hidden="true">
<div class="field">
<label for="email">email</label>
<input id="email" type="email" name="email" required placeholder="you@localhost">
</div>
<div class="field">
<label for="plan">plan</label>
<select id="plan" name="plan" required>
<option value="free">Free — just the CLI</option>
<option value="early_access">Early Access — $7/mo, founding price</option>
<option value="team">Team — $29/mo, founding price</option>
</select>
</div>
<div class="field">
<label for="note">note (optional)</label>
<textarea id="note" name="note" placeholder="what are you previewing? vite, next, rails..."></textarea>
</div>
<button class="buy" type="submit">Join early access →</button>
</form>
<div class="thanks">✓ RECEIVED — thanks, you're on the list.<br>We'll only email you about preview-sidecar. Never shared.</div>
</div>
<p class="fineprint" style="text-align:center">One line on your email: used only to reach you about preview-sidecar, never sold, never shared.</p>
<hr class="rule">
<h2 class="sec">itemized</h2>
<div class="items">
<div class="item"><span>detect newly bound localhost port</span><span class="dots"></span><span class="ok">✓</span></div>
<div class="item"><span>auto-open preview pane (iframe)</span><span class="dots"></span><span class="ok">✓</span></div>
<div class="item"><span>one-click screenshot receipt</span><span class="dots"></span><span class="ok">✓</span></div>
<div class="item"><span>standalone CLI, zero deps</span><span class="dots"></span><span class="ok">✓</span></div>
<div class="item"><span>no Claude Code changes</span><span class="dots"></span><span class="ok">✓</span></div>
</div>
<hr class="rule">
<h2 class="sec">before / after</h2>
<div class="ba">
<div>
<div class="cap">BEFORE</div>
<pre><code>$ npm run dev
ready on http://localhost:5173
(you alt-tab, guess the port,
open it yourself, no record
of what it looked like)</code></pre>
</div>
<div>
<div class="cap">AFTER</div>
<pre><code>$ npm run dev
ready on http://localhost:5173
[preview-sidecar] detected
127.0.0.1:5173 at 17:29:17Z
-> preview pane opened
-> receipt: receipt-5173-*.png</code></pre>
</div>
</div>
<h2 class="sec" style="margin-top:16px">real run, recorded</h2>
<pre><code>$ python3 demo.py
$ (throwaway dev server on 127.0.0.1:61125)
$ python3 sidecar.py watch --ports 61125 \
--interval 0.3 --max-cycles 3 --no-open
[preview-sidecar] detected 127.0.0.1:61125
at 2026-10-10T17:29:17Z -> preview-61125...html
$ python3 sidecar.py screenshot --port 61125
{
"url": "http://127.0.0.1:61125",
"bytes": 16208,
"sha256": "6e13265f215eb1e3..."
}</code></pre>
<p class="fineprint">Full transcript: <a class="plain" href="demo.txt">demo.txt</a> · full receipt: <a class="plain" href="demo-receipt.json">demo-receipt.json</a></p>
<div class="exhibit">
<img src="demo-receipt.png" alt="Actual headless-Chrome screenshot of the throwaway dev server page captured by sidecar.py screenshot, reading Hello from dev server / Started by preview-sidecar demo.py">
<div class="cap">EXHIBIT A — real capture of <code>127.0.0.1:61125</code><br>sha256 6e13265f215e… · 2026&#8209;10&#8209;10T17:29:17Z</div>
</div>
<hr class="rule">
<h2 class="sec">how to use it</h2>
<pre><code>python3 sidecar.py watch
python3 sidecar.py watch --ports 3000,5173
python3 sidecar.py screenshot --port 3000</code></pre>
<p class="fineprint">Python 3.9+, no installs. <code>watch</code> polls a bounded port list with a plain TCP connect and opens a preview the moment one answers. <code>screenshot</code> needs a Chromium‑family browser already on the machine.</p>
<hr class="rule">
<h2 class="sec">bill of goods</h2>
<div class="totals">
<div class="trow"><span>FREE · the CLI, forever</span><span class="dots"></span><span class="price">$0</span></div>
<div class="trow"><span>EARLY ACCESS · hosted receipt gallery</span><span class="dots"></span><span class="price">$7/mo*</span></div>
<div class="trow"><span>TEAM · PR auto-receipts, shared history</span><span class="dots"></span><span class="price">$29/mo*</span></div>
<div class="tfoot"><span>TOTAL DUE TODAY</span><span>$0.00</span></div>
</div>
<p class="fineprint">*founding price, early access — there is no checkout yet, joining only reserves the rate. The CLI stays free no matter what you pick above.</p>
<hr class="rule">
<h2 class="sec">honest limits</h2>
<ul class="limits">
<li>Watches a bounded candidate port list (≤200 ports), not every socket on the machine — it can't see which process owns a port.</li>
<li>Polling means a detection delay, and a port that opens and closes between polls is missed.</li>
<li>A dev server's own X‑Frame‑Options/CSP can block the preview iframe; every preview page also has a plain "open in new tab" link.</li>
<li>Screenshots need Chrome/Chromium/Edge already installed — not bundled, and a clear error if none is found.</li>
<li>Loopback only. No remote tunneling, no multi‑port orchestration — one detected port gets one preview.</li>
</ul>
<p class="fineprint"><a class="plain" href="README.md">Full README →</a></p>
<div class="barcode" aria-hidden="true"></div>
<div class="barnum">5 3 9 9 7 · N O T · P L A N N E D</div>
<footer class="shop">
THANK YOU FOR YOUR VISIT<br>
<a class="plain" href="sidecar.py" download>download the free CLI ↓</a> ·
<a class="plain" href="https://github.com/anthropics/claude-code/issues/53997">upstream issue</a> ·
<a class="plain" href="/">notplanned</a>
</footer>
<div class="torn bottom" aria-hidden="true"></div>
</div>
<script>
if(new URLSearchParams(location.search).get("joined")==="1"){
var r=document.getElementById("join");
if(r){r.classList.add("joined");}
}
</script>
</body>
</html>
+276
View File
@@ -0,0 +1,276 @@
#!/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"""<!doctype html>
<html><head><meta charset="utf-8">
<title>preview-sidecar: {host}:{port}</title>
<style>
html,body{{margin:0;height:100%;font-family:ui-monospace,Menlo,monospace}}
.bar{{display:flex;gap:12px;align-items:center;padding:8px 12px;background:#111;color:#eee;font-size:13px}}
.bar a{{color:#9fd;text-decoration:none}}
iframe{{display:block;width:100%;height:calc(100% - 37px);border:0}}
</style></head>
<body>
<div class="bar">
<strong>preview-sidecar</strong>
<span>detected {host}:{port} at {detected_at}</span>
<a href="{url}" target="_blank" rel="noopener">open in new tab</a>
<span>(if the frame below is blank, the dev server set X-Frame-Options/CSP — use the link)</span>
</div>
<iframe src="{url}" sandbox="allow-scripts allow-same-origin allow-forms"></iframe>
</body></html>
"""
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())
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
import socket
import tempfile
import unittest
from pathlib import Path
from unittest import mock
import sidecar
class ParsePorts(unittest.TestCase):
def test_default_when_nothing_given(self):
self.assertEqual(sidecar.parse_ports(None, None), sorted(sidecar.DEFAULT_PORTS))
def test_explicit_list(self):
self.assertEqual(sidecar.parse_ports("3000,3000,5173", None), [3000, 5173])
def test_range(self):
self.assertEqual(sidecar.parse_ports(None, "3000-3003"), [3000, 3001, 3002, 3003])
def test_range_too_wide_rejected(self):
with self.assertRaises(ValueError):
sidecar.parse_ports(None, f"1-{sidecar.MAX_PORTS + 50}")
def test_bad_port_rejected(self):
with self.assertRaises(ValueError):
sidecar.parse_ports("70000", None)
class ProbeAndDiff(unittest.TestCase):
def test_probe_detects_a_real_listener(self):
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.bind(("127.0.0.1", 0))
srv.listen(1)
port = srv.getsockname()[1]
try:
self.assertTrue(sidecar.probe_port("127.0.0.1", port))
finally:
srv.close()
def test_probe_false_for_closed_port(self):
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.bind(("127.0.0.1", 0))
port = srv.getsockname()[1]
srv.close()
self.assertFalse(sidecar.probe_port("127.0.0.1", port, timeout=0.2))
def test_diff_new_only_reports_additions(self):
self.assertEqual(sidecar.diff_new({3000}, {3000, 5173}), {5173})
self.assertEqual(sidecar.diff_new({3000, 5173}, {5173}), set())
class PreviewHtml(unittest.TestCase):
def test_render_contains_iframe_and_url(self):
html = sidecar.render_preview_html("127.0.0.1", 5173, "2026-01-01T00:00:00Z")
self.assertIn("http://127.0.0.1:5173", html)
self.assertIn("<iframe", html)
self.assertIn("open in new tab", html)
def test_write_preview_creates_file(self):
with tempfile.TemporaryDirectory() as tmp:
path, detected_at = sidecar.write_preview(tmp, "127.0.0.1", 4200)
self.assertTrue(path.exists())
self.assertIn("4200", path.name)
self.assertIn("http://127.0.0.1:4200", path.read_text())
self.assertRegex(detected_at, r"^\d{4}-\d{2}-\d{2}T")
class FindChrome(unittest.TestCase):
def test_explicit_path_wins_when_present(self):
with tempfile.TemporaryDirectory() as tmp:
fake = Path(tmp) / "chrome-bin"
fake.write_text("")
self.assertEqual(sidecar.find_chrome(str(fake)), str(fake))
def test_explicit_missing_path_returns_none(self):
self.assertIsNone(sidecar.find_chrome("/definitely/not/a/real/chrome/binary"))
def test_env_var_used_when_set(self):
with tempfile.TemporaryDirectory() as tmp:
fake = Path(tmp) / "chrome-env"
fake.write_text("")
with mock.patch.dict("os.environ", {"CHROME_PATH": str(fake)}):
self.assertEqual(sidecar.find_chrome(), str(fake))
def test_none_when_nothing_found(self):
with mock.patch.object(sidecar, "CHROME_CANDIDATES", ["/nope/not/here"]), \
mock.patch.dict("os.environ", {}, clear=True):
self.assertIsNone(sidecar.find_chrome())
class MakeReceiptErrors(unittest.TestCase):
def test_raises_when_port_not_open(self):
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaises(RuntimeError):
sidecar.make_receipt("127.0.0.1", 1, tmp, None, 5)
def test_raises_when_no_chrome_found(self):
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.bind(("127.0.0.1", 0))
srv.listen(1)
port = srv.getsockname()[1]
try:
with tempfile.TemporaryDirectory() as tmp:
with mock.patch.object(sidecar, "find_chrome", return_value=None):
with self.assertRaises(RuntimeError):
sidecar.make_receipt("127.0.0.1", port, tmp, None, 5)
finally:
srv.close()
if __name__ == "__main__":
unittest.main()