Files
claudetools/.claude/scripts/ff.py
Mike Swanson 8a9759789f feat(scripts): add Firefox driver (ff.py) via Playwright; disable claude-in-chrome
Add .claude/scripts/ff.py, a Firefox browser driver built on Playwright and
the Firefox sibling of the existing cdp.py Chrome driver. It runs a small
background daemon holding one Playwright Firefox page on a persistent profile,
controlled over localhost:9333, with subcommands launch/status/nav/shot/click/
type/eval/console/network/stop. Verified end-to-end (real screenshot, network
and console capture). This is now the preferred browser-automation path because
Mike dislikes Chrome and the claude-in-chrome extension (that connector was
disabled in ~/.claude.json this session - not a repo change).

Add memory reference_ff_firefox_driver.md documenting the driver and an index
line in MEMORY.md. The MEMORY.md change also unavoidably includes a pre-existing
adjacent index line for reference_antigravity_agy_not_headless.md, so that memory
file is bundled in to keep the index consistent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:50:45 -07:00

280 lines
10 KiB
Python

#!/usr/bin/env python
"""
ff.py - drive Firefox over Playwright, the Firefox sibling of cdp.py.
Firefox dropped most of its CDP support, so the stateless "new connection per
command" trick cdp.py uses against Chrome's debug port doesn't port cleanly.
Instead `launch` spawns a small background daemon that holds ONE Playwright
Firefox page (on a persistent profile, so logins survive); every other
subcommand is a thin HTTP client to that daemon. The page persists between
calls (nav now, shot later) and the daemon accumulates console + network logs
for retrieval -- the "collect the logs" use case.
Usage:
py ff.py launch [url] [--headless] # start the background Firefox daemon
py ff.py status # daemon health + current url/title
py ff.py nav <url> # navigate the page
py ff.py shot <out.png> # screenshot the page to a PNG file
py ff.py click <x> <y> # left-click at viewport coords
py ff.py type <text> # insert text into the focused element
py ff.py key <Key> # press a key (Enter/Tab/Escape/...)
py ff.py eval <js> # page.evaluate(js), prints JSON result
py ff.py console [--clear] # dump collected console messages (JSON)
py ff.py network [--clear] # dump collected network requests (JSON)
py ff.py stop # shut the daemon down
Env: FF_PORT (control port, default 9333)
FF_PROFILE (default %USERPROFILE%\\.claude\\ff-profile)
"""
import sys, os, json, time, subprocess, urllib.request, urllib.error
PORT = int(os.environ.get("FF_PORT", "9333"))
BASE = f"http://localhost:{PORT}"
PROFILE = os.environ.get("FF_PROFILE", os.path.join(os.path.expanduser("~"), ".claude", "ff-profile"))
# --------------------------------------------------------------------------- #
# client side (the CLI you actually type)
# --------------------------------------------------------------------------- #
def _req(path, method="GET", body=None, timeout=30):
data = json.dumps(body).encode() if body is not None else None
r = urllib.request.Request(BASE + path, data=data, method=method,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(r, timeout=timeout) as resp:
raw = resp.read().decode()
return json.loads(raw) if raw else {}
def _alive():
try:
_req("/status", timeout=2)
return True
except Exception:
return False
def cmd_launch(args):
headless = "--headless" in args
url = next((a for a in args if not a.startswith("--")), None)
if _alive():
print(f"[ff] daemon already running on {BASE}")
if url:
_req("/nav", "POST", {"url": _fix(url)})
print(f"[ff] navigated -> {_fix(url)}")
return
os.makedirs(PROFILE, exist_ok=True)
flags = subprocess.CREATE_NEW_PROCESS_GROUP | 0x00000008 # DETACHED_PROCESS
env = dict(os.environ, FF_DAEMON="1", FF_HEADLESS="1" if headless else "0",
FF_START_URL=_fix(url) if url else "about:blank")
# Redirect the detached child's stdio to a logfile -- otherwise it inherits
# the parent's stdout pipe (caller never gets control back) and any startup
# crash is invisible.
log = open(os.path.join(os.path.dirname(PROFILE), "ff-daemon.log"), "w")
subprocess.Popen([sys.executable, os.path.abspath(__file__), "_serve"],
env=env, creationflags=flags, close_fds=True,
stdin=subprocess.DEVNULL, stdout=log, stderr=log)
for _ in range(60):
if _alive():
print(f"[ff] daemon up on {BASE} (headless={headless}) profile={PROFILE}")
if url:
print(f"[ff] start url -> {_fix(url)}")
return
time.sleep(0.5)
raise SystemExit("[ff] daemon failed to start (check that 'py -m playwright install firefox' ran)")
def _fix(url):
if url and "://" not in url and url != "about:blank":
return "https://" + url
return url
def _need(args, n, what):
if len(args) < n:
raise SystemExit(f"[ff] {what}")
def cmd_status(a):
print(json.dumps(_req("/status"), indent=2))
def cmd_nav(a):
_need(a, 1, "usage: ff.py nav <url>")
_req("/nav", "POST", {"url": _fix(a[0])})
print(f"[ff] navigated -> {_fix(a[0])}")
def cmd_shot(a):
_need(a, 1, "usage: ff.py shot <out.png>")
out = os.path.abspath(a[0])
_req("/shot", "POST", {"path": out})
print(f"[ff] screenshot -> {out} ({os.path.getsize(out)} bytes)")
def cmd_click(a):
_need(a, 2, "usage: ff.py click <x> <y>")
_req("/click", "POST", {"x": float(a[0]), "y": float(a[1])})
print(f"[ff] click ({a[0]},{a[1]})")
def cmd_type(a):
_need(a, 1, "usage: ff.py type <text>")
_req("/type", "POST", {"text": a[0]})
print(f"[ff] typed {len(a[0])} chars")
def cmd_key(a):
_need(a, 1, "usage: ff.py key <Key>")
_req("/key", "POST", {"key": a[0]})
print(f"[ff] key {a[0]}")
def cmd_eval(a):
_need(a, 1, "usage: ff.py eval <js>")
print(json.dumps(_req("/eval", "POST", {"js": a[0]}).get("value"), indent=2, default=str))
def cmd_console(a):
res = _req("/console" + ("?clear=1" if "--clear" in a else ""))
print(json.dumps(res.get("messages", []), indent=2, default=str))
def cmd_network(a):
res = _req("/network" + ("?clear=1" if "--clear" in a else ""))
print(json.dumps(res.get("requests", []), indent=2, default=str))
def cmd_stop(a):
if not _alive():
print("[ff] daemon not running")
return
try:
_req("/stop", "POST", {}, timeout=5)
except Exception:
pass
print("[ff] daemon stopped")
# --------------------------------------------------------------------------- #
# daemon side (py ff.py _serve) -- holds the live Firefox page
# --------------------------------------------------------------------------- #
def serve():
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs
import threading
from playwright.sync_api import sync_playwright
headless = os.environ.get("FF_HEADLESS") == "1"
start_url = os.environ.get("FF_START_URL", "about:blank")
pw = sync_playwright().start()
ctx = pw.firefox.launch_persistent_context(PROFILE, headless=headless,
viewport={"width": 1280, "height": 800})
page = ctx.pages[0] if ctx.pages else ctx.new_page()
console_log, network_log = [], []
page.on("console", lambda m: console_log.append(
{"type": m.type, "text": m.text, "location": m.location}))
page.on("response", lambda r: network_log.append(
{"status": r.status, "method": r.request.method, "url": r.url,
"type": r.request.resource_type}))
page.on("pageerror", lambda e: console_log.append(
{"type": "pageerror", "text": str(e), "location": {}}))
if start_url and start_url != "about:blank":
try:
page.goto(start_url, wait_until="load", timeout=30000)
except Exception:
pass
class H(BaseHTTPRequestHandler):
def log_message(self, *a): # silence
pass
def _reply(self, obj, code=200):
b = json.dumps(obj, default=str).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(b)))
self.end_headers()
self.wfile.write(b)
def _body(self):
n = int(self.headers.get("Content-Length", 0))
return json.loads(self.rfile.read(n)) if n else {}
def do_GET(self):
u = urlparse(self.path)
q = parse_qs(u.query)
try:
if u.path == "/status":
self._reply({"ok": True, "url": page.url, "title": page.title(),
"headless": headless, "console": len(console_log),
"network": len(network_log)})
elif u.path == "/console":
msgs = list(console_log)
if q.get("clear"):
console_log.clear()
self._reply({"messages": msgs})
elif u.path == "/network":
reqs = list(network_log)
if q.get("clear"):
network_log.clear()
self._reply({"requests": reqs})
else:
self._reply({"error": "not found"}, 404)
except Exception as e:
self._reply({"error": str(e)}, 500)
def do_POST(self):
u = urlparse(self.path)
try:
b = self._body()
if u.path == "/nav":
page.goto(b["url"], wait_until="load", timeout=30000)
self._reply({"ok": True, "url": page.url})
elif u.path == "/shot":
page.screenshot(path=b["path"], full_page=b.get("full", False))
self._reply({"ok": True})
elif u.path == "/click":
page.mouse.click(b["x"], b["y"])
self._reply({"ok": True})
elif u.path == "/type":
page.keyboard.insert_text(b["text"])
self._reply({"ok": True})
elif u.path == "/key":
page.keyboard.press(b["key"])
self._reply({"ok": True})
elif u.path == "/eval":
self._reply({"value": page.evaluate(b["js"])})
elif u.path == "/stop":
self._reply({"ok": True})
threading.Thread(target=httpd.shutdown, daemon=True).start()
else:
self._reply({"error": "not found"}, 404)
except Exception as e:
self._reply({"error": str(e)}, 500)
httpd = HTTPServer(("127.0.0.1", PORT), H)
try:
httpd.serve_forever()
finally:
try:
ctx.close()
except Exception:
pass
pw.stop()
CMDS = {"launch": cmd_launch, "status": cmd_status, "nav": cmd_nav, "shot": cmd_shot,
"click": cmd_click, "type": cmd_type, "key": cmd_key, "eval": cmd_eval,
"console": cmd_console, "network": cmd_network, "stop": cmd_stop}
if __name__ == "__main__":
if len(sys.argv) >= 2 and sys.argv[1] == "_serve":
serve()
elif len(sys.argv) < 2 or sys.argv[1] not in CMDS:
print(__doc__)
raise SystemExit(1)
else:
CMDS[sys.argv[1]](sys.argv[2:])