Browser tab management from the terminal. A Rust replacement for brotab.
Note: This project was built with Claude Code. Architecture was carefully designed based on studying the Claude Chrome extension's native messaging implementation, brotab, and tabctl.
Particularly useful with AI coding tools like Claude Code — lets your AI assistant list, search, open, and close browser tabs programmatically.
$ rustab list
b.18452.42 GitHub - rustab https://github.com/user/rustab
b.18452.99 Nix manual https://nixos.org/manual/nix/stable/
f.20881.12 Reddit https://www.reddit.com
$ rustab list | grep Reddit | rustab close
- List, close, move, activate, and open browser tabs from the CLI
- List browser windows and target tab operations by window
- Supports Chrome, Brave, Firefox, Chromium, Orion, Edge, Vivaldi, Zen
- List read-only synced Orion tabs from local macOS state
- Pipe-friendly:
rustab list | grep pattern | rustab close - TSV and JSON output formats
- Multiple concurrent browsers
- Linux and macOS native messaging support
- Nix/flake-native packaging
Browser extension <--native messaging (stdio)--> rustab-mediator <--Unix socket--> rustab CLI
Each browser instance gets its own mediator process and Unix socket at /tmp/rustab-{uid}/{browser}-{pid}.sock. The CLI discovers mediators by scanning this directory and filtering out stale sockets (dead PIDs).
Rustab emits full tab IDs that include the browser prefix, mediator PID, and browser tab ID: c.18452.123, b.20881.456, f.19001.789, etc. The legacy two-part form (c.123) is still accepted when only one matching browser instance is connected.
Window IDs use the same scoped form with a w marker: c.18452.w.12, b.20881.w.34, etc. Raw browser window IDs are accepted by commands that target a window when only one browser instance is in play, but the scoped IDs from rustab windows are the safest form for scripts.
Add rustab as a flake input:
{
inputs.rustab.url = "github:carjorvaz/rustab";
inputs.rustab.inputs.nixpkgs.follows = "nixpkgs";
}The flake provides six packages:
rustab-- CLI + mediator binaries with native messaging manifestschrome-extension-- unpacked Chromium extension directoryfirefox-extension-- AMO-signed XPI for Firefoxcheck-version-sync-- helper for verifying release metadata stays alignedrefresh-firefox-xpi-- helper for re-signing and refreshing the checked-in Firefox XPIpackage-chromium-release-- helper for building a signed CRX + update feed bundle
The rustab package also exposes passthru metadata:
chromeExtensionfirefoxExtensionchromeExtensionIdfirefoxExtensionId
The flake lib output also provides:
chromeExtensionIdfirefoxExtensionIdmkChromiumPolicy
On Linux, a browser wrapper can load the unpacked extension via --load-extension:
# In your browser overlay or wrapper
"--load-extension=${inputs.rustab.packages.${system}.chrome-extension}"Or, if you are using Home Manager's Chromium module:
let
rustab = inputs.rustab.packages.${pkgs.stdenv.hostPlatform.system}.default;
in {
home.packages = [ rustab ];
programs.brave = {
enable = true;
nativeMessagingHosts = [ rustab ];
};
}On macOS, Chromium browsers still require a one-time manual extension install because fully declarative installation would need a packaged CRX and hosted update manifest. A clean approach is to expose the unpacked extension at a stable path in your home directory and load it once from brave://extensions, chrome://extensions, or Orion's Tools > Extensions > Install from Disk.
Rustab also installs the native messaging host manifest for Brave into Chromium-family fallback locations on macOS. This is intentional: current Brave releases do not always discover NativeMessagingHosts from their branded BraveSoftware/Brave-Browser application-support directory, but they do reliably pick up the standard Chromium user paths.
That means rustab install may report multiple manifest locations for a single Brave profile on macOS. This is expected.
On macOS, rustab install also writes Orion's native messaging host manifest to ~/Library/Application Support/Orion/NativeMessagingHosts.
For managed Chromium installation on Linux, or for enterprise-managed Chromium installation on macOS, package a signed CRX and a static update manifest:
nix run .#package-chromium-release -- \
--key /secure/path/rustab-chromium.pem \
--base-url https://example.com/rustab/chromiumThis produces:
rustab-<version>.crxupdates.xmlextension-settings.json
The packaged release injects update_url into the staged manifest and signs the CRX with the provided private key, so future updates keep the same extension ID.
If you want updates.xml to live at one URL and the .crx to live somewhere else, pass an explicit codebase URL:
nix run .#package-chromium-release -- \
--key /secure/path/rustab-chromium.pem \
--base-url https://carjorvaz.github.io/rustab/chromium \
--codebase-url https://github.com/carjorvaz/rustab/releases/download/vX.Y.Z/rustab-X.Y.Z.crxThat split is a good fit for GitHub Pages + GitHub Releases: keep updates.xml and extension-settings.json on Pages, keep the versioned CRX on Releases.
For managed Chromium browsers, host those files at the base-url you passed above and install with enterprise policy. In Nix, you can either write the policy yourself or use the helper from inputs.rustab.lib:
inputs.rustab.lib.mkChromiumPolicy {
updateUrl = "https://example.com/rustab/chromium/updates.xml";
}That returns:
{
ExtensionSettings = {
"<rustab-extension-id>" = {
installation_mode = "force_installed";
update_url = "https://example.com/rustab/chromium/updates.xml";
override_update_url = true;
};
};
}For Home Manager + Brave on Linux, that policy can be installed through the usual Chromium managed policy paths. For nix-darwin + Brave on macOS, serialize the same ExtensionSettings structure into com.brave.Browser.plist under /Library/Managed Preferences/<user>/, but only expect off-store self-hosted installs to work when the browser is enterprise-managed. On unmanaged macOS Brave, the supported Rustab path remains a one-time Load unpacked step for the extension plus the declarative native-host setup described above.
Rustab includes a tag-driven GitHub Actions workflow at .github/workflows/release.yml that automates the clean GitHub-hosted path:
- verifies that Cargo, Chromium, and Firefox source metadata agree on
X.Y.Z - runs formatting, clippy, and tests
- signs
rustab@rustab.dev.xpi - runs flake checks after the freshly signed XPI is in place
- signs
rustab-<version>.crx - uploads both browser artifacts to GitHub Releases
- deploys
updates.xmlandextension-settings.jsonto GitHub Pages under/chromium/
To use it:
- Enable GitHub Pages for the repository with
GitHub Actionsas the source. - Add the repository secret
CHROMIUM_EXTENSION_KEY_PEMcontaining the private key that matches the public key embedded inextensions/chrome/manifest.json. - Add the repository secrets
WEB_EXT_API_KEYandWEB_EXT_API_SECRETfor AMO unlisted signing. - Optionally set the repository variable
RUSTAB_CHROMIUM_BASE_URLif you want a custom Pages or custom-domain URL. Otherwise the workflow defaults tohttps://<owner>.github.io/<repo>/chromium. - Push a tag like
vX.Y.Z.
The workflow does not require a gh-pages branch. It deploys Pages directly from the workflow artifact, which keeps the repository history free of generated release files.
For normal push and pull request validation, .github/workflows/ci.yml runs formatting, clippy, tests, and nix flake check without needing signing secrets.
# home-manager
let
rustab = inputs.rustab.packages.${pkgs.stdenv.hostPlatform.system}.default;
in
programs.firefox = {
nativeMessagingHosts = [ rustab ];
profiles.default.extensions.packages = [ rustab.firefoxExtension ];
};cargo build --release
./target/release/rustab installThen load the browser extension:
- Chrome/Brave: Go to
chrome://extensionsorbrave://extensions, enable Developer Mode, and "Load unpacked" fromextensions/chrome/ - Orion: Open
Tools > Extensions > Install from Diskand chooseextensions/chrome/ - Firefox: Open
extensions/firefox-signed/rustab@rustab.dev.xpiin Firefox to install
rustab install uses the built-in Chromium extension ID by default. If you're testing a custom unpacked Chromium extension build with a different ID, pass --chrome-extension-id <ID>.
rustab list # list all tabs (TSV)
rustab list --format json # list all tabs (JSON)
rustab list --browser brave # list tabs from Brave only
rustab windows # list browser windows
rustab windows --format json # list windows with scoped IDs and active tabs
rustab synced list --browser orion # list synced Orion tabs cached locally on macOS
rustab synced list --browser orion --archived # inspect the newest non-empty archived Orion sync snapshot
rustab synced list --format json # list synced tabs as JSON
rustab close b.18452.42 b.18452.99 # close specific tabs
rustab list | grep github | rustab close # pipe pattern
rustab move --to-window b.18452.w.7 b.18452.42 # move a tab to a window
rustab list | grep YouTube | rustab move --to-window b.18452.w.7 # consolidate tabs
rustab move --to-tab b.18452.99 b.18452.42 # move a tab to the window containing another tab
rustab activate c.18452.42 # focus a tab
rustab open https://example.com # open URL in the first responsive browser
rustab open -b firefox https://x.com # open in specific browser
rustab open --window b.18452.w.7 https://example.com # open in a specific window
rustab clients # show connected browsers, mediator PIDs, and sockets
rustab doctor # diagnose manifests, mediators, and extension support
rustab synced list is intentionally read-only. Today it supports Orion on macOS by reading Orion's locally cached sync state. By default it reads the live browser_session_state.plist view when available, falling back to Orion's current synced-tab plist on older layouts; --archived is a debugging escape hatch for the newest non-empty backup snapshot. Orion's live session-state data does not appear to include a friendly device name, so current entries may omit device_id even when archived snapshots have one.
The flake includes a dev shell with Rust toolchain and web-ext for Firefox extension signing:
nix run .#check-version-sync
# Refresh the checked-in signed Firefox XPI after extension changes
nix run .#refresh-firefox-xpi
# Re-run the consistency check before tagging a release
nix run .#check-version-syncIf the same Firefox version has already been submitted to AMO and is already public, refresh-firefox-xpi will download that existing signed XPI instead of failing on a duplicate-version error. That makes reruns and release recovery much calmer.
The Chromium release helper also works well from the dev shell:
nix develop -c package-chromium-release -- \
--key /secure/path/rustab-chromium.pem \
--base-url https://example.com/rustab/chromiumAGPL-3.0-or-later