
Browser Mate
- 43 installs
- 339 repo stars
- Updated August 4, 2026
- glebis/claude-skills
Automate a logged-in Chrome via a dedicated debug instance that coexists with the user's main browser, without quitting it or disturbing open tabs.
About
Launches or reuses a per-profile debug Chrome instance with its own user-data-dir and port so agent-browser can drive authenticated sessions non-destructively. A developer uses it to automate sites like ChatGPT or LinkedIn while preserving their existing browser windows.
- Never quits or kills any browser; coexists with the user's main Chrome
- Per-profile persistent login state, loopback-only debug port
Browser Mate by the numbers
- 43 all-time installs (skills.sh)
- Ranked #1,127 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/glebis/claude-skills --skill browser-mateAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 43 |
|---|---|
| repo stars | ★ 339 |
| Last updated | August 4, 2026 |
| Repository | glebis/claude-skills ↗ |
What it does
Automate a logged-in Chrome via a dedicated debug instance that coexists with the user's main browser, without quitting it or disturbing open tabs.
Files
browser-mate
Non-destructive Chrome automation. The problem it solves: to attach a CDP/agent-browser session, Chrome must run with --remote-debugging-port. The real-browser skill achieves this by quitting Chrome Beta first, destroying the user's open tabs. browser-mate instead runs a dedicated debug Chrome instance (its own --user-data-dir + port) that coexists with the user's main browser — Chrome permits concurrent instances when the data dirs differ, so no quit is needed.
The one invariant (never violate)
Never quit, `pkill`, or `osascript quit` any browser. The skill only ever launches a new dedicated instance or reuses an existing one. The single stop path sends SIGTERM only to a process matched by BOTH our dedicated user_data_dir AND our debug port — never the user's browser.
Usage
# Ensure a profile's debug Chrome is up (launch or reuse); prints the PORT on stdout
PORT=$(python3 scripts/browser.py chatgpt)
# Then drive it with agent-browser (always pass --cdp $PORT). See references/agent-browser.md
SID=$(LC_ALL=C tr -dc 'a-z0-9' < /dev/urandom | head -c 6)
agent-browser --cdp $PORT --session $SID open "https://chatgpt.com/"
agent-browser --cdp $PORT --session $SID snapshot -iOther subcommands:
python3 scripts/browser.py list # configured profiles
python3 scripts/browser.py status [profile] # up/down
python3 scripts/browser.py stop chatgpt # SIGTERM OUR instance only (graceful)For the full agent-browser command set (click, fill, upload, screenshot, reliability, upgrade gotchas), read references/agent-browser.md.
Profiles
Config: ~/.config/browser-mate/profiles.json (auto-created from assets/profiles.example.json on first run). Each profile:
{ "default": "automation",
"profiles": {
"automation": { "binary": "<chrome binary>", "user_data_dir": "~/.browser-mate/automation", "port": 9222 },
"chatgpt": { "binary": "<chrome binary>", "user_data_dir": "~/.browser-mate/chatgpt", "port": 9223, "default_url": "https://chatgpt.com/" }
} }- Each profile keeps its own login state — log in once per profile, it persists.
- Add a profile by editing the JSON: pick a unique port and a dedicated `user_data_dir`
(the launcher validates uniqueness and refuses dirs that point at the user's real Chrome profile).
Authentication
Cannot enter passwords (and must not). For a first-time login, launch the profile, then ask the user to log in manually in that window once; the session persists in the profile's user_data_dir for all future runs.
Safety & limits
- Loopback only. The debug port binds to
127.0.0.1(Chrome default). CDP has no auth
— anyone local can drive it. Use on trusted machines; never pass --remote-debugging-address.
- Dedicated data dir. A profile's
user_data_dirmust never be the user's real Chrome
profile (~/Library/Application Support/Google/Chrome*) — the launcher refuses these.
- Locked dir. If a profile's dir is already open in a non-debug Chrome window, the
launcher fails loudly rather than killing it.
- Replaces
real-browser's launch step; interaction still usesagent-browser.
{
"default": "automation",
"profiles": {
"automation": {
"binary": "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
"user_data_dir": "~/.browser-mate/automation",
"port": 9222
},
"chatgpt": {
"binary": "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
"user_data_dir": "~/.browser-mate/chatgpt",
"port": 9223,
"default_url": "https://chatgpt.com/"
}
}
}
Driving a browser-mate instance with agent-browser
browser-mate only launches/reuses the debug Chrome; interaction is via the agent-browser CLI against the printed port. Pass --cdp <port> on EVERY call.
PORT=$(python3 scripts/browser.py chatgpt) # ensure up, capture port
SID=$(LC_ALL=C tr -dc 'a-z0-9' < /dev/urandom | head -c 6) # unique session id
agent-browser --cdp $PORT --session $SID snapshot -i # interactive snapshot (refs)
agent-browser --cdp $PORT --session $SID open "https://chatgpt.com/"
agent-browser --cdp $PORT --session $SID click @e3
agent-browser --cdp $PORT --session $SID fill @e5 "text"
agent-browser --cdp $PORT --session $SID screenshot /tmp/page.png
agent-browser --cdp $PORT --session $SID eval 'navigator.webdriver' # expect false/undefinedFile upload — hidden <input type=file> (no native dialog)
upload takes a CSS selector or @ref and sets the file via CDP — it does NOT open the OS file picker and works on HIDDEN inputs. Do NOT click the visible "attach"/"+" button (that opens a native dialog you can't drive).
agent-browser --cdp $PORT --session $SID upload "input[type=file]" /abs/file.zipPick the right input when there are several: the general attachment input has accept="*" (image-only ones have accept="image/*"). Verify after: eval 'document.body.innerText.includes("file.zip")'.
Text entry — textarea vs contenteditable
- Real
<input>/<textarea>:fill "<sel>" "text". - contenteditable / ProseMirror (e.g. ChatGPT's
#prompt-textareais a
<div contenteditable>, not a textarea — .value stays empty): click it then type "text". fill silently does nothing on these. Detect first: eval 'const e=document.querySelector(sel); e.tagName+"/"+e.getAttribute("contenteditable")'.
Recipe: drive ChatGPT (file + prompt + model) — works first try
1. open "https://chatgpt.com/"; confirm a composer (#prompt-textarea) exists (logged in). 2. CHECK THE MODEL FIRST — before touching anything. It is usually already what you want; don't open the switcher unless you must change it. Read the composer model pill (+ ⏱ Pro ⌄), NOT the switcher button (its innerText is just the brand "ChatGPT").
- To change:
click "[data-testid=\"model-switcher-dropdown-button\"]"; the menu is a
PORTAL — read [data-radix-popper-content-wrapper] (e.g. "Latest 5.5", "Thinking", "Pro"), click the target, then verify. 3. upload "input[type=file]" /abs/file (the accept="*" input); verify filename in body. 4. click "#prompt-textarea" then type "<prompt>" (contenteditable — see above). 5. click "[data-testid=\"send-button\"]". Generating == [data-testid=\"stop-button\"] present. 6. Wait until the stop-button disappears, then read the last [data-message-author-role=\"assistant\"] element's innerText.
Reliability
- Read state before acting. Check the current value/model/login before changing it —
it is often already correct, and clobbering a good state is the common failure mode.
- Bound long calls: macOS has no
timeout— usegtimeout(coreutils) if present,
else run the call in the background and kill after N seconds. On timeout, snapshot to capture last-known state, then retry that step (checkpoint, don't restart).
- Generate the 6-char
--sessiononce per run; two runs sharing a name collide. - After upgrading agent-browser:
pkill -f agent-browser; rm -rf ~/.agent-browser/sockets/
then reconnect (stale daemons cause blank pages / missing cookies).
- Never use
agent-browser ... connect <port>(its daemon opens a blank tab).
--cdp <port> attaches correctly.
Cleanup
- Leave the instance running (reused next time). To stop OUR instance only:
python3 scripts/browser.py stop chatgpt (SIGTERM to the matched pid; never the user's browser).
#!/usr/bin/env python3
"""browser-mate — non-destructive Chrome automation with configurable profiles.
Launches (or REUSES) a dedicated debug Chrome instance per named profile, each
with its own --user-data-dir and --remote-debugging-port, COEXISTING with the
user's main browser. It NEVER quits or kills the user's browser — that is the
whole reason this skill exists.
Subcommands:
browser.py <profile> ensure the profile's debug instance is up; print PORT
browser.py launch <profile> same as above (explicit)
browser.py list list configured profiles
browser.py status [profile] show up/down for one or all profiles
browser.py stop <profile> gracefully stop ONLY our instance (SIGTERM, never the user's)
Config: ~/.config/browser-mate/profiles.json (created from the bundled example on
first run). Each profile: {binary, user_data_dir, port, default_url?}.
Security: the debug port binds to 127.0.0.1 only (Chrome default) and CDP has NO
auth — anyone local can drive it. Use on trusted machines only. user_data_dir MUST
be a dedicated dir, never the user's real Chrome profile.
"""
import json
import os
import re
import shutil
import signal
import subprocess
import sys
import time
import urllib.request
CONFIG = os.path.expanduser(os.environ.get("BROWSER_MATE_CONFIG",
"~/.config/browser-mate/profiles.json"))
HERE = os.path.dirname(os.path.abspath(__file__))
EXAMPLE = os.path.join(os.path.dirname(HERE), "assets", "profiles.example.json")
# Dirs we must never point a profile at (the user's real browsers).
_FORBIDDEN = [os.path.expanduser(p) for p in (
"~/Library/Application Support/Google/Chrome",
"~/Library/Application Support/Google/Chrome Beta",
"~/Library/Application Support/Google/Chrome Canary",
"~/Library/Application Support/Chromium",
)]
def die(msg, code=1):
print(f"browser-mate: {msg}", file=sys.stderr)
sys.exit(code)
def load_config():
if not os.path.exists(CONFIG):
os.makedirs(os.path.dirname(CONFIG), exist_ok=True)
shutil.copy(EXAMPLE, CONFIG)
print(f"browser-mate: created config {CONFIG} (edit to taste)", file=sys.stderr)
cfg = json.load(open(CONFIG, encoding="utf-8"))
profs = cfg.get("profiles", {})
if not profs:
die(f"no profiles in {CONFIG}")
# validate uniqueness of ports and data dirs across profiles
ports, dirs = {}, {}
for name, p in profs.items():
port = p.get("port")
udd = os.path.expanduser(p.get("user_data_dir", ""))
if port in ports:
die(f"profiles '{name}' and '{ports[port]}' share port {port} — ports must be unique")
if udd in dirs:
die(f"profiles '{name}' and '{dirs[udd]}' share user_data_dir — must be unique")
ports[port], dirs[udd] = name, name
return cfg
def resolve(cfg, name):
name = name or cfg.get("default")
p = cfg.get("profiles", {}).get(name)
if not p:
die(f"unknown profile '{name}'. Configured: {', '.join(cfg.get('profiles', {}))}")
binary = os.path.expanduser(p["binary"])
udd = os.path.expanduser(p["user_data_dir"])
port = int(p["port"])
if not os.path.exists(binary):
die(f"profile '{name}': binary not found: {binary}")
if os.path.realpath(udd) in [os.path.realpath(f) for f in _FORBIDDEN]:
die(f"profile '{name}': user_data_dir points at the real browser profile — refusing "
f"(would corrupt/lock the user's browser). Use a dedicated dir.")
return name, binary, udd, port, p.get("default_url")
def port_up(port):
"""Return the CDP /json/version dict if a Chrome debug endpoint answers, else None."""
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=2) as r:
return json.load(r)
except Exception:
return None
def _pids_for(udd, port):
"""PIDs of chrome processes started by US for this profile (match BOTH the
dedicated user_data_dir AND our debug port — so we never match the user's
browser). Returns a list of ints."""
try:
out = subprocess.run(["ps", "-ax", "-o", "pid=,command="],
capture_output=True, text=True).stdout
except Exception:
return []
pids = []
needle_dir = f"--user-data-dir={udd}"
needle_port = f"--remote-debugging-port={port}"
for line in out.splitlines():
if needle_dir in line and needle_port in line:
m = re.match(r"\s*(\d+)\s", line)
if m:
pids.append(int(m.group(1)))
return pids
def ensure(cfg, name):
name, binary, udd, port, url = resolve(cfg, name)
info = port_up(port)
if info:
print(f"[browser-mate] reusing '{name}' on port {port} ({info.get('Browser','?')})",
file=sys.stderr)
print(port)
return
# not up. Refuse if the dir is locked by a NON-debug instance (never kill it).
lock = os.path.join(udd, "SingletonLock")
if os.path.exists(lock) and not _pids_for(udd, port):
die(f"profile '{name}': user_data_dir is open in a non-debug Chrome (SingletonLock "
f"present, no debug port). Refusing to kill it. Close that window or use another "
f"profile/dir.")
os.makedirs(udd, exist_ok=True)
args = [binary, f"--remote-debugging-port={port}", f"--user-data-dir={udd}",
"--no-first-run", "--no-default-browser-check"]
if url:
args.append(url)
# launch detached; coexists with the user's browser (distinct user_data_dir)
subprocess.Popen(args, start_new_session=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for _ in range(30): # ~15s
time.sleep(0.5)
if port_up(port):
print(f"[browser-mate] launched '{name}' on port {port} (data dir {udd})",
file=sys.stderr)
print(port)
return
die(f"profile '{name}': launched but debug port {port} never came up")
def cmd_list(cfg):
for name, p in cfg.get("profiles", {}).items():
star = " (default)" if name == cfg.get("default") else ""
print(f"{name}{star}: port {p.get('port')} · {p.get('binary')} · {p.get('user_data_dir')}")
def cmd_status(cfg, name=None):
names = [name] if name else list(cfg.get("profiles", {}))
for n in names:
nm, binary, udd, port, url = resolve(cfg, n)
up = port_up(port)
print(f"{nm}: {'UP' if up else 'down'} (port {port})" + (f" — {up.get('Browser')}" if up else ""))
def cmd_stop(cfg, name):
nm, binary, udd, port, url = resolve(cfg, name)
pids = _pids_for(udd, port)
if not pids:
print(f"[browser-mate] '{nm}' not running under our control", file=sys.stderr)
return
for pid in pids:
os.kill(pid, signal.SIGTERM) # graceful, ONLY our matched instance
print(f"[browser-mate] sent SIGTERM to {nm} (pids {pids})", file=sys.stderr)
def main():
argv = sys.argv[1:]
if not argv:
die("usage: browser.py <profile> | launch <profile> | list | status [profile] | stop <profile>")
cfg = load_config()
head = argv[0]
if head == "list":
cmd_list(cfg)
elif head == "status":
cmd_status(cfg, argv[1] if len(argv) > 1 else None)
elif head == "stop":
if len(argv) < 2:
die("stop needs a profile name")
cmd_stop(cfg, argv[1])
elif head == "launch":
ensure(cfg, argv[1] if len(argv) > 1 else None)
else:
ensure(cfg, head) # bare profile name
if __name__ == "__main__":
main()