tabbridge-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TabBridge contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,179 @@
1
+ # TabBridge
2
+
3
+ English · [简体中文](README.zh-CN.md)
4
+
5
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
7
+ TabBridge is a CLI tool and Skill designed for AI Agents: after user authorization, it allows AI agents to operate on **tabs you already have open** in your real browser through a browser extension—instead of launching a fresh browser instance like Playwright, Puppeteer, or Agent Browser.
8
+
9
+ ## Core Positioning
10
+
11
+ > Don't open a new browser instance. Connect the agent to your already-open tabs and let AI seamlessly take over.
12
+
13
+ Traditional browser automation tools spin up a new browser process, which means:
14
+
15
+ - Already-logged-in accounts must log in again
16
+ - Cookies, localStorage, and session state are lost
17
+ - Additional authentication steps are required (SMS, 2FA, QR code scans)
18
+
19
+ TabBridge is different: it takes over **tabs that are already running in your real browser**, letting AI continue working within your existing session.
20
+
21
+ ## Why TabBridge
22
+
23
+ | Capability | Playwright / Puppeteer / Agent Browser | TabBridge |
24
+ |------------|----------------------------------------|-----------|
25
+ | Target | Fresh browser instance | Your already-open tabs |
26
+ | Login state | Must re-authenticate | Inherits current session |
27
+ | Deployment | Needs browser driver or remote infra | Local CLI + browser extension |
28
+ | AI-friendliness | Raw HTML, high token cost | Structured Snapshot, token-efficient |
29
+ | Dangerous actions | Usually none | Coordinate actions require user confirmation |
30
+
31
+ ## Key Features
32
+
33
+ - **Session takeover**: connect to the currently active tab or any already-open tab.
34
+ - **AI-friendly Snapshot**: converts a page into a structured semantic view with interactive element refs (`@e1`, `@e2`…), helping large models understand pages faster while consuming fewer tokens.
35
+ - **Ref-based actions**: perform `click`, `fill`, `type`, `select`, `check`, and more using semantic refs from the latest snapshot.
36
+ - **Fine-grained permissions**: explicit user authorization is required before reading or acting; grants expire after 30 minutes and re-authorization is needed when a tab navigates to a different origin. Approval requests shown in the extension popup expire after 5 minutes if not acted on.
37
+ - **High-risk action confirmation**: coordinate actions (`click-coordinates`, `drag-coordinates`) must be manually confirmed in the browser extension popup.
38
+ - **Local-first**: the CLI communicates with the browser extension through a local WebSocket broker, with no reliance on cloud browser farms.
39
+
40
+ ## Architecture Overview
41
+
42
+ ```text
43
+ ┌─────────────────────────────────────────────────────────────┐
44
+ │ AI Agent (via Skill) │
45
+ │ invokes `tabbridge` commands │
46
+ └───────────────────────────┬─────────────────────────────────┘
47
+ │ process spawn
48
+ ┌───────────────────────────▼─────────────────────────────────┐
49
+ │ TabBridge CLI (Node.js) │
50
+ └───────────────────────────┬─────────────────────────────────┘
51
+ │ WebSocket
52
+ ┌───────────────────────────▼─────────────────────────────────┐
53
+ │ TabBridge Broker │
54
+ │ (local WebSocket server) │
55
+ └───────────────────────────┬─────────────────────────────────┘
56
+ │ WebSocket
57
+ ┌───────────────────────────▼─────────────────────────────────┐
58
+ │ Chrome Extension │
59
+ │ (offscreen document + content script + popup) │
60
+ └───────────────────────────┬─────────────────────────────────┘
61
+
62
+ ┌───────────────────────────▼─────────────────────────────────┐
63
+ │ User's Webpage │
64
+ │ (already-open tab) │
65
+ └─────────────────────────────────────────────────────────────┘
66
+ ```
67
+
68
+ The first `tabbridge` command automatically starts the local broker; you do not need to run it manually.
69
+
70
+ ## Quick Start
71
+
72
+ ### Prerequisites
73
+
74
+ - Node.js (LTS recommended)
75
+ - Chrome / Chromium 116+
76
+ - macOS, Windows, or Linux
77
+
78
+ ### 1. Install the CLI
79
+
80
+ ```bash
81
+ npm install -g tabbridge-cli
82
+ ```
83
+
84
+ After installation, the `tabbridge` command is available globally.
85
+
86
+ ### 2. Install the browser extension
87
+
88
+ > The extension is not yet on the Chrome Web Store. Until it is published, you need to build and load it manually from source.
89
+
90
+ Clone the repository, install workspace dependencies from the monorepo root, build the extension with WXT, and load it into Chrome:
91
+
92
+ ```bash
93
+ pnpm install
94
+ pnpm --filter @tabbridge/chrome-extension build
95
+ ```
96
+
97
+ Then open `chrome://extensions`, enable **Developer mode**, click **Load unpacked**, and select `packages/chrome-extension/dist/chrome-mv3/` (the directory that contains `manifest.json`).
98
+
99
+ When you run `tabbridge tabs request-access`, approve the site in the extension popup. You can also revoke or manage access later in the extension details page.
100
+
101
+ Note: the extension popup UI is currently in Chinese. Approval buttons are 允许 (Allow), 允许一次 (Allow once), and 拒绝 (Deny).
102
+
103
+ For development, run `pnpm --filter @tabbridge/chrome-extension dev`; WXT outputs to `packages/chrome-extension/dist/chrome-mv3-dev/`.
104
+
105
+ ### 3. Connect and operate a page
106
+
107
+ Make sure the active tab is a normal `http://` or `https://` page. Internal Chrome pages, extension pages, file URLs, and `about:` pages are not supported in the current version.
108
+
109
+ ```bash
110
+ # Check connection status
111
+ tabbridge status --json
112
+
113
+ # List controllable tabs
114
+ tabbridge tabs list --json
115
+
116
+ # Connect to the currently active tab
117
+ tabbridge connect --json
118
+
119
+ # Request access to the page
120
+ tabbridge tabs request-access --tab <tabId> --reason "Read page content" --json
121
+
122
+ # Get an AI-friendly snapshot (`-i` is accepted for compatibility but ignored)
123
+ tabbridge snapshot -i --json
124
+
125
+ # Perform an action
126
+ tabbridge click @e1 --json
127
+ ```
128
+
129
+ ## Snapshot: A Page View Optimized for LLMs
130
+
131
+ `tabbridge snapshot` does not dump the entire DOM to the model. Instead, it generates a compact semantic representation:
132
+
133
+ ```text
134
+ Page: Example
135
+ URL: https://example.com
136
+
137
+ @e1 [button] "Save"
138
+ @e2 [textbox] placeholder="Comment"
139
+ ```
140
+
141
+ Each `@eN` ref is a temporary label for the current snapshot. The AI can use it to precisely refer to elements without parsing verbose HTML or CSS selectors. Refs are volatile and reassigned on every snapshot, ensuring actions are always based on the latest page state.
142
+
143
+ ## Security & Approval Boundaries
144
+
145
+ TabBridge puts the security of your browser context first:
146
+
147
+ - **Site authorization**: each origin requires separate user approval.
148
+ - **Session isolation**: no extraction of cookies, localStorage, credentials, or tokens.
149
+ - **No arbitrary code execution**: injecting arbitrary JavaScript or intercepting network traffic is not allowed.
150
+ - **High-risk confirmation**: coordinate actions require confirmation through the browser extension popup.
151
+ - **No silent fallback**: ref actions are never silently replaced by coordinate actions.
152
+ - **No secrets in argv**: never pass passwords, 2FA codes, payment details, credentials, or tokens as CLI arguments. Use `type --text-stdin` / `fill --text-stdin` for sensitive input.
153
+
154
+ ## Project Structure
155
+
156
+ ```text
157
+ tabbridge/
158
+ ├── packages/
159
+ │ ├── cli/ # TabBridge CLI (the `tabbridge` command)
160
+ │ ├── chrome-extension/ # Chrome / Chromium browser extension
161
+ │ ├── broker/ # Local WebSocket bridge service
162
+ │ └── shared/ # Shared types and error codes
163
+ ├── skills/
164
+ │ └── tabbridge/ # Claude Skill and reference docs
165
+ └── docs/ # Design documents
166
+ ```
167
+
168
+ ## Development
169
+
170
+ ```bash
171
+ pnpm test # run the full test suite
172
+ pnpm typecheck # run TypeScript checks
173
+ pnpm lint # run lint / type checks across packages
174
+ pnpm clean # remove build artifacts
175
+ ```
176
+
177
+ ## License
178
+
179
+ [MIT](LICENSE)
@@ -0,0 +1,177 @@
1
+ # TabBridge
2
+
3
+ 简体中文 · [English](README.md)
4
+
5
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
7
+
8
+ TabBridge 是一款为 AI Agent 设计的 CLI 工具与 Skill:在获得用户授权后,它能让 AI 直接操作你**当前已经打开的浏览器页面**,通过浏览器插件而不是像 Playwright、Puppeteer 或 Agent Browser 那样启动一个全新的浏览器实例。
9
+
10
+ ## 核心定位
11
+
12
+ > 不是新开一个浏览器实例,而是连接 Agent 与你已打开的网页,让 AI 无缝接手。
13
+
14
+ 传统的浏览器自动化工具会新开一个浏览器进程或实例,导致:
15
+
16
+ - 已经登录的账号需要重新登录
17
+ - Cookie、localStorage、会话状态全部丢失
18
+ - 需要额外的认证流程(短信、2FA、扫码)
19
+
20
+ TabBridge 不同:它接管你**真实浏览器中正在运行的标签页**,让 AI 在你的现有会话上继续工作。
21
+
22
+ ## 为什么是 TabBridge
23
+
24
+ | 能力 | Playwright / Puppeteer / Agent Browser | TabBridge |
25
+ |------|----------------------------------------|-----------|
26
+ | 操作对象 | 新开的浏览器实例 | 用户已打开的页面 |
27
+ | 登录态 | 需要重新登录 | 继承当前会话 |
28
+ | 部署方式 | 需要浏览器驱动或远程环境 | 本地 CLI + 浏览器插件 |
29
+ | 对 AI 友好度 | 原始 HTML,Token 消耗大 | 结构化 Snapshot,更省 Token |
30
+ | 危险操作审核 | 通常无 | 坐标类等高危操作需用户确认 |
31
+
32
+ ## 主要特性
33
+
34
+ - **会话接管**:连接当前激活的标签页,或指定任意已打开的标签页。
35
+ - **AI 友好的 Snapshot**:将网页转换为结构化的语义视图,包含可交互元素引用(`@e1`、`@e2`…),让大模型更快理解页面,显著减少 Token 消耗。
36
+ - **Ref-based 操作**:基于 Snapshot 中的语义引用执行 `click`、`fill`、`type`、`select`、`check` 等操作。
37
+ - **细粒度权限控制**:读取页面、执行动作前需要用户显式授权;授权在 30 分钟后过期,且网页切换 origin 后需重新授权。浏览器插件弹窗中的授权请求若 5 分钟内未处理将会过期。
38
+ - **高危操作确认**:坐标类操作(`click-coordinates`、`drag-coordinates`)必须通过浏览器插件弹窗手动确认。
39
+ - **本地优先**:CLI 经本地 Broker 与浏览器插件建立 WebSocket 通信,不依赖云端浏览器集群。
40
+
41
+ ## 架构概览
42
+
43
+ ```text
44
+ ┌─────────────────────────────────────────────────────────────┐
45
+ │ AI Agent(via Skill) │
46
+ │ 调用 `tabbridge` 命令行 │
47
+ └───────────────────────────┬─────────────────────────────────┘
48
+ │ 进程调用
49
+ ┌───────────────────────────▼─────────────────────────────────┐
50
+ │ TabBridge CLI(Node.js) │
51
+ └───────────────────────────┬─────────────────────────────────┘
52
+ │ WebSocket
53
+ ┌───────────────────────────▼─────────────────────────────────┐
54
+ │ TabBridge Broker │
55
+ │ (本地 WebSocket 服务) │
56
+ └───────────────────────────┬─────────────────────────────────┘
57
+ │ WebSocket
58
+ ┌───────────────────────────▼─────────────────────────────────┐
59
+ │ Chrome Extension │
60
+ │ (离屏文档 + 内容脚本 + 浏览器弹窗) │
61
+ └───────────────────────────┬─────────────────────────────────┘
62
+
63
+ ┌───────────────────────────▼─────────────────────────────────┐
64
+ │ 用户已打开的网页 │
65
+ └─────────────────────────────────────────────────────────────┘
66
+ ```
67
+
68
+ 首次运行任意 `tabbridge` 命令会自动启动本地 Broker,无需手动启动。
69
+
70
+ ## 快速开始
71
+
72
+ ### 前置条件
73
+
74
+ - Node.js(建议使用 LTS)
75
+ - Chrome / Chromium 116+
76
+ - macOS、Windows 或 Linux
77
+
78
+ ### 1. 安装 CLI
79
+
80
+ ```bash
81
+ npm install -g tabbridge-cli
82
+ ```
83
+
84
+ 安装完成后,`tabbridge` 命令即可全局使用。
85
+
86
+ ### 2. 安装浏览器插件
87
+
88
+ > 扩展目前尚未上架 Chrome Web Store。在上架之前,你需要从源码构建并手动加载。
89
+
90
+ 克隆仓库,在 monorepo 根目录安装 workspace 依赖,使用 WXT 构建并加载到 Chrome:
91
+
92
+ ```bash
93
+ pnpm install
94
+ pnpm --filter @tabbridge/chrome-extension build
95
+ ```
96
+
97
+ 然后打开 `chrome://extensions`,开启**开发者模式**,点击**加载已解压的扩展**,选择 `packages/chrome-extension/dist/chrome-mv3/`(包含 `manifest.json` 的目录)。
98
+
99
+ 运行 `tabbridge tabs request-access` 后,在扩展弹窗中批准站点访问。也可以稍后在扩展详情页的站点访问中管理。
100
+
101
+ 开发时请运行 `pnpm --filter @tabbridge/chrome-extension dev`;WXT 会输出到 `packages/chrome-extension/dist/chrome-mv3-dev/`。
102
+
103
+ ### 3. 连接并操作页面
104
+
105
+ 请确保当前标签页是正常的 `http://` 或 `https://` 页面。当前版本不支持 Chrome 内部页面、扩展页面、文件 URL 和 `about:` 页面。
106
+
107
+ ```bash
108
+ # 检查连接状态
109
+ tabbridge status --json
110
+
111
+ # 列出可操作的标签页
112
+ tabbridge tabs list --json
113
+
114
+ # 连接到当前激活的标签页
115
+ tabbridge connect --json
116
+
117
+ # 请求页面访问授权
118
+ tabbridge tabs request-access --tab <tabId> --reason "读取页面内容" --json
119
+
120
+ # 获取 AI 友好的 Snapshot(`-i` 为兼容参数,可忽略)
121
+ tabbridge snapshot -i --json
122
+
123
+ # 执行操作
124
+ tabbridge click @e1 --json
125
+ ```
126
+
127
+ ## Snapshot:为 AI 大模型优化的页面视图
128
+
129
+ `tabbridge snapshot` 不会把整个 DOM dump 给模型,而是生成一个紧凑的语义表示:
130
+
131
+ ```text
132
+ Page: Example
133
+ URL: https://example.com
134
+
135
+ @e1 [button] "Save"
136
+ @e2 [textbox] placeholder="Comment"
137
+ ```
138
+
139
+ 每个 `@eN` 引用都是当前快照下的临时标签,AI 可以用它精确地指代元素,而无需处理冗长的 HTML 或 CSS 选择器。引用具有易变性,每次快照都会重新分配,确保操作始终基于最新页面状态。
140
+
141
+ ## 安全与审批边界
142
+
143
+ TabBridge 将用户浏览器上下文的安全放在首位:
144
+
145
+ - **站点授权**:每个 origin 都需要用户单独授权。
146
+ - **会话隔离**:不提取 Cookie、localStorage、凭证或 Token。
147
+ - **无任意代码执行**:禁止注入任意 JavaScript 或拦截网络请求。
148
+ - **高危操作确认**:坐标类操作必须经浏览器插件弹窗确认后才能执行。
149
+ - **无静默降级**:不会把 ref 操作静默替换为坐标操作。
150
+ - **不在命令行传 secrets**:永远不要把密码、2FA 码、支付信息、凭证或 Token 放在 CLI 参数里。敏感输入请使用 `type --text-stdin` / `fill --text-stdin`。
151
+
152
+ ## 项目结构
153
+
154
+ ```text
155
+ tabbridge/
156
+ ├── packages/
157
+ │ ├── cli/ # TabBridge CLI(tabbridge 命令)
158
+ │ ├── chrome-extension/ # Chrome / Chromium 浏览器插件
159
+ │ ├── broker/ # 本地 WebSocket 桥接服务
160
+ │ └── shared/ # 共享类型与错误码
161
+ ├── skills/
162
+ │ └── tabbridge/ # Claude Skill 与使用参考
163
+ └── docs/ # 设计文档
164
+ ```
165
+
166
+ ## 开发
167
+
168
+ ```bash
169
+ pnpm test # 运行完整测试套件
170
+ pnpm typecheck # 运行 TypeScript 检查
171
+ pnpm lint # 运行各包的 lint / 类型检查
172
+ pnpm clean # 清理构建产物
173
+ ```
174
+
175
+ ## 许可证
176
+
177
+ [MIT](LICENSE)
package/broker.js ADDED
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/runtime.ts
4
+ import crypto from "crypto";
5
+ import fs from "fs/promises";
6
+ import path from "path";
7
+ import envPaths from "env-paths";
8
+
9
+ // ../shared/dist/index.js
10
+ var ERROR_CODES = [
11
+ "EXTENSION_NOT_CONNECTED",
12
+ "BRIDGE_SOCKET_UNAVAILABLE",
13
+ "BRIDGE_REQUEST_TIMEOUT",
14
+ "TAB_NOT_FOUND",
15
+ "TAB_NOT_AUTHORIZED",
16
+ "TAB_NOT_ACTIVE_FOR_SCREENSHOT",
17
+ "HOST_PERMISSION_DENIED",
18
+ "USER_APPROVAL_REQUIRED",
19
+ "APPROVAL_EXPIRED",
20
+ "APPROVAL_TIMEOUT",
21
+ "UNSUPPORTED_PAGE",
22
+ "FRAME_NOT_ACCESSIBLE",
23
+ "FRAME_ORIGIN_NOT_AUTHORIZED",
24
+ "SNAPSHOT_REQUIRED",
25
+ "REF_STALE",
26
+ "ELEMENT_NOT_VISIBLE",
27
+ "ELEMENT_DISABLED",
28
+ "ELEMENT_SCOPE_TOO_LARGE",
29
+ "ACTION_REQUIRES_CONFIRMATION",
30
+ "ACTION_NOT_SUPPORTED_IN_EXTENSION_MODE",
31
+ "USER_DENIED",
32
+ "MESSAGE_TOO_LARGE",
33
+ "PROTOCOL_VERSION_MISMATCH",
34
+ "BROWSER_COMMAND_TIMEOUT",
35
+ "EXTENSION_ID_MISMATCH"
36
+ ];
37
+ var JSON_RPC_TABBRIDGE_ERROR_BASE = -32e3;
38
+ var TAB_BRIDGE_ERROR_CODE_INDEX = Object.fromEntries(
39
+ ERROR_CODES.map((code, index) => [code, index])
40
+ );
41
+ function tabBridgeErrorToJsonRpc(error) {
42
+ return {
43
+ code: JSON_RPC_TABBRIDGE_ERROR_BASE - TAB_BRIDGE_ERROR_CODE_INDEX[error.code],
44
+ message: error.code,
45
+ data: error
46
+ };
47
+ }
48
+ var JSON_RPC_VERSION = "2.0";
49
+ function createJsonRpcSuccess(id, result) {
50
+ return { jsonrpc: JSON_RPC_VERSION, id, result };
51
+ }
52
+ function createJsonRpcError(id, error) {
53
+ return { jsonrpc: JSON_RPC_VERSION, id, error };
54
+ }
55
+ var NATIVE_HOST_TO_CHROME_MAX_BYTES = 1024 * 1024;
56
+ var CHROME_TO_NATIVE_HOST_MAX_BYTES = 64 * 1024 * 1024;
57
+ var SNAPSHOT_DEFAULT_MAX_BYTES = 256 * 1024;
58
+ var TEXT_DEFAULT_MAX_BYTES = 128 * 1024;
59
+ var HTML_DEFAULT_MAX_BYTES = 64 * 1024;
60
+ var GRANT_TTL_MS = 30 * 6e4;
61
+ var BROKER_PORT = 9876;
62
+
63
+ // src/runtime.ts
64
+ function defaultSupportDir() {
65
+ return envPaths("tabbridge", { suffix: "" }).data;
66
+ }
67
+ function createRuntimePaths(supportDir) {
68
+ const dir = supportDir ?? defaultSupportDir();
69
+ return {
70
+ supportDir: dir,
71
+ tokenPath: path.join(dir, "broker-token"),
72
+ lockPath: path.join(dir, "broker.lock")
73
+ };
74
+ }
75
+ async function ensureSupportDir(paths) {
76
+ await fs.mkdir(paths.supportDir, { recursive: true, mode: 448 });
77
+ await fs.chmod(paths.supportDir, 448);
78
+ }
79
+ function generateToken() {
80
+ return crypto.randomBytes(32).toString("hex");
81
+ }
82
+ async function writeToken(paths, token) {
83
+ await fs.writeFile(paths.tokenPath, `${token}
84
+ `, { mode: 384 });
85
+ await fs.chmod(paths.tokenPath, 384);
86
+ }
87
+
88
+ // src/lock.ts
89
+ import lockfile from "proper-lockfile";
90
+ async function acquireBrokerLock(lockPath) {
91
+ return await lockfile.lock(lockPath, {
92
+ stale: 5e3,
93
+ update: 2e3,
94
+ retries: 0
95
+ });
96
+ }
97
+
98
+ // src/server.ts
99
+ import { WebSocket, WebSocketServer } from "ws";
100
+ var STANDARD_ERRORS = {
101
+ parseError: { code: -32700, message: "Parse error" },
102
+ invalidRequest: { code: -32600, message: "Invalid Request" },
103
+ methodNotFound: { code: -32601, message: "Method not found" },
104
+ internalError: { code: -32603, message: "Internal error" },
105
+ duplicatePendingRequest: { code: -32600, message: "Duplicate pending request id" }
106
+ };
107
+ var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
108
+ function isRecord(value) {
109
+ return typeof value === "object" && value !== null;
110
+ }
111
+ function isAuthMessage(value) {
112
+ return isRecord(value) && ("token" in value || "role" in value);
113
+ }
114
+ function looksLikeResponse(value) {
115
+ return typeof value.id === "string" && ("result" in value || "error" in value);
116
+ }
117
+ var BrokerServer = class {
118
+ constructor(options) {
119
+ this.options = options;
120
+ this.wss = new WebSocketServer({ port: options.port });
121
+ this.port = this.wss.address().port;
122
+ this.wss.on("connection", (socket, req) => this.handleConnection(socket, req));
123
+ }
124
+ options;
125
+ wss;
126
+ clients = /* @__PURE__ */ new Set();
127
+ extensionClient;
128
+ pending = /* @__PURE__ */ new Map();
129
+ hello;
130
+ port;
131
+ status() {
132
+ return {
133
+ cliConnected: [...this.clients].some((c) => c.role === "cli"),
134
+ extensionConnected: this.extensionClient !== void 0 && this.extensionClient.socket.readyState === WebSocket.OPEN,
135
+ extensionHello: this.hello
136
+ };
137
+ }
138
+ handleConnection(socket, req) {
139
+ const client = { socket, authenticated: false };
140
+ this.clients.add(client);
141
+ const authTimeout = setTimeout(() => {
142
+ if (!client.authenticated) socket.close();
143
+ }, 5e3);
144
+ socket.on("message", async (data) => {
145
+ try {
146
+ await this.handleMessage(client, data, req.headers.origin);
147
+ } catch {
148
+ socket.send(JSON.stringify(createJsonRpcError("unknown", STANDARD_ERRORS.internalError)));
149
+ }
150
+ });
151
+ socket.on("close", () => {
152
+ clearTimeout(authTimeout);
153
+ this.clients.delete(client);
154
+ if (this.extensionClient === client) {
155
+ this.extensionClient = void 0;
156
+ this.hello = void 0;
157
+ this.failAllPending(extensionNotConnectedError());
158
+ } else {
159
+ this.clearPendingForClient(client);
160
+ }
161
+ });
162
+ }
163
+ async handleMessage(client, data, origin) {
164
+ let parsed;
165
+ try {
166
+ parsed = JSON.parse(data.toString("utf8"));
167
+ } catch {
168
+ client.socket.send(JSON.stringify(createJsonRpcError("unknown", STANDARD_ERRORS.parseError)));
169
+ return;
170
+ }
171
+ if (!client.authenticated) {
172
+ if (!isAuthMessage(parsed)) {
173
+ client.socket.close();
174
+ return;
175
+ }
176
+ if (parsed.token === this.options.token) {
177
+ client.authenticated = true;
178
+ client.role = "cli";
179
+ } else if (parsed.role === "extension" && typeof origin === "string" && origin.startsWith("chrome-extension://")) {
180
+ client.authenticated = true;
181
+ client.role = "extension";
182
+ this.extensionClient = client;
183
+ } else {
184
+ client.socket.close();
185
+ }
186
+ return;
187
+ }
188
+ if (!isRecord(parsed)) return;
189
+ if (client.role === "extension") {
190
+ if (client !== this.extensionClient) return;
191
+ if (looksLikeResponse(parsed)) {
192
+ const pendingRequest = this.pending.get(parsed.id);
193
+ if (pendingRequest) {
194
+ this.clearPendingRequest(parsed.id, pendingRequest);
195
+ pendingRequest.client.socket.send(JSON.stringify(parsed));
196
+ }
197
+ return;
198
+ }
199
+ if (parsed.method === "broker.hello") {
200
+ this.hello = parsed.params;
201
+ client.socket.send(JSON.stringify(createJsonRpcSuccess(String(parsed.id), { ok: true })));
202
+ }
203
+ return;
204
+ }
205
+ const request = parsed;
206
+ if (request.jsonrpc !== "2.0" || typeof request.method !== "string" || typeof request.id !== "string") {
207
+ client.socket.send(JSON.stringify(createJsonRpcError(String(request.id ?? "unknown"), STANDARD_ERRORS.invalidRequest)));
208
+ return;
209
+ }
210
+ if (!this.extensionClient || this.extensionClient.socket.readyState !== WebSocket.OPEN) {
211
+ this.sendJsonRpcError(client, request.id, tabBridgeErrorToJsonRpc(extensionNotConnectedError()));
212
+ return;
213
+ }
214
+ if (this.pending.has(request.id)) {
215
+ this.sendJsonRpcError(client, request.id, STANDARD_ERRORS.duplicatePendingRequest);
216
+ return;
217
+ }
218
+ const requestId = request.id;
219
+ const timer = setTimeout(() => {
220
+ const pendingRequest = this.pending.get(requestId);
221
+ if (!pendingRequest) return;
222
+ this.clearPendingRequest(requestId, pendingRequest);
223
+ this.sendJsonRpcError(pendingRequest.client, requestId, tabBridgeErrorToJsonRpc(requestTimeoutError()));
224
+ }, this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
225
+ this.pending.set(requestId, { client, timer });
226
+ this.extensionClient.socket.send(JSON.stringify(request));
227
+ }
228
+ clearPendingRequest(id, pendingRequest) {
229
+ clearTimeout(pendingRequest.timer);
230
+ this.pending.delete(id);
231
+ }
232
+ clearPendingForClient(client) {
233
+ for (const [id, pendingRequest] of this.pending) {
234
+ if (pendingRequest.client === client) this.clearPendingRequest(id, pendingRequest);
235
+ }
236
+ }
237
+ failAllPending(error) {
238
+ for (const [id, pendingRequest] of this.pending) {
239
+ this.clearPendingRequest(id, pendingRequest);
240
+ this.sendJsonRpcError(pendingRequest.client, id, tabBridgeErrorToJsonRpc(error));
241
+ }
242
+ }
243
+ sendJsonRpcError(client, id, error) {
244
+ client.socket.send(JSON.stringify(createJsonRpcError(id, error)));
245
+ }
246
+ close() {
247
+ return new Promise((resolve) => {
248
+ for (const [id, pendingRequest] of this.pending) {
249
+ this.clearPendingRequest(id, pendingRequest);
250
+ }
251
+ this.wss.close(() => resolve());
252
+ for (const client of this.clients) {
253
+ client.socket.terminate();
254
+ }
255
+ });
256
+ }
257
+ };
258
+ function extensionNotConnectedError() {
259
+ return {
260
+ code: "EXTENSION_NOT_CONNECTED",
261
+ message: "The TabBridge extension is not connected.",
262
+ recoverable: true,
263
+ suggestedCommand: "Open Chrome and click the TabBridge extension icon, then run tabbridge status --json."
264
+ };
265
+ }
266
+ function requestTimeoutError() {
267
+ return {
268
+ code: "BRIDGE_REQUEST_TIMEOUT",
269
+ message: "The TabBridge extension did not respond before the broker request timed out.",
270
+ recoverable: true,
271
+ suggestedCommand: "Retry the command. If it keeps timing out, open Chrome and restart the TabBridge extension."
272
+ };
273
+ }
274
+
275
+ // src/run-broker.ts
276
+ import fs2 from "fs/promises";
277
+ async function runBroker(pathsArg, port = BROKER_PORT) {
278
+ const paths = pathsArg ?? createRuntimePaths();
279
+ await ensureSupportDir(paths);
280
+ await fs2.writeFile(paths.lockPath, "", { mode: 384 });
281
+ const release = await acquireBrokerLock(paths.lockPath);
282
+ const token = generateToken();
283
+ await writeToken(paths, token);
284
+ const server = new BrokerServer({ port, token });
285
+ return {
286
+ port: server.port,
287
+ close: async () => {
288
+ await server.close();
289
+ await release();
290
+ }
291
+ };
292
+ }
293
+
294
+ // src/main.ts
295
+ import path2 from "path";
296
+ import { fileURLToPath } from "url";
297
+ function isExecutedEntrypoint() {
298
+ if (!process.argv[1]) return false;
299
+ return fileURLToPath(import.meta.url) === path2.resolve(process.argv[1]);
300
+ }
301
+ if (isExecutedEntrypoint()) {
302
+ runBroker().catch((error) => {
303
+ console.error(error);
304
+ process.exitCode = 1;
305
+ });
306
+ }
package/main.js ADDED
@@ -0,0 +1,599 @@
1
+ #!/usr/bin/env node
2
+ // ../shared/dist/index.js
3
+ var ERROR_CODES = [
4
+ "EXTENSION_NOT_CONNECTED",
5
+ "BRIDGE_SOCKET_UNAVAILABLE",
6
+ "BRIDGE_REQUEST_TIMEOUT",
7
+ "TAB_NOT_FOUND",
8
+ "TAB_NOT_AUTHORIZED",
9
+ "TAB_NOT_ACTIVE_FOR_SCREENSHOT",
10
+ "HOST_PERMISSION_DENIED",
11
+ "USER_APPROVAL_REQUIRED",
12
+ "APPROVAL_EXPIRED",
13
+ "APPROVAL_TIMEOUT",
14
+ "UNSUPPORTED_PAGE",
15
+ "FRAME_NOT_ACCESSIBLE",
16
+ "FRAME_ORIGIN_NOT_AUTHORIZED",
17
+ "SNAPSHOT_REQUIRED",
18
+ "REF_STALE",
19
+ "ELEMENT_NOT_VISIBLE",
20
+ "ELEMENT_DISABLED",
21
+ "ELEMENT_SCOPE_TOO_LARGE",
22
+ "ACTION_REQUIRES_CONFIRMATION",
23
+ "ACTION_NOT_SUPPORTED_IN_EXTENSION_MODE",
24
+ "USER_DENIED",
25
+ "MESSAGE_TOO_LARGE",
26
+ "PROTOCOL_VERSION_MISMATCH",
27
+ "BROWSER_COMMAND_TIMEOUT",
28
+ "EXTENSION_ID_MISMATCH"
29
+ ];
30
+ function bridgeNotConnectedError(state) {
31
+ if (state === "chrome_closed") {
32
+ return {
33
+ code: "EXTENSION_NOT_CONNECTED",
34
+ message: "Chrome is not connected to the TabBridge broker.",
35
+ recoverable: true,
36
+ suggestedCommand: "Open Chrome, confirm the TabBridge extension is enabled, then run tabbridge status --json."
37
+ };
38
+ }
39
+ return {
40
+ code: "EXTENSION_NOT_CONNECTED",
41
+ message: "The TabBridge extension service worker is not connected to the broker.",
42
+ recoverable: true,
43
+ suggestedCommand: "Open Chrome and click the TabBridge extension icon to start the broker, then run tabbridge status --json."
44
+ };
45
+ }
46
+ var TAB_BRIDGE_ERROR_CODE_INDEX = Object.fromEntries(
47
+ ERROR_CODES.map((code, index) => [code, index])
48
+ );
49
+ function jsonRpcErrorToTabBridgeError(error) {
50
+ const data = error.data;
51
+ if (typeof data !== "object" || data === null) return void 0;
52
+ const candidate = data;
53
+ if (typeof candidate.code === "string" && ERROR_CODES.includes(candidate.code) && typeof candidate.message === "string" && typeof candidate.recoverable === "boolean") {
54
+ return candidate;
55
+ }
56
+ return void 0;
57
+ }
58
+ var JSON_RPC_VERSION = "2.0";
59
+ function createJsonRpcRequest(id, method, params) {
60
+ const req = { jsonrpc: JSON_RPC_VERSION, id, method };
61
+ if (params !== void 0) {
62
+ req.params = params;
63
+ }
64
+ return req;
65
+ }
66
+ var NATIVE_HOST_TO_CHROME_MAX_BYTES = 1024 * 1024;
67
+ var CHROME_TO_NATIVE_HOST_MAX_BYTES = 64 * 1024 * 1024;
68
+ var SNAPSHOT_DEFAULT_MAX_BYTES = 256 * 1024;
69
+ var TEXT_DEFAULT_MAX_BYTES = 128 * 1024;
70
+ var HTML_DEFAULT_MAX_BYTES = 64 * 1024;
71
+ var GRANT_TTL_MS = 30 * 6e4;
72
+ var APPROVAL_WAIT_DEFAULT_TIMEOUT_MS = 3e4;
73
+ var BROKER_PORT = 9876;
74
+ function okEnvelope(data) {
75
+ return { ok: true, data };
76
+ }
77
+ function errorEnvelope(error) {
78
+ return { ok: false, error };
79
+ }
80
+
81
+ // src/broker-client.ts
82
+ import { WebSocket } from "ws";
83
+ function closedBeforeResponseError() {
84
+ return errorEnvelope({
85
+ code: "BRIDGE_SOCKET_UNAVAILABLE",
86
+ message: "Broker closed the connection before sending a complete response.",
87
+ recoverable: true,
88
+ suggestedCommand: "tabbridge status --json"
89
+ });
90
+ }
91
+ function timeoutError() {
92
+ return errorEnvelope({
93
+ code: "BRIDGE_REQUEST_TIMEOUT",
94
+ message: "Timed out waiting for the broker response.",
95
+ recoverable: true,
96
+ suggestedCommand: "tabbridge status --json"
97
+ });
98
+ }
99
+ function protocolError(message) {
100
+ return errorEnvelope({
101
+ code: "PROTOCOL_VERSION_MISMATCH",
102
+ message,
103
+ recoverable: true,
104
+ suggestedCommand: "tabbridge status --json"
105
+ });
106
+ }
107
+ function parseResponse(request, raw) {
108
+ let parsed;
109
+ try {
110
+ parsed = JSON.parse(raw);
111
+ } catch {
112
+ return protocolError("Broker returned malformed JSON.");
113
+ }
114
+ if (parsed.id !== request.id) {
115
+ return protocolError(`Broker response id ${String(parsed.id)} did not match request id ${request.id}.`);
116
+ }
117
+ if ("result" in parsed && parsed.result !== void 0) {
118
+ return okEnvelope(parsed.result);
119
+ }
120
+ if ("error" in parsed && parsed.error !== void 0) {
121
+ const businessError = jsonRpcErrorToTabBridgeError(parsed.error);
122
+ if (businessError) return errorEnvelope(businessError);
123
+ const synthetic = {
124
+ code: "PROTOCOL_VERSION_MISMATCH",
125
+ message: parsed.error.message,
126
+ recoverable: true
127
+ };
128
+ return errorEnvelope(synthetic);
129
+ }
130
+ return protocolError("Broker returned an invalid JSON-RPC response.");
131
+ }
132
+ async function sendBrokerRequest(request, options) {
133
+ return await new Promise((resolve) => {
134
+ const ws = new WebSocket(options.url);
135
+ let settled = false;
136
+ const finish = (envelope) => {
137
+ if (settled) return;
138
+ settled = true;
139
+ clearTimeout(timer);
140
+ ws.terminate();
141
+ resolve(envelope);
142
+ };
143
+ const timer = setTimeout(() => finish(timeoutError()), options.timeoutMs);
144
+ ws.once("open", () => {
145
+ ws.send(JSON.stringify({ type: "auth", token: options.token }));
146
+ ws.send(JSON.stringify(request));
147
+ });
148
+ ws.once("error", () => finish(errorEnvelope(bridgeNotConnectedError("extension_asleep"))));
149
+ ws.once("close", () => {
150
+ if (!settled) finish(closedBeforeResponseError());
151
+ });
152
+ ws.on("message", (data) => {
153
+ const text = data.toString("utf8");
154
+ try {
155
+ const probe = JSON.parse(text);
156
+ if (probe.type === "auth") return;
157
+ } catch {
158
+ }
159
+ finish(parseResponse(request, text));
160
+ });
161
+ });
162
+ }
163
+
164
+ // src/cli.ts
165
+ function isValueFlag(token) {
166
+ return token.startsWith("--") && token !== "--json" && token !== "--current" && token !== "--text-stdin";
167
+ }
168
+ function readFlag(argv, flag) {
169
+ const index = argv.indexOf(flag);
170
+ if (index < 0) return void 0;
171
+ const value = argv[index + 1];
172
+ if (value === void 0 || value.startsWith("--") || value === "-i") throw new Error(`${flag} requires a value`);
173
+ return value;
174
+ }
175
+ function hasFlag(argv, flag) {
176
+ return argv.includes(flag);
177
+ }
178
+ function readNumberFlag(argv, flag) {
179
+ const value = readFlag(argv, flag);
180
+ if (value === void 0) return void 0;
181
+ const parsed = Number(value);
182
+ if (!Number.isInteger(parsed)) throw new Error(`${flag} must be an integer`);
183
+ return parsed;
184
+ }
185
+ function requireNumberFlag(argv, flag, command) {
186
+ const value = readNumberFlag(argv, flag);
187
+ if (value === void 0) throw new Error(`${command} requires ${flag}`);
188
+ return value;
189
+ }
190
+ function requireStringFlag(argv, flag, command) {
191
+ const value = readFlag(argv, flag);
192
+ if (!value) throw new Error(`${command} requires ${flag}`);
193
+ return value;
194
+ }
195
+ function positionalArgs(argv) {
196
+ const values = [];
197
+ for (let index = 0; index < argv.length; index += 1) {
198
+ const token = argv[index];
199
+ if (!token) continue;
200
+ if (token.startsWith("--")) {
201
+ if (isValueFlag(token)) index += 1;
202
+ continue;
203
+ }
204
+ if (token === "-i") continue;
205
+ values.push(token);
206
+ }
207
+ return values;
208
+ }
209
+ function parseRefAction(first, argv, json) {
210
+ const [, ref, value] = positionalArgs(argv);
211
+ if (!ref) throw new Error(`${first} requires a ref like @e1`);
212
+ const payload = { ref };
213
+ if (first === "select") {
214
+ if (!value) throw new Error("select requires a value");
215
+ payload.value = value;
216
+ }
217
+ return { command: `action.${first}`, json, payload };
218
+ }
219
+ function parseTextAction(first, argv, json) {
220
+ const [, ref, positionalText] = positionalArgs(argv);
221
+ if (!ref) throw new Error(`${first} requires a ref like @e1`);
222
+ const textFromStdin = hasFlag(argv, "--text-stdin");
223
+ const flagText = readFlag(argv, "--text");
224
+ const text = flagText ?? positionalText;
225
+ if (!text && !textFromStdin) throw new Error(`${first} requires text or --text-stdin`);
226
+ return {
227
+ command: `action.${first}`,
228
+ json,
229
+ payload: textFromStdin ? { ref, textFromStdin: true } : { ref, text }
230
+ };
231
+ }
232
+ function parseCli(argv) {
233
+ const json = hasFlag(argv, "--json");
234
+ const [first, second] = argv;
235
+ if (first === "navigate") {
236
+ throw new Error("navigate is not part of the TabBridge MVP command set");
237
+ }
238
+ if (first === "status") return { command: "status", json, payload: {} };
239
+ if (first === "doctor") return { command: "doctor", json, payload: {} };
240
+ if (first === "tabs" && second === "list") return { command: "tabs.list", json, payload: {} };
241
+ if (first === "tabs" && second === "current") return { command: "tabs.current", json, payload: {} };
242
+ if (first === "tabs" && second === "release") {
243
+ return { command: "tabs.release", json, payload: { tabId: requireNumberFlag(argv, "--tab", "tabs release") } };
244
+ }
245
+ if (first === "tabs" && second === "request-access") {
246
+ return {
247
+ command: "tabs.requestAccess",
248
+ json,
249
+ payload: {
250
+ tabId: requireNumberFlag(argv, "--tab", "tabs request-access"),
251
+ reason: requireStringFlag(argv, "--reason", "tabs request-access")
252
+ }
253
+ };
254
+ }
255
+ if (first === "approvals" && second === "status") {
256
+ return { command: "approvals.status", json, payload: { approvalId: requireStringFlag(argv, "--id", "approvals status") } };
257
+ }
258
+ if (first === "approvals" && second === "cancel") {
259
+ return { command: "approvals.cancel", json, payload: { approvalId: requireStringFlag(argv, "--id", "approvals cancel") } };
260
+ }
261
+ if (first === "approvals" && second === "wait") {
262
+ const timeout = readNumberFlag(argv, "--timeout");
263
+ return { command: "approvals.wait", json, payload: { approvalId: requireStringFlag(argv, "--id", "approvals wait"), timeoutMs: timeout } };
264
+ }
265
+ if (first === "connect") {
266
+ const tabId = readNumberFlag(argv, "--tab");
267
+ return { command: "session.connect", json, payload: tabId === void 0 ? { current: true } : { tabId } };
268
+ }
269
+ if (first === "session") return { command: "session.status", json, payload: {} };
270
+ if (first === "disconnect") return { command: "session.disconnect", json, payload: {} };
271
+ if (first === "snapshot") return { command: "snapshot", json, payload: { interactive: true } };
272
+ if (first === "text") return { command: "text", json, payload: { maxBytes: readNumberFlag(argv, "--max-bytes") } };
273
+ if (first === "html") {
274
+ return {
275
+ command: "html",
276
+ json,
277
+ payload: {
278
+ ref: requireStringFlag(argv, "--ref", "html"),
279
+ maxBytes: readNumberFlag(argv, "--max-bytes")
280
+ }
281
+ };
282
+ }
283
+ if (first === "screenshot") {
284
+ const [, path2] = positionalArgs(argv);
285
+ return { command: "screenshot", json, payload: path2 ? { path: path2 } : {} };
286
+ }
287
+ const refActions = /* @__PURE__ */ new Set(["click", "clear", "select", "check", "uncheck", "focus"]);
288
+ if (first && refActions.has(first)) return parseRefAction(first, argv, json);
289
+ if (first === "fill" || first === "type") return parseTextAction(first, argv, json);
290
+ if (first === "press") return { command: "action.press", json, payload: { key: requireStringFlag(argv, "--key", "press") } };
291
+ if (first === "scroll") return { command: "action.scroll", json, payload: { dx: readNumberFlag(argv, "--dx") ?? 0, dy: readNumberFlag(argv, "--dy") ?? 0 } };
292
+ if (first === "click-coordinates") return { command: "action.clickCoordinates", json, payload: { x: requireNumberFlag(argv, "--x", "click-coordinates"), y: requireNumberFlag(argv, "--y", "click-coordinates") } };
293
+ if (first === "drag-coordinates") return { command: "action.dragCoordinates", json, payload: { fromX: requireNumberFlag(argv, "--from-x", "drag-coordinates"), fromY: requireNumberFlag(argv, "--from-y", "drag-coordinates"), toX: requireNumberFlag(argv, "--to-x", "drag-coordinates"), toY: requireNumberFlag(argv, "--to-y", "drag-coordinates") } };
294
+ if (first === "wait") return { command: "wait", json, payload: { ms: requireNumberFlag(argv, "--ms", "wait") } };
295
+ if (first === "wait-for-text") return { command: "waitForText", json, payload: { text: requireStringFlag(argv, "--text", "wait-for-text"), timeoutMs: readNumberFlag(argv, "--timeout") } };
296
+ if (first === "reload") return { command: "navigation.reload", json, payload: {} };
297
+ if (first === "back") return { command: "navigation.back", json, payload: {} };
298
+ if (first === "forward") return { command: "navigation.forward", json, payload: {} };
299
+ throw new Error(`Unknown tabbridge command: ${argv.join(" ")}`);
300
+ }
301
+
302
+ // src/commands.ts
303
+ function mapCliToJsonRpcRequest(parsed, id) {
304
+ const payload = { ...parsed.payload };
305
+ if (parsed.command === "approvals.wait" && typeof payload.timeoutMs !== "number") {
306
+ payload.timeoutMs = APPROVAL_WAIT_DEFAULT_TIMEOUT_MS;
307
+ }
308
+ return createJsonRpcRequest(id, parsed.command, payload);
309
+ }
310
+
311
+ // src/doctor.ts
312
+ import fs2 from "fs/promises";
313
+
314
+ // ../broker/dist/index.js
315
+ import { WebSocket as WebSocket2, WebSocketServer } from "ws";
316
+ import fs from "fs/promises";
317
+ import path from "path";
318
+ import envPaths from "env-paths";
319
+ import lockfile from "proper-lockfile";
320
+ var ERROR_CODES2 = [
321
+ "EXTENSION_NOT_CONNECTED",
322
+ "BRIDGE_SOCKET_UNAVAILABLE",
323
+ "BRIDGE_REQUEST_TIMEOUT",
324
+ "TAB_NOT_FOUND",
325
+ "TAB_NOT_AUTHORIZED",
326
+ "TAB_NOT_ACTIVE_FOR_SCREENSHOT",
327
+ "HOST_PERMISSION_DENIED",
328
+ "USER_APPROVAL_REQUIRED",
329
+ "APPROVAL_EXPIRED",
330
+ "APPROVAL_TIMEOUT",
331
+ "UNSUPPORTED_PAGE",
332
+ "FRAME_NOT_ACCESSIBLE",
333
+ "FRAME_ORIGIN_NOT_AUTHORIZED",
334
+ "SNAPSHOT_REQUIRED",
335
+ "REF_STALE",
336
+ "ELEMENT_NOT_VISIBLE",
337
+ "ELEMENT_DISABLED",
338
+ "ELEMENT_SCOPE_TOO_LARGE",
339
+ "ACTION_REQUIRES_CONFIRMATION",
340
+ "ACTION_NOT_SUPPORTED_IN_EXTENSION_MODE",
341
+ "USER_DENIED",
342
+ "MESSAGE_TOO_LARGE",
343
+ "PROTOCOL_VERSION_MISMATCH",
344
+ "BROWSER_COMMAND_TIMEOUT",
345
+ "EXTENSION_ID_MISMATCH"
346
+ ];
347
+ var TAB_BRIDGE_ERROR_CODE_INDEX2 = Object.fromEntries(
348
+ ERROR_CODES2.map((code, index) => [code, index])
349
+ );
350
+ var NATIVE_HOST_TO_CHROME_MAX_BYTES2 = 1024 * 1024;
351
+ var CHROME_TO_NATIVE_HOST_MAX_BYTES2 = 64 * 1024 * 1024;
352
+ var SNAPSHOT_DEFAULT_MAX_BYTES2 = 256 * 1024;
353
+ var TEXT_DEFAULT_MAX_BYTES2 = 128 * 1024;
354
+ var HTML_DEFAULT_MAX_BYTES2 = 64 * 1024;
355
+ var GRANT_TTL_MS2 = 30 * 6e4;
356
+ function defaultSupportDir() {
357
+ return envPaths("tabbridge", { suffix: "" }).data;
358
+ }
359
+ function createRuntimePaths(supportDir) {
360
+ const dir = supportDir ?? defaultSupportDir();
361
+ return {
362
+ supportDir: dir,
363
+ tokenPath: path.join(dir, "broker-token"),
364
+ lockPath: path.join(dir, "broker.lock")
365
+ };
366
+ }
367
+ async function readToken(paths) {
368
+ try {
369
+ return (await fs.readFile(paths.tokenPath, "utf8")).trim();
370
+ } catch (error) {
371
+ if (error.code === "ENOENT") return void 0;
372
+ throw error;
373
+ }
374
+ }
375
+
376
+ // src/ensure-broker.ts
377
+ import { spawn } from "child_process";
378
+ import { access } from "fs/promises";
379
+ import { fileURLToPath } from "url";
380
+ import { WebSocket as WebSocket3 } from "ws";
381
+ var DEFAULT_BROKER_URL = `ws://127.0.0.1:${BROKER_PORT}`;
382
+ async function isBrokerListening(url) {
383
+ return await new Promise((resolve) => {
384
+ const ws = new WebSocket3(url);
385
+ const timer = setTimeout(() => {
386
+ ws.terminate();
387
+ resolve(false);
388
+ }, 500);
389
+ ws.once("open", () => {
390
+ clearTimeout(timer);
391
+ ws.terminate();
392
+ resolve(true);
393
+ });
394
+ ws.once("error", () => {
395
+ clearTimeout(timer);
396
+ resolve(false);
397
+ });
398
+ });
399
+ }
400
+ async function defaultBrokerEntryExists(path2) {
401
+ try {
402
+ await access(path2);
403
+ return true;
404
+ } catch {
405
+ return false;
406
+ }
407
+ }
408
+ function brokerEntryPath() {
409
+ return fileURLToPath(new URL("./broker.js", import.meta.url));
410
+ }
411
+ async function brokerStartArgs(brokerEntryExists) {
412
+ const entryPath = brokerEntryPath();
413
+ if (!await brokerEntryExists(entryPath)) {
414
+ throw new Error(`BROKER_ENTRY_MISSING: expected built broker entry at ${entryPath}; please reinstall the tabbridge package`);
415
+ }
416
+ return [entryPath];
417
+ }
418
+ function waitForBroker(child, url, isListening, options) {
419
+ return new Promise((resolve, reject) => {
420
+ const start = Date.now();
421
+ const timer = setInterval(async () => {
422
+ try {
423
+ if (await isListening(url)) {
424
+ clearInterval(timer);
425
+ resolve();
426
+ } else if (Date.now() - start >= options.timeoutMs) {
427
+ clearInterval(timer);
428
+ reject(new Error("BROKER_START_FAILED: broker did not start in time"));
429
+ }
430
+ } catch {
431
+ if (Date.now() - start >= options.timeoutMs) {
432
+ clearInterval(timer);
433
+ reject(new Error("BROKER_START_FAILED: broker did not start in time"));
434
+ }
435
+ }
436
+ }, options.intervalMs);
437
+ child.on("error", (error) => {
438
+ clearInterval(timer);
439
+ reject(new Error(`BROKER_START_FAILED: ${error.message}`));
440
+ });
441
+ });
442
+ }
443
+ async function ensureBroker(options = {}) {
444
+ const paths = options.paths ?? createRuntimePaths();
445
+ const url = DEFAULT_BROKER_URL;
446
+ const listening = options.isListening ?? isBrokerListening;
447
+ const readBrokerToken = options.readToken ?? readToken;
448
+ if (await listening(url)) {
449
+ const token2 = await readBrokerToken(paths);
450
+ if (!token2) {
451
+ throw new Error("BROKER_TOKEN_MISSING: broker is listening but the token file is missing; restart the broker");
452
+ }
453
+ return { url, token: token2 };
454
+ }
455
+ const child = (options.spawn ?? spawn)(process.execPath, await brokerStartArgs(options.brokerEntryExists ?? defaultBrokerEntryExists), {
456
+ detached: true,
457
+ stdio: "ignore",
458
+ windowsHide: true
459
+ });
460
+ child.unref();
461
+ await waitForBroker(
462
+ child,
463
+ url,
464
+ listening,
465
+ { timeoutMs: options.waitTimeoutMs ?? 5e3, intervalMs: options.waitIntervalMs ?? 100 }
466
+ );
467
+ const token = await readBrokerToken(paths);
468
+ if (!token) {
469
+ throw new Error("BROKER_TOKEN_MISSING: broker started but the token file is missing; restart the broker");
470
+ }
471
+ return { url, token };
472
+ }
473
+
474
+ // src/doctor.ts
475
+ async function exists(filePath) {
476
+ try {
477
+ await fs2.access(filePath);
478
+ return true;
479
+ } catch {
480
+ return false;
481
+ }
482
+ }
483
+ async function fileMode(filePath) {
484
+ try {
485
+ return (await fs2.stat(filePath)).mode;
486
+ } catch {
487
+ return void 0;
488
+ }
489
+ }
490
+ async function runDoctor(input = {}) {
491
+ const paths = createRuntimePaths(input.home);
492
+ const listening = await isBrokerListening(`ws://127.0.0.1:${BROKER_PORT}`);
493
+ const tokenExists = await exists(paths.tokenPath);
494
+ const tokenMode = await fileMode(paths.tokenPath);
495
+ const lockExists = await exists(paths.lockPath);
496
+ const checks = [
497
+ { name: "broker is listening on port 9876", ok: listening },
498
+ { name: "broker token file exists", ok: tokenExists },
499
+ {
500
+ name: "broker token file mode is 0600",
501
+ ok: process.platform === "win32" || tokenMode !== void 0 && (tokenMode & 511) === 384
502
+ },
503
+ { name: "broker lock file exists", ok: lockExists }
504
+ ];
505
+ if (!listening) {
506
+ return { ok: false, bridgeState: "extension_asleep", errorCode: "EXTENSION_NOT_CONNECTED", checks };
507
+ }
508
+ const ok = checks.every((check) => check.ok);
509
+ return { ok, bridgeState: "connected", checks };
510
+ }
511
+
512
+ // src/json-output.ts
513
+ function printJsonEnvelope(envelope, stdout) {
514
+ stdout.write(`${JSON.stringify(envelope)}
515
+ `);
516
+ }
517
+ function printCliError(error, json, stdout, stderr) {
518
+ const message = error instanceof Error ? error.message : "Unknown CLI error";
519
+ if (json) {
520
+ printJsonEnvelope(errorEnvelope({ code: "BRIDGE_SOCKET_UNAVAILABLE", message, recoverable: true }), stdout);
521
+ return;
522
+ }
523
+ stderr.write(`${message}
524
+ `);
525
+ }
526
+
527
+ // src/main.ts
528
+ async function readStdin(stdin) {
529
+ const chunks = [];
530
+ for await (const chunk of stdin) {
531
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
532
+ }
533
+ return Buffer.concat(chunks).toString("utf8");
534
+ }
535
+ async function hydrateStdinPayload(parsed, stdin) {
536
+ if (!["action.type", "action.fill"].includes(parsed.command) || parsed.payload.textFromStdin !== true) return parsed;
537
+ const { textFromStdin: _textFromStdin, ...payloadWithoutMarker } = parsed.payload;
538
+ return {
539
+ ...parsed,
540
+ payload: {
541
+ ...payloadWithoutMarker,
542
+ text: await readStdin(stdin)
543
+ }
544
+ };
545
+ }
546
+ async function run(options = {}) {
547
+ const argv = options.argv ?? process.argv.slice(2);
548
+ const json = argv.includes("--json");
549
+ const stdout = options.stdout ?? process.stdout;
550
+ const stderr = options.stderr ?? process.stderr;
551
+ const stdin = options.stdin ?? process.stdin;
552
+ const requestId = options.requestId ?? (() => `req_${process.pid}_${Date.now()}`);
553
+ const ensureBroker2 = options.ensureBroker ?? ensureBroker;
554
+ const sendBrokerRequest2 = options.sendBrokerRequest ?? sendBrokerRequest;
555
+ const runDoctor2 = options.runDoctor ?? runDoctor;
556
+ try {
557
+ const parsed = await hydrateStdinPayload(parseCli(argv), stdin);
558
+ if (parsed.command === "doctor") {
559
+ const report = await runDoctor2();
560
+ printJsonEnvelope(okEnvelope(report), stdout);
561
+ return report.ok ? 0 : 1;
562
+ }
563
+ const broker = await ensureBroker2();
564
+ const request = mapCliToJsonRpcRequest(parsed, requestId());
565
+ const envelope = await sendBrokerRequest2(request, { url: broker.url, token: broker.token, timeoutMs: 3e4 });
566
+ if (parsed.json) {
567
+ printJsonEnvelope(envelope, stdout);
568
+ } else if (envelope.ok) {
569
+ if (parsed.command === "snapshot" && typeof envelope.data.text === "string") {
570
+ stdout.write(`${envelope.data.text}
571
+ `);
572
+ } else {
573
+ stdout.write(`${JSON.stringify(envelope.data, null, 2)}
574
+ `);
575
+ }
576
+ } else {
577
+ stderr.write(`${envelope.error.message}
578
+ `);
579
+ }
580
+ return envelope.ok ? 0 : 1;
581
+ } catch (error) {
582
+ printCliError(error, json, stdout, stderr);
583
+ return 1;
584
+ }
585
+ }
586
+ if (process.env.VITEST !== "true") {
587
+ run().then(
588
+ (code) => {
589
+ process.exitCode = code;
590
+ },
591
+ (error) => {
592
+ console.error(error);
593
+ process.exitCode = 1;
594
+ }
595
+ );
596
+ }
597
+ export {
598
+ run
599
+ };
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "tabbridge-cli",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "bin": {
7
+ "tabbridge": "main.js"
8
+ },
9
+ "files": [
10
+ "main.js",
11
+ "broker.js",
12
+ "README.md",
13
+ "README.zh-CN.md",
14
+ "LICENSE"
15
+ ],
16
+ "dependencies": {
17
+ "env-paths": "^4.0.0",
18
+ "proper-lockfile": "^4.1.2",
19
+ "ws": "^8.18.0"
20
+ }
21
+ }