Skip to content

rummykhan/cli-companion

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

cli-companion

cli-companion side panel running a live zsh next to github.com

Your real local shell — zsh, bash, whatever $SHELL points to — running inside a browser side panel. xterm.js on the inside, a tiny Node PTY bridge on the outside. Open the panel, type commands, reload the page next to it.

Think VS Code's integrated terminal, but in the browser you're actually building the thing for. Multi-tab, auto-reconnect, no page-content access.

Ships with a Chrome (MV3) extension today; Firefox is planned and will slot in alongside.

npm install && npm run build:xterm && npm start
# then load extensions/chrome/ as unpacked in chrome://extensions
┌──────────── browser ────────────┐        ┌────── your machine ──────┐
│  ┌── page ───┐  ┌─ side panel ┐ │        │                          │
│  │           │  │ xterm.js    │◀┼─WS────▶│ bridge/server.js         │
│  │  render   │  │ terminal    │ │ :7681  │  ws + node-pty → zsh     │
│  └───────────┘  └─────────────┘ │        │                          │
└─────────────────────────────────┘        └──────────────────────────┘

Why a bridge

Browser extensions (Chrome MV3, and similarly Firefox) can't spawn processes — the sandbox forbids it. So the extension can't spawn("zsh") directly. The pragmatic workaround is a tiny local process that owns the PTY and speaks WebSocket: the extension is the renderer, the bridge is the shell host. Bridge binds to 127.0.0.1 only, so nothing is exposed off-machine.

Layout

bridge/server.js                  # ~60 lines: ws + node-pty, one pty per connection
extensions/
  chrome/                         # MV3 Chrome extension
    manifest.json                 # uses side_panel + tabs + storage
    background.js                 # opens the side panel on toolbar click
    sidepanel.html / .css / .js   # xterm.js UI + reconnect / reload-tab / settings
    vendor/                       # xterm.js assets (generated by build:xterm)
    icon.png                      # placeholder 1×1; swap for a real one
  # firefox/                      # planned — same UI, MV2/MV3-compatible shim
scripts/
  copy-xterm.js                   # vendors xterm from node_modules into each extension
  fix-node-pty-perms.js           # postinstall: restore +x on spawn-helper (see below)
package.json

Setup

npm install                # installs node-pty + ws, vendors nothing yet
npm run build:xterm        # copies xterm.js + addons into extensions/chrome/vendor/
npm start                  # starts bridge on ws://127.0.0.1:7681

Then load the Chrome extension:

  1. Chrome → chrome://extensions
  2. Enable Developer mode (top-right).
  3. Load unpacked → select the extensions/chrome/ folder.
  4. Pin the icon. Click it → side panel opens with a live shell.

Side-panel UI

Control What it does
status pill online / connecting / offline — WebSocket state of the active tab
tab strip Click a tab to switch, × to close, + to open a new one
↻ tab Reloads the browser's active tab — the edit-in-terminal / see-it-render loop
Force-reconnect the active session to the bridge
Change the bridge URL (persisted in chrome.storage.local)

Keyboard shortcuts (on the side panel):

  • ⌘/Ctrl + T — new tab
  • ⌘/Ctrl + W — close active tab
  • ⌘/Ctrl + 1..9 — switch to tab 1..9

Other niceties:

  • Each tab is an independent PTY (the bridge already spawns one per WebSocket connection).
  • Tab label follows the PTY title (OSC 0/2 escape), so ssh host / vim file retitle the tab automatically.
  • Clicking URLs in the terminal opens them in a browser tab (WebLinksAddon).
  • Resizing the panel resizes the active PTY (FitAddon + ResizeObserver → control frame).
  • Auto-reconnect with a 2s backoff if the bridge goes away; tab count is remembered across reopens.

Wire protocol

WebSocket, text frames. The extension uses a small in-band control channel so we don't need a second socket for resize.

  • Client → server
    • Stdin: plain text (UTF-8 bytes as strings).
    • Control frame: first byte 0x01, then JSON.
      • {"type":"resize","cols":N,"rows":N}
  • Server → client
    • Raw stdout bytes from the PTY.

Configuration

  • CLI_COMPANION_PORT (default 7681) — change the bridge port, then update the URL in ⚙ inside the side panel. The legacy CHROME_CLI_PORT is still honored as a fallback.
  • SHELL — whatever's in your env; override it if you want bash on a zsh box.

Gotchas we hit (and fixed)

  • Dropbox strips +x from node-pty's spawn-helper, which makes pty.fork() die with posix_spawnp failed. scripts/fix-node-pty-perms.js runs as a postinstall and restores the bit. If you ever see that error again, re-run npm install. Same thing applies to iCloud Drive and OneDrive.
  • MV3 can't load scripts from a CDN, so xterm has to be vendored into the extension. npm run build:xterm does this — extensions/*/vendor/ is gitignored; regenerate on fresh clones.
  • Resize control frames need branching on text-vs-binary, not on raw[0]ws gives you a Buffer either way but the framing type matters. Fixed in bridge/server.js; verified end-to-end (resize to 132×40 → tput cols returns 132).

Prior art

The pieces here aren't new — the packaging is.

  • ttyd (11.5k ⭐) — the grandparent of this space. A C server that exposes a local shell over WebSocket and serves its own web UI at localhost:7681. Same architectural shape as bridge/server.js, minus the extension. If you want a browser terminal and don't care about side-panel integration, use ttyd.
  • ysk2014/webshell, wrfly/container-web-tty, and friends — node-pty + xterm.js + a web page, usually container-scoped.
  • Chrome's old Secure Shell (hterm/nassh) from Google — SSH client in a Chrome app, not a local shell.
  • VS Code's integrated terminal — does exactly this pattern, but inside an editor, not a browser extension.

What's different here: it's an MV3 side-panel extension, not a full-tab web app. The terminal lives next to the page you're working on, the ↻ tab button reloads that page, and tabs inside the panel are independent PTYs. Small, focused, and opinionated toward the edit-in-shell / see-it-render loop rather than general terminal-in-browser.

Security notes

  • Bridge listens on 127.0.0.1 only; no external exposure.
  • Any process on your machine that can open a loopback socket can talk to the bridge. That's fine for a dev tool; if you want paranoia, add a shared-secret handshake on connect.
  • MV3 extension only holds sidePanel, tabs, storage, and host permission for ws://127.0.0.1/* + ws://localhost/*. No page content access.

Roadmap

  • Firefox extension under extensions/firefox/ — same xterm.js UI, adapted manifest.
  • companion-reload / companion-open <url> CLIs — the bridge reads markers on the PTY's stdout and forwards actions back to the extension over the same WebSocket.
  • companion-screenshot — use chrome.tabs.captureVisibleTab from the panel and dump a PNG to stdout.
  • Persist scrollback in chrome.storage.local across side-panel reopens.

Contributing

Small, focused project. PRs welcome — especially for the Firefox port. The layout is intentionally set up so a new extension is just a sibling directory under extensions/ and one more entry in scripts/copy-xterm.js.

License

MIT — see LICENSE.