Skip to content

Latest commit

Β 

History

9 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

browserctrl

CLI + Go library β€” which browser is that device id? Answered from disk, in one command.

Maps Claude-in-Chrome device ids to the Chromium browser, profile and signed-in email behind them, shows which profiles are open right now, and resolves a nickname to an id.

Go Reference on pkg.go.dev Go Report Card Latest release License: Apache-2.0 Go 1.26 or later Runs on macOS, Linux and Windows (macOS verified live) Read-only: no network, no browser flags Statement coverage: 97.2% browser package, 97.3% CLI, 96.2% merged across every package

browserctrl is an open-source, local-first browser identity resolver for AI coding agents: it tells you which Chromium browser profile is behind each Claude-in-Chrome device id β€” running or not β€” so Claude Code's claude-in-chrome MCP can select_browser the right window first time instead of prompting in every browser. It reads Chrome, Edge, Brave, Vivaldi, Opera, Arc and Chromium profile stores read-only, needs no --remote-debugging-port, sends nothing anywhere, and ships as a CLI (browserctrl) plus an importable Go package (github.com/khanakia/browserctrl/browser) with a fixture package for tests.

go install github.com/khanakia/browserctrl@latest
import "github.com/khanakia/browserctrl/browser"

Two ways in:

You want Start here
πŸ–₯️ browserctrl CLI see every profile with the Claude extension, which are open right now, and get the id for "my work chrome" without clicking through prompts Usage β†’ command reference β†’ recipes
πŸ“¦ Go library scan Chromium user-data roots from your own tool, match entries, build test fixtures Library use β†’ Go API

Contents: Why browserctrl? Β· Install Β· Usage Β· Using it from Claude Code Β· Agent skill Β· How it works Β· Library use Β· Testing Β· Development Β· Docs Β· FAQ

Why browserctrl?

With more than one Chromium browser or profile open, the Claude Code claude-in-chrome MCP can only report list_connected_browsers like this:

[
  { "deviceId": "ce9a8e06-61d3-4e7b-894b-13fd92987212", "name": "Browser 1" },
  { "deviceId": "1056c3fd-8eab-42e9-a9fd-7df3b88394c6", "name": "Browser 2" },
  { "deviceId": "30c7e2d1-b0da-4e21-bed8-27c8cc755346", "name": "Browser 3" }
]

The names are not stable between listings, so the only way to pick the right window was switch_browser: a confirmation prompt fires in every connected extension and you click the one you meant. Every session. browserctrl reads the answer from disk instead:

$ browserctrl list
STATE    MCP  DEVICE ID                             BROWSER  PROFILE     NAME     EMAIL              DISPLAY NAME
running  yes  ce9a8e06-61d3-4e7b-894b-13fd92987212  custom   Default     Aman     [email protected]   chrome-main
running  yes  2aa533d3-d03f-4dd8-b664-f59b8930eccc  custom   Profile 5   Work     [email protected]  work-chrome
idle     yes  f836694e-b2f0-4e5b-93e4-ff946c6183ad  custom   Profile 23  legable  [email protected]    aman-legable
idle     no   -                                     custom   Profile 4   Fresh    [email protected]  -

$ browserctrl find legable
f836694e-b2f0-4e5b-93e4-ff946c6183ad

Output captured against the repo's doc fixture (task docs:capture), which is why the browser column reads custom; on a real machine it says chrome, vivaldi, edge, and so on.

How the jobs compare with the other ways of answering "which browser is that id?":

browserctrl MCP switch_browser prompt By hand (extension popup / chrome://version) DevTools protocol tools
Map a device id to browser + profile + email βœ… one command, from disk ❌ click in each window ⚠️ open every profile, read it off ❌ extension storage is not exposed
Tell which profiles are open right now βœ… STATE column, via the LevelDB lock ⚠️ connected ones only, unstable labels ❌ ⚠️ only with --remote-debugging-port
Include profiles whose browser is closed βœ… idle rows ❌ ❌ ❌
Scriptable (--json, exit codes, nickname β†’ id) βœ… ❌ tool call + human click ❌ βœ…
Works without relaunching the browser with flags βœ… read-only, no flags βœ… βœ… ❌
Chrome, Edge, Brave, Vivaldi, Opera, Arc, Chromium in one list βœ… βœ… whatever is connected ⚠️ one at a time ⚠️ per instance

If you want to drive the page itself, the MCP already does that; if you want to know which window the id is and hand the agent the right one first time, that is browserctrl.

Install

go install github.com/khanakia/browserctrl@latest

Without Go, the install script downloads the release archive for your OS/arch, verifies it against checksums.txt, and puts the binary on your PATH (INSTALL_DIR=~/.local/bin to avoid sudo, VERSION=v0.1.0 to pin):

curl -fsSL https://raw.githubusercontent.com/khanakia/browserctrl/main/install.sh | sh

Windows PowerShell: irm https://raw.githubusercontent.com/khanakia/browserctrl/main/install.ps1 | iex. Homebrew: brew install khanakia/tap/browserctrl.

Or from a checkout: task install (binary into $GOPATH/bin) or task build (./bin/browserctrl). Requires Go 1.26 or later; no runtime dependencies, no browser flags, no network.

Releases are cut by volt: every vX.Y.Z tag ships cross-compiled archives (darwin/linux Γ— amd64/arm64, windows/amd64) with a checksums.txt, the Homebrew formula, and the skills bundle the agent skill section describes; the same tag is what go install …@latest resolves. browserctrl --version prints the stamped version and commit; a source build prints Go's pseudo-version instead so you can still tell which commit you are running.

Usage

  • browserctrl list β€” every profile with the extension, running ones first. --json for machine output, --running to keep only open profiles, --all to also scan the Claude desktop-app extension ids, --root <dir> (repeatable) to scan a custom --user-data-dir instead of the well-known install locations.
  • browserctrl find <term>... β€” prints exactly one device id. Every term must match (case-insensitive substring) one of display name, profile name, email, profile dir, browser kind, device id. If several match but exactly one is running, that one wins. Exit 1 = no match, exit 2 = ambiguous (candidates on stderr).
  • browserctrl skills β€” list, print and freshness-check the agent skill this binary ships (list, get, path, check, version, refresh).
  • browserctrl --version, browserctrl completion <shell>.

Every flag, with real output for every verb, is in the command reference; end-to-end workflows are in recipes.

Using it from Claude Code

Add this to your global CLAUDE.md so the agent never asks you to click through the confirmation prompt again:

# Chrome browser selection
Before any claude-in-chrome action, run `browserctrl list --running --json` and pick the deviceId whose profileName / email / displayName matches what I asked for (e.g. "my legable chrome" β†’ the entry with email [email protected]). Call select_browser with that id directly. Only fall back to switch_browser if the list is empty or the match is ambiguous.

The recipes page has the longer version, including how to give each browser a memorable display name.

Agent skill

The rule above also ships as a SKILL.md β€” the open agent-skill format β€” so any harness can install it instead of you pasting it into CLAUDE.md:

npx skills add khanakia/browserctrl        # install into your agent (skills.sh)
browserctrl skills                          # what this binary ships
browserctrl skills check <installed-dir>    # exit 0 current, exit 1 stale
$ browserctrl skills
  browserctrl-core  Pick the right Chrome/Chromium browser for the claude-in-chrome MCP without a click-through prompt.

install for agents:  npx skills add khanakia/browserctrl

The skill is served by the binary itself (skills get browserctrl-core) and is always the version that matches the installed binary: release builds fetch skills_<version>.tar.gz from their own release once and cache it, source builds serve the repo's skills/ directory. That is voltkit/skillcmd; the wiring in skills_gen.go is generated by volt gen skills.

How it works

Each Chromium profile keeps the extension's chrome.storage.local in <profile>/Local Extension Settings/<extension-id>/ as a LevelDB. The Claude extension writes bridgeDeviceId (the id the MCP reports), bridgeDisplayName (the name you typed when connecting) and mcpConnected there. Local State at the browser's user-data root maps profile directories to names and signed-in emails. A running browser holds an flock on the LevelDB LOCK file, which is the "running" signal. The LevelDB is snapshotted to a temp dir and opened read-only, so the tool never touches the live store.

Verified on macOS against Chrome, Vivaldi, Opera and Edge. The Linux and Windows path tables come from the browsers' documented defaults and are only covered by the cross-compile gate (task cross); the running/idle probe returns unknown on Windows.

Library use

The browser package is importable on its own: browser.Scan(ctx, browser.Options{}) returns []browser.Entry; browser.Match and browser.OnlyRunning do the filtering; browser/browsertest builds fake user-data roots for your own tests.

entries, err := browser.Scan(ctx, browser.Options{})
if err != nil {
	return err
}
for _, e := range browser.OnlyRunning(browser.Match(entries, "legable")) {
	fmt.Println(e.DeviceID, e.Browser, e.ProfileName, e.Email)
}

Full surface, with the contracts each function keeps, is in the Go API page.

Testing

Package Statement coverage (own tests)
browser (library) 97.2%
browser/browsertest (fixtures) 89.1%
root main package (CLI) 97.3%
internal/docfixture (doc harness) 97.3%
merged, every package 96.2% (358/372 statements)

Re-measure with task cover; task test:uncovered lists what is left per function. The numbers above are the measured ones, not rounded claims. The suite needs no browser: every scenario runs against fake user-data roots built by browser/browsertest, "running" is simulated by holding the same flock Chromium holds, and go test -race with parallel subtests is what caught the exclusive-lock probe bug.

The uncovered remainder is 14 statements, each named rather than hand-waved:

  • Two os.Exit wrappers β€” main() in the root package and in internal/docfixture. Both delegate to a run() that is tested end to end; the wrapper itself cannot run inside go test.
  • Six deferred-Close / cleanup error arms β€” db.Close, in.Close, out.Close and os.RemoveAll in the store reader, plus db.Close in the fixture builder. They surface a flush or unlink failure instead of dropping it; provoking one needs fault injection below os, which the suite deliberately does not do.
  • Two tabwriter row-write arms in the table renderer. text/tabwriter buffers any line containing a tab until Flush, so a broken stdout is reported by Flush (covered), never by the per-row write.
  • One flock default arm in the running probe: an error other than EWOULDBLOCK, e.g. a filesystem that does not support flock. It maps to unknown, never to idle.
  • Three fixture-builder defensive arms: json.Marshal of a map[string]string (cannot fail), db.Put on a freshly opened LevelDB (no error source without disk faults), and re-creating LOCK when LevelDB did not (it always does; the arm guards a future implementation change).

Development

task check is the gate: gofmt, vet, staticcheck + golangci-lint, go test -race (which includes the markdown lint in docs_test.go), cross-compile for linux/windows/darwin, and a go mod tidy diff check. task volt:ci runs volt's equivalent gate plus its skills-frontmatter lint. task docs:capture -- <args> regenerates any terminal output shown in the docs against the fixture in internal/docfixture.

Releases: task volt:release:snapshot builds every platform into dist/ and publishes nothing; task volt:release -- --bump patch (or -- vX.Y.Z) tags vX.Y.Z and publishes archives, checksums, the skills bundle and (with HOMEBREW_TAP_GITHUB_TOKEN) the brew formula β€” one stream, because package main lives at the repo root next to the browser library. Configuration lives in .volt.yml; install.sh / install.ps1 and the workflows are volt-generated and hash-guarded. Nothing runs on push, by decision: .github/workflows/release.yml and ci.yml are manual-dispatch only (Actions tab β†’ Run workflow, or gh workflow run ci.yml); the local gate is the real one, and the workflows exist to re-run it on a clean runner on demand.

Docs

Page Covers
Command reference every verb and flag, real output, exit codes, JSON shape
Recipes Claude Code integration, shell + jq workflows, custom user-data dirs, testing with the fixture package
Go API browser and browser/browsertest β€” types, functions, guarantees
Docs index all of the above plus the design promises

FAQ

Does browserctrl send anything to the cloud or need an API key? No. It reads files under your browsers' user-data directories and prints what it finds. There is no network code in the binary, no account, no telemetry. The only third-party dependency is a LevelDB reader.

Does it modify my Chrome profile, or lock it? No. Each extension store is copied to a temp directory, opened read-only from the copy, and the copy is deleted before exit. The running/idle probe takes a shared non-blocking flock on the LOCK file and releases it immediately; it cannot block the browser or another scan.

Why does Claude Code show "Browser 1 / 2 / 3", and how does this fix it? The claude-in-chrome MCP only knows each connected extension's device id and assigns display labels per listing, so they shift. browserctrl joins the same device id (bridgeDeviceId in the extension's storage) with the profile's name and email from Chromium's Local State, so the agent can pick by "my work chrome" and call select_browser directly instead of switch_browser's click-in-every-window prompt.

Does it work when the browser is closed? Yes. Idle profiles are listed with their ids too (STATE idle), which is how you see everything the extension is installed in. Only a running profile can actually be selected by the MCP, so pass --running when the id is going straight into select_browser.

Which browsers and OSes are supported? Any Chromium-based browser whose profile layout is standard: Chrome (stable/beta/canary/dev), Chromium, Brave, Edge, Arc, Vivaldi, Opera and Opera GX are scanned by default, and --root handles anything launched with --user-data-dir. macOS is verified against real installs; the Linux and Windows path tables are compiled and shape-tested in the gate but have not been run on a live machine, and the running probe returns unknown on Windows rather than guess.

How is this different from just using switch_browser? switch_browser asks a human to click Connect in the right window, every time, and cannot tell you which window is which. browserctrl answers from disk in milliseconds, works for closed profiles, is scriptable (--json, exit codes), and lets you resolve a nickname to an id with find. Keep switch_browser as the fallback for the ambiguous case.

What does mcpConnected mean if a profile is idle? It is a sticky flag the extension sets after its first successful MCP handshake and does not clear on exit. idle + MCP yes means "has connected before, not open now"; MCP no with an empty device id means the extension is installed but has never connected.

Can I use it from Go, and test without a browser? Yes. browser.Scan, browser.Match and browser.OnlyRunning are the whole surface the CLI uses, and browser/browsertest writes fake user-data roots with real LevelDB stores so your tests need no browser. See the Go API page.

browserctrl β€” open-source, local-first CLI and Go library that resolves Claude-in-Chrome / Claude Code MCP device ids to Chromium browser profiles (Chrome, Edge, Brave, Vivaldi, Opera, Arc, Chromium) by reading extension LevelDB storage and Local State read-only. No cloud, no API key, no browser flags. Apache-2.0.

About

Map Claude-in-Chrome device ids to the Chromium browser, profile and email behind them, see which profiles are open right now, and resolve a nickname like 'work chrome' to an id in one command. CLI + Go library. Reads Chrome, Edge, Brave, Vivaldi, Opera, Arc and Chromium profile stores read-only: no browser flags, no network.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages