@glittering0717/ai-browser 1.3.1 → 2.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.
Files changed (37) hide show
  1. package/README.md +56 -5
  2. package/bin/ai-browser.cjs +41 -4
  3. package/examples/AGENT_INSTRUCTIONS.md +49 -164
  4. package/llms-install.md +67 -5
  5. package/package.json +3 -1
  6. package/scripts/cli_call.cjs +222 -0
  7. package/scripts/ensure_running.cjs +195 -0
  8. package/scripts/skill.cjs +133 -0
  9. package/skills/ai-browser/SKILL.md +56 -0
  10. package/skills/ai-browser/references/cli.md +81 -0
  11. package/skills/ai-browser/references/hard-pages.md +52 -0
  12. package/skills/ai-browser/references/network.md +39 -0
  13. package/skills/ai-browser/references/tasks.md +44 -0
  14. package/skills/ai-browser/references/troubleshooting.md +31 -0
  15. package/skills/content-publish/SKILL.md +61 -0
  16. package/skills/read-webpage/SKILL.md +52 -0
  17. package/skills/web-network-monitor/SKILL.md +52 -0
  18. package/src/main/axExtractor.js +50 -6
  19. package/src/main/capability_probe.js +93 -5
  20. package/src/main/index.js +99 -6
  21. package/src/main/mcp_args.js +122 -0
  22. package/src/main/mcp_server.js +72 -159
  23. package/src/main/mcp_tools.js +127 -86
  24. package/src/main/mcp_ws.js +7 -1
  25. package/src/main/page_manager.js +481 -71
  26. package/src/main/window_open_shim_source.js +117 -0
  27. package/src/preload/bridge.cjs +22 -4
  28. package/src/preload/extractor.cjs +161 -17
  29. package/src/shared/doc_coverage.js +264 -0
  30. package/src/shared/effect_state.js +160 -0
  31. package/src/shared/frame_probe.cjs +91 -1
  32. package/src/shared/frame_read_source.js +260 -0
  33. package/src/shared/opened_tab.js +71 -0
  34. package/src/shared/ref_epoch.js +99 -0
  35. package/src/shared/tab_hooks.js +33 -0
  36. package/src/shared/version.js +1 -1
  37. package/tools/coverage_probe.cjs +111 -9
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env node
2
+ // scripts/cli_call.cjs — 命令行直接调浏览器,**不用写任何客户端脚本**。
3
+ //
4
+ // 为什么要有这个(真实抱怨,2026-10-19):
5
+ // 「我看操作这个项目,经常需要写 cjs 代码,这是必须的吗?」
6
+ // 不是必须的,但以前确实没有别的办法:对外只有两个入口 —— MCP stdio(给 agent
7
+ // 客户端用)和裸 WebSocket JSON-RPC(ws://127.0.0.1:9223)。想从 shell 里问一句
8
+ // "现在有哪些标签页",就得先 npm i ws、再写一个连上发 JSON 的小脚本、再等回包。
9
+ // 一个本来只是一行命令的事情,被迫变成"写代码"。
10
+ //
11
+ // 所以这里补的是**通道**,不是新能力:它做的就是把一条 JSON-RPC 发出去、把 result
12
+ // 打出来。所有能力仍然只有一套实现(page_manager),MCP 和 CLI 都是它的薄壳。
13
+ //
14
+ // 用法:
15
+ // ai-browser call ui.list_tabs
16
+ // ai-browser call ui.get_tree '{"subset":"interactive"}'
17
+ // ai-browser call ui.navigate '{"url":"https://example.com"}'
18
+ // echo '{"url":"https://example.com"}' | ai-browser call ui.navigate -
19
+ // ai-browser call ui.act '{"action":"click","target":"e:a-1"}'
20
+ // ai-browser status
21
+ //
22
+ // 选项:
23
+ // --port <n> 默认 9223(也读 AI_BROWSER_WS_PORT)
24
+ // --timeout <ms> 默认 30000
25
+ // --no-start 连不上**不要**自动拉起浏览器(脚本里想快速失败时用)
26
+ // --compact 单行输出(默认就是紧凑 JSON,加这个只是显式)
27
+ // --pretty 缩进输出(给人看时用)
28
+ //
29
+ // 关于自动拉起(2026-10-20 改):`call` 连不上时默认会先把浏览器拉起来再重试一次。
30
+ // 理由:agent 装完包应该一条命令就能干活,而不是先学"要 start 再 call"两步。
31
+ // `status` 不这么做 —— 它问的是"在跑吗",回答了"不在"才算答对。
32
+ 'use strict';
33
+
34
+ const WS_PORT_DEFAULT = 9223;
35
+ const TIMEOUT_DEFAULT = 30000;
36
+
37
+ function parseArgs(argv) {
38
+ const out = { port: null, timeout: TIMEOUT_DEFAULT, pretty: false, start: true, rest: [] };
39
+ for (let i = 0; i < argv.length; i++) {
40
+ const a = argv[i];
41
+ if (a === '--port') { out.port = Number(argv[++i]); continue; }
42
+ if (a === '--timeout') { out.timeout = Number(argv[++i]); continue; }
43
+ if (a === '--pretty') { out.pretty = true; continue; }
44
+ if (a === '--compact') { out.pretty = false; continue; }
45
+ if (a === '--no-start') { out.start = false; continue; }
46
+ out.rest.push(a);
47
+ }
48
+ if (!out.port || !Number.isFinite(out.port)) {
49
+ // AI_BROWSER_PORT 是 Electron 侧(src/shared/config.js)用的名字,读它才和
50
+ // "AI_BROWSER_PORT=9701 ai-browser ensure" 这种用法对得上;WS_PORT 是历史别名。
51
+ const envPort = Number(process.env.AI_BROWSER_PORT ?? process.env.AI_BROWSER_WS_PORT);
52
+ out.port = Number.isFinite(envPort) && envPort > 0 ? envPort : WS_PORT_DEFAULT;
53
+ }
54
+ if (!Number.isFinite(out.timeout) || out.timeout <= 0) out.timeout = TIMEOUT_DEFAULT;
55
+ return out;
56
+ }
57
+
58
+ /** 把命令行里的 JSON 载荷解析成对象;空/缺省 → {}。 */
59
+ function parsePayload(text) {
60
+ if (text === undefined) return {};
61
+ const t = String(text).trim();
62
+ if (!t) return {};
63
+ let val;
64
+ try {
65
+ val = JSON.parse(t);
66
+ } catch (e) {
67
+ const err = new Error(`payload is not valid JSON: ${e.message}\n got: ${t.slice(0, 120)}`);
68
+ err.code = 'bad_json';
69
+ throw err;
70
+ }
71
+ if (val === null || typeof val !== 'object' || Array.isArray(val)) {
72
+ const err = new Error('payload must be a JSON object, e.g. {"url":"https://example.com"}');
73
+ err.code = 'bad_json';
74
+ throw err;
75
+ }
76
+ return val;
77
+ }
78
+
79
+ /**
80
+ * 发一条 JSON-RPC 并等回包。**不在这里做重试/重连** —— CLI 是同步语义:
81
+ * 一条命令一个结果,失败就把原因原样报出来,别替调用方猜。
82
+ */
83
+ function rpcCall({ port, timeout, method, params }) {
84
+ // require 放在函数里:只有真的要连的时候才需要 ws(--help 之类不该加载它)。
85
+ const WebSocket = require('ws');
86
+ return new Promise((resolve, reject) => {
87
+ let settled = false;
88
+ const done = (fn, arg) => { if (!settled) { settled = true; try { ws.close(); } catch (_) {} fn(arg); } };
89
+ const ws = new WebSocket(`ws://127.0.0.1:${port}`);
90
+ const timer = setTimeout(() => {
91
+ done(reject, Object.assign(new Error(`no reply from ${method} within ${timeout}ms`), { code: 'timeout' }));
92
+ }, timeout);
93
+
94
+ ws.on('open', () => {
95
+ ws.send(JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }));
96
+ });
97
+ ws.on('message', (data) => {
98
+ let msg;
99
+ try { msg = JSON.parse(String(data)); } catch (_) { return; } // 事件广播不是回包,忽略
100
+ if (msg.id !== 1) return;
101
+ clearTimeout(timer);
102
+ if (msg.error) {
103
+ // 带上方法名:服务端只会说 "Method not found",不带名字的话调用方还得自己
104
+ // 回想刚才打的是哪个(一条命令里可能有变量拼出来的方法名)。
105
+ const why = msg.error.message || 'rpc error';
106
+ done(reject, Object.assign(new Error(`${why} (${method})`), { code: 'rpc_error', rpc: msg.error }));
107
+ } else {
108
+ done(resolve, msg.result);
109
+ }
110
+ });
111
+ ws.on('error', (e) => {
112
+ clearTimeout(timer);
113
+ const hint = /ECONNREFUSED/i.test(e.message)
114
+ ? `浏览器没在 127.0.0.1:${port} 上跑。\`ai-browser ensure\` 会拉起它,\`ai-browser start\` 是前台启动。`
115
+ : e.message;
116
+ done(reject, Object.assign(new Error(hint), { code: 'connect_failed' }));
117
+ });
118
+ });
119
+ }
120
+
121
+ function printResult(result, pretty) {
122
+ if (result === undefined) { process.stdout.write('null\n'); return; }
123
+ const text = pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result);
124
+ process.stdout.write(text + '\n');
125
+ }
126
+
127
+ async function runCall(argv) {
128
+ const opt = parseArgs(argv);
129
+ const method = opt.rest[0];
130
+ if (!method) {
131
+ process.stderr.write('usage: ai-browser call <method> [json|-] [--port n] [--timeout ms] [--pretty]\n'
132
+ + ' e.g. ai-browser call ui.list_tabs\n'
133
+ + ' ai-browser call ui.get_tree \'{"subset":"interactive"}\'\n');
134
+ return 2;
135
+ }
136
+ let params;
137
+ try {
138
+ const raw = opt.rest[1] === '-' ? readStdin() : opt.rest[1];
139
+ params = parsePayload(raw);
140
+ } catch (e) {
141
+ process.stderr.write(`[ai-browser] ${e.message}\n`);
142
+ return 2;
143
+ }
144
+ try {
145
+ const result = await rpcCall({ port: opt.port, timeout: opt.timeout, method, params });
146
+ printResult(result, opt.pretty);
147
+ return 0;
148
+ } catch (e) {
149
+ // 连不上 → 默认把浏览器拉起来再试一次(见文件头"关于自动拉起")。
150
+ // 只在 connect_failed 时做:方法不存在、参数错这类不该去启动进程。
151
+ if (e.code !== 'connect_failed' || !opt.start) {
152
+ process.stderr.write(`[ai-browser] ${e.message}\n`);
153
+ return 1;
154
+ }
155
+ const { ensureRunning } = require('./ensure_running.cjs');
156
+ try {
157
+ const info = await ensureRunning({
158
+ port: opt.port,
159
+ log: (m) => process.stderr.write(m + '\n'),
160
+ });
161
+ if (info.started) process.stderr.write(`[ai-browser] browser started (via ${info.via})\n`);
162
+ const result = await rpcCall({ port: opt.port, timeout: opt.timeout, method, params });
163
+ printResult(result, opt.pretty);
164
+ return 0;
165
+ } catch (e2) {
166
+ // ensure 的报错已经带原因和下一步;原始连接错误作为补充,别只丢一句 ECONNREFUSED。
167
+ process.stderr.write(`[ai-browser] ${e2.message}\n`);
168
+ return 1;
169
+ }
170
+ }
171
+ }
172
+
173
+ /** `ai-browser ensure` — 只负责把浏览器弄到"能连"的状态,不调任何业务方法。 */
174
+ async function runEnsure(argv) {
175
+ const opt = parseArgs(argv);
176
+ const { ensureRunning } = require('./ensure_running.cjs');
177
+ try {
178
+ const info = await ensureRunning({
179
+ port: opt.port,
180
+ log: (m) => process.stderr.write(m + '\n'),
181
+ });
182
+ printResult({ ...info, port: info.port ?? opt.port }, opt.pretty);
183
+ return 0;
184
+ } catch (e) {
185
+ printResult({ running: false, port: opt.port, error: e.message }, opt.pretty);
186
+ return 1;
187
+ }
188
+ }
189
+
190
+ async function runStatus(argv) {
191
+ const opt = parseArgs(argv);
192
+ try {
193
+ const info = await rpcCall({ port: opt.port, timeout: Math.min(opt.timeout, 5000), method: 'ui.server_info', params: {} });
194
+ let tabs = null;
195
+ try { tabs = await rpcCall({ port: opt.port, timeout: Math.min(opt.timeout, 5000), method: 'ui.list_tabs', params: {} }); } catch (_) {}
196
+ const out = {
197
+ running: true,
198
+ port: opt.port,
199
+ pid: info && info.pid,
200
+ started_at: info && info.started_at,
201
+ platform: info && info.platform,
202
+ tabs: tabs && tabs.tabs ? tabs.tabs.map((t) => ({ id: t.id, active: t.active, title: t.title, url: t.url })) : null,
203
+ };
204
+ printResult(out, opt.pretty);
205
+ return 0;
206
+ } catch (e) {
207
+ // 没跑**不是错误状态**:这是用户最常问的第一个问题,答案要清楚、可执行。
208
+ printResult({ running: false, port: opt.port, reason: e.message }, opt.pretty);
209
+ return 1;
210
+ }
211
+ }
212
+
213
+ // stdin 读成字符串(供 `... | ai-browser call ui.navigate -` 用)
214
+ function readStdin() {
215
+ try {
216
+ return require('node:fs').readFileSync(0, 'utf8');
217
+ } catch (_) {
218
+ return '';
219
+ }
220
+ }
221
+
222
+ module.exports = { runCall, runStatus, runEnsure, parsePayload, parseArgs, rpcCall, WS_PORT_DEFAULT };
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env node
2
+ // scripts/ensure_running.cjs — 幂等地把浏览器弄起来,CLI 与 MCP **共用这一份**。
3
+ //
4
+ // # 为什么抽出来(2026-10-20)
5
+ //
6
+ // 原来这段只活在 mcp_server.js 里(ensureElectronRunning)。后果是:
7
+ // • 走 MCP 的 agent:浏览器没开也能用(服务端自动拉起);
8
+ // • 走 CLI 的 agent:连不上只会看到 "先 ai-browser start" —— 得学两步。
9
+ // 而「装完就能用」要求的是**一条命令**。所以把拉起逻辑挪到共享模块,
10
+ // 两个壳调用同一份实现(与项目一贯的"一套实现、多个薄壳"对齐)。
11
+ //
12
+ // # 三个坑(都是实测踩出来的,别删)
13
+ //
14
+ // ① `ELECTRON_RUN_AS_NODE` 必须删掉。本机 shell 就带着它,此时 electron 二进制
15
+ // 会当普通 Node 跑,拿 Node 加载 ESM 入口直接崩,端口永不监听,而报错是一串
16
+ // Node 的 ESM 栈 —— 完全不提 Electron。
17
+ // ② 必须 `detached` + `unref()`。否则调用方(CLI 进程)一退出,浏览器被一起带走。
18
+ // ③ 失败必须**带原因**。之前 return false 被上层 catch 吞掉,agent 先重试 120 秒,
19
+ // 最后只拿到 "connection refused",没有任何线索说明浏览器根本没起来。
20
+ //
21
+ // # macOS 的沙箱兜底(第四个坑)
22
+ //
23
+ // 在受限运行环境里(agent 的沙箱 shell)直接 spawn Chromium,会以
24
+ // "sandbox initialization failed: Operation not permitted" 挂掉 —— 沙箱能力
25
+ // 随父进程继承,`--no-sandbox` 也救不了。此时改走 LaunchServices:
26
+ // open -n -a <Electron.app> --args <appRoot>
27
+ // 新进程由 launchd 拉起,不继承父进程的沙箱限制。这条兜底不是 hack,是 macOS
28
+ // 上启动 GUI 应用的**正规方式**(Finder 双击走的就是它)。
29
+ 'use strict';
30
+
31
+ const { spawn } = require('node:child_process');
32
+ const net = require('node:net');
33
+ const path = require('node:path');
34
+
35
+ const ROOT = path.resolve(__dirname, '..');
36
+ const DEFAULT_PORT = 9223;
37
+ const DEFAULT_TIMEOUT = 30000;
38
+
39
+ /** 端口上有没有人在听。超时 500ms —— 只探本地回环,不该慢。 */
40
+ function checkPort(port, host = '127.0.0.1') {
41
+ return new Promise((resolve) => {
42
+ const sock = net.connect({ port, host });
43
+ let done = false;
44
+ const finish = (ok) => { if (!done) { done = true; sock.destroy(); resolve(ok); } };
45
+ sock.once('connect', () => finish(true));
46
+ sock.once('error', () => finish(false));
47
+ sock.once('timeout', () => finish(false));
48
+ sock.setTimeout(500);
49
+ });
50
+ }
51
+
52
+ /** 轮询等到端口起来,或超时。`abort()` 说"别等了"时立刻放弃(子进程已经没了)。 */
53
+ async function waitForPort(port, timeoutMs, stepMs = 400, abort = null) {
54
+ const start = Date.now();
55
+ while (Date.now() - start < timeoutMs) {
56
+ if (await checkPort(port)) return true;
57
+ if (abort && abort()) return false; // 子进程已经报错退出,再等只是浪费
58
+ await new Promise((r) => setTimeout(r, stepMs));
59
+ }
60
+ return false;
61
+ }
62
+
63
+ /**
64
+ * 从 electron 二进制路径反推 macOS 的 .app 包路径。
65
+ * `/x/Electron.app/Contents/MacOS/Electron` → `/x/Electron.app`
66
+ * 非 mac / 路径不含 .app 时返回 null。
67
+ */
68
+ function appBundleFromBinary(bin) {
69
+ if (!bin) return null;
70
+ const i = bin.indexOf('.app' + path.sep);
71
+ if (i < 0) return null;
72
+ return bin.slice(0, i + 4);
73
+ }
74
+
75
+ /** 用 LaunchServices 启动(macOS)。返回是否成功把进程扔出去(不代表端口会开)。 */
76
+ function launchViaOpen(bundle, root, log) {
77
+ try {
78
+ const child = spawn('open', ['-n', '-a', bundle, '--args', root], {
79
+ stdio: 'ignore',
80
+ detached: true,
81
+ });
82
+ child.unref();
83
+ return true;
84
+ } catch (e) {
85
+ log(`[ensure] open -a ${bundle} failed: ${e.message}`);
86
+ return false;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * 确保浏览器在跑。幂等:已经在跑就立刻返回。
92
+ *
93
+ * @param {object} [opts]
94
+ * @param {number} [opts.port=9223]
95
+ * @param {number} [opts.timeoutMs=30000] 首轮等待上限
96
+ * @param {(msg: string) => void} [opts.log] 进度输出(走 stderr,别污染 stdout 的 result)
97
+ * @param {boolean} [opts.allowLaunchServices=true] macOS 沙箱失败时是否兜底走 open
98
+ * @returns {Promise<{running:boolean, started:boolean, via:string|null, port:number}>}
99
+ * @throws 端口始终没起来时抛错,message 里带**可执行的下一步**
100
+ */
101
+ async function ensureRunning(opts = {}) {
102
+ const port = Number(opts.port) || DEFAULT_PORT;
103
+ const timeoutMs = Number(opts.timeoutMs) || DEFAULT_TIMEOUT;
104
+ const log = opts.log || ((m) => console.error(m));
105
+ const allowLS = opts.allowLaunchServices !== false;
106
+
107
+ if (await checkPort(port)) return { running: true, started: false, via: 'already', port };
108
+
109
+ // AI_BROWSER_ELECTRON_BIN:显式指定二进制(打包版、自定义构建、e2e 用假路径)。
110
+ // 没设就交给共享解析器(它会在二进制缺失时自己跑一遍 electron 的安装脚本)。
111
+ let bin;
112
+ const override = process.env.AI_BROWSER_ELECTRON_BIN;
113
+ if (override) {
114
+ bin = override;
115
+ } else {
116
+ const { resolveElectronBinary } = require('./electron_resolve.cjs');
117
+ try {
118
+ bin = resolveElectronBinary({ log });
119
+ } catch (e) {
120
+ throw new Error(
121
+ `could not locate the Electron binary: ${e.message}\n`
122
+ + ' fix: run `npm install` once inside the ai-browser package (this also downloads Chromium).'
123
+ );
124
+ }
125
+ }
126
+
127
+ log(`[ensure] port ${port} not listening — starting ai-browser`);
128
+
129
+ // —— 第一轮:直接 spawn(Linux/Windows 的正常路径,macOS 也先试这个)——
130
+ const env = { ...process.env, AI_BROWSER_LAUNCHED_BY_AGENT: '1' };
131
+ delete env.ELECTRON_RUN_AS_NODE; // 坑 ①
132
+ let earlyExit = null;
133
+ let child = null;
134
+ try {
135
+ child = spawn(bin, [ROOT], { cwd: ROOT, detached: true, stdio: 'ignore', env }); // 坑 ②
136
+ child.unref();
137
+ child.on('exit', (code, signal) => { earlyExit = { code, signal }; });
138
+ child.on('error', (e) => { earlyExit = { error: e.message }; });
139
+ } catch (e) {
140
+ log(`[ensure] spawn failed: ${e.message}`);
141
+ earlyExit = { error: e.message };
142
+ }
143
+
144
+ // 子进程自报错(ENOENT 之类)就别等满超时了 —— 那只会把真实原因埋掉。
145
+ let ok = await waitForPort(port, timeoutMs, 400, () => !!(earlyExit && earlyExit.error));
146
+ if (ok) return { running: true, started: true, via: 'spawn', port };
147
+
148
+ // —— 第二轮:macOS 沙箱兜底,走 LaunchServices ——
149
+ let lsTried = false;
150
+ if (allowLS && process.platform === 'darwin') {
151
+ const bundle = appBundleFromBinary(bin);
152
+ if (bundle) {
153
+ lsTried = true;
154
+ log(`[ensure] direct spawn did not come up (${describeExit(earlyExit)}) — retrying via LaunchServices`);
155
+ if (launchViaOpen(bundle, ROOT, log)) {
156
+ ok = await waitForPort(port, 20000);
157
+ if (ok) return { running: true, started: true, via: 'launchservices', port };
158
+ }
159
+ }
160
+ }
161
+
162
+ throw new Error(buildFailureMessage({ port, earlyExit, lsTried }));
163
+ }
164
+
165
+ /** 把子进程退出状态说人话。 */
166
+ function describeExit(earlyExit) {
167
+ if (!earlyExit) return 'no exit event, port still closed';
168
+ if (earlyExit.error) return `spawn error: ${earlyExit.error}`;
169
+ return `exited early (code=${earlyExit.code}, signal=${earlyExit.signal})`;
170
+ }
171
+
172
+ /** 失败原因 + 用户/agent 照着能做的下一步。 */
173
+ function buildFailureMessage({ port, earlyExit, lsTried }) {
174
+ const lines = [`could not start the browser: nothing is listening on 127.0.0.1:${port}`];
175
+ lines.push(` cause: ${describeExit(earlyExit)}`);
176
+ if (lsTried) lines.push(' a LaunchServices launch (open -n -a Electron.app) was also tried and did not come up');
177
+ lines.push(
178
+ ' common causes:',
179
+ ' • ELECTRON_RUN_AS_NODE is set in the environment (unset it)',
180
+ ' • Chromium sandbox is blocked inside a container/sandbox — launch once from a normal terminal: `ai-browser start`',
181
+ ' • Chromium binary missing or partially extracted — `npm install` in the ai-browser package',
182
+ ' • a stale lock: another instance owns this userData dir (quit it, or use --port for a separate profile)'
183
+ );
184
+ return lines.join('\n');
185
+ }
186
+
187
+ module.exports = {
188
+ ensureRunning,
189
+ checkPort,
190
+ waitForPort,
191
+ appBundleFromBinary,
192
+ DEFAULT_PORT,
193
+ DEFAULT_TIMEOUT,
194
+ APP_ROOT: ROOT,
195
+ };
@@ -0,0 +1,133 @@
1
+ // scripts/skill.cjs — 把随包分发的 skills/ 装进 agent 客户端的技能目录
2
+ //
3
+ // 为什么需要它:技能只有被客户端**发现并自动加载**才有价值。放在 npm 包里
4
+ // 不等于装上了——用户的 ~/.claude/skills 或 ~/.workbuddy/skills 里没有文件,
5
+ // agent 每次开新会话就只能靠人贴说明书(这就是"每次都要重新学"的真相)。
6
+ //
7
+ // 设计取舍:
8
+ // - 只写自己管理的目录,靠 .ai-browser-skill 标记文件区分"我装的"和"用户自己写的"。
9
+ // 没有标记就跳过并提示,绝不覆盖用户手改的技能。
10
+ // - 不猜路径:每个客户端一个明确常量,装不装得上让用户看见。
11
+
12
+ 'use strict';
13
+
14
+ const fs = require('node:fs');
15
+ const os = require('node:os');
16
+ const path = require('node:path');
17
+ const pkg = require('../package.json');
18
+
19
+ const SKILLS_ROOT = path.join(__dirname, '..', 'skills');
20
+ const MARKER = '.ai-browser-skill';
21
+
22
+ // 已知客户端的技能目录。宁可只列确定正确的几个,也不猜。
23
+ const TARGETS = [
24
+ { id: 'workbuddy', label: 'WorkBuddy', dir: path.join(os.homedir(), '.workbuddy', 'skills') },
25
+ { id: 'claude', label: 'Claude Code', dir: path.join(os.homedir(), '.claude', 'skills') },
26
+ { id: 'codex', label: 'Codex', dir: path.join(os.homedir(), '.codex', 'skills') },
27
+ ];
28
+
29
+ function listSkills() {
30
+ if (!fs.existsSync(SKILLS_ROOT)) return [];
31
+ return fs
32
+ .readdirSync(SKILLS_ROOT, { withFileTypes: true })
33
+ .filter((d) => d.isDirectory() && fs.existsSync(path.join(SKILLS_ROOT, d.name, 'SKILL.md')))
34
+ .map((d) => d.name);
35
+ }
36
+
37
+ function detectTargets() {
38
+ return TARGETS.map((t) => ({
39
+ ...t,
40
+ exists: fs.existsSync(t.dir),
41
+ installed: listSkills().filter((s) => fs.existsSync(path.join(t.dir, s, MARKER))),
42
+ }));
43
+ }
44
+
45
+ function installOne(skill, targetDir) {
46
+ const src = path.join(SKILLS_ROOT, skill);
47
+ const dest = path.join(targetDir, skill);
48
+ const marker = path.join(dest, MARKER);
49
+
50
+ if (fs.existsSync(dest) && !fs.existsSync(marker)) {
51
+ return { skill, status: 'skipped-user-owned' };
52
+ }
53
+
54
+ fs.mkdirSync(targetDir, { recursive: true });
55
+ fs.cpSync(src, dest, { recursive: true });
56
+ fs.writeFileSync(marker, `ai-browser ${pkg.version}\n`, 'utf8');
57
+ return { skill, status: 'installed' };
58
+ }
59
+
60
+ function install({ dir, all }) {
61
+ const skills = listSkills();
62
+ if (!skills.length) {
63
+ console.error('[skill] 包里没有 skills/ 目录,这个构建是坏的');
64
+ return 1;
65
+ }
66
+
67
+ let dirs;
68
+ if (dir) {
69
+ dirs = [{ id: 'custom', label: dir, dir: path.resolve(dir), exists: true }];
70
+ } else {
71
+ dirs = detectTargets().filter((t) => all || t.exists);
72
+ if (!dirs.length) {
73
+ console.error('[skill] 没找到任何已知客户端的技能目录。用 --dir <path> 指定,或加 --all 强行创建。');
74
+ console.error(' 已知位置:' + TARGETS.map((t) => `${t.label} → ${t.dir}`).join(';'));
75
+ return 1;
76
+ }
77
+ }
78
+
79
+ let failed = 0;
80
+ for (const t of dirs) {
81
+ console.log(`\n→ ${t.label} (${t.dir})`);
82
+ for (const s of skills) {
83
+ const r = installOne(s, t.dir);
84
+ const note =
85
+ r.status === 'installed'
86
+ ? `✓ ${s}`
87
+ : `! ${s} —— 这个目录已有同名技能且不是本工具装的,跳过(要覆盖请先手动删掉)`;
88
+ console.log(' ' + note);
89
+ }
90
+ }
91
+ console.log(`\n装了 ${skills.length} 个技能。新开会话时客户端会自动发现它们,不需要再贴说明书。`);
92
+ return failed;
93
+ }
94
+
95
+ function printEntry() {
96
+ const entry = path.join(SKILLS_ROOT, 'ai-browser', 'SKILL.md');
97
+ if (!fs.existsSync(entry)) {
98
+ console.error('[skill] 找不到 skills/ai-browser/SKILL.md');
99
+ return 1;
100
+ }
101
+ process.stdout.write(fs.readFileSync(entry, 'utf8'));
102
+ return 0;
103
+ }
104
+
105
+ function run(argv) {
106
+ const sub = argv[0] || 'list';
107
+
108
+ if (sub === 'list') {
109
+ const skills = listSkills();
110
+ console.log(`ai-browser ${pkg.version} 附带 ${skills.length} 个技能:${skills.join(', ')}\n`);
111
+ const rows = detectTargets();
112
+ for (const t of rows) {
113
+ const state = !t.exists ? '目录不存在' : t.installed.length ? `已装 ${t.installed.length} 个` : '未装';
114
+ console.log(` ${t.label.padEnd(12)} ${state.padEnd(12)} ${t.dir}`);
115
+ }
116
+ console.log('\n装上:ai-browser skill install 只装到已存在的客户端目录');
117
+ console.log(' ai-browser skill install --all 三个位置都装');
118
+ console.log(' ai-browser skill print 打印入口 SKILL.md(手动粘贴用)');
119
+ return 0;
120
+ }
121
+
122
+ if (sub === 'install') {
123
+ const dirIdx = argv.indexOf('--dir');
124
+ return install({ dir: dirIdx >= 0 ? argv[dirIdx + 1] : null, all: argv.includes('--all') });
125
+ }
126
+
127
+ if (sub === 'print') return printEntry();
128
+
129
+ console.error(`[skill] 未知子命令: ${sub}\n用法: ai-browser skill [list|install|print] [--dir <path>] [--all]`);
130
+ return 2;
131
+ }
132
+
133
+ module.exports = { run, listSkills, detectTargets, SKILLS_ROOT };
@@ -0,0 +1,56 @@
1
+ ---
2
+ name: ai-browser
3
+ description: >-
4
+ Control a real, visible browser to do things on websites: open a page, click, type, fill a form, log in, read page content, download/upload, inspect network requests. Use whenever the user asks to operate a website (打开网页/点一下/填表/登录/发布/提交/抓取/截图/看接口) or to read a page that needs JS to render.
5
+ 触发词:打开、点击、填表、输入、登录、扫码、发布、提交、下载、上传、抓取、爬取、截图、看接口、网页操作、browse。
6
+ ---
7
+
8
+ # ai-browser
9
+
10
+ ## 两条路,选一条就能干活
11
+
12
+ 工具在就 `browse_navigate {"url":"https://example.com"}`;**不在也不用配任何东西**,走 CLI(同一引擎,装完即用):
13
+
14
+ ```
15
+ ai-browser call ui.get_tree '{"subset":"interactive"}' # 读页面,拿 e:xxx
16
+ ai-browser call ui.act '{"action":"click","target":"e:a-3"}' # 点它
17
+ ai-browser call ui.navigate '{"url":"https://example.com"}'
18
+ ```
19
+
20
+ 没这个命令就加前缀 `npx -y @glittering0717/ai-browser`。浏览器没开时会自动拉起。
21
+
22
+ CLI 方法 = 工具名去掉 `browse_` 换 `ui.`(`get_tree` `act` `navigate` … 一一对应)。
23
+ 不一一对应的 4 个(tabs/network/canvas/diff)见 `references/cli.md`。
24
+
25
+ ## 点东西只认 id
26
+
27
+ `get_tree` 每个节点有 `id`(形如 `e:a-3`)。`act` 的 `target` **只接受这个 id**,
28
+ 传页面上的文字会报 `target_not_found`。
29
+
30
+ ## 操作后先看 effect_state
31
+
32
+ `committed` 已生效 → 继续|`none` 页面没动 → 换 target 或重读树|
33
+ `unknown` 说不准 → **先重读树再决定**,盲目重试会提交两次。
34
+
35
+ ## 卡住时
36
+
37
+ | 现象 | 先做什么 |
38
+ |---|---|
39
+ | 树里没有要点的东西 | 等 `{"condition":"text_contains","text":"关键词"}` 再重读 |
40
+ | `target_not_found` + `stale_ref` | 页面变了 → 重读树拿新 id |
41
+ | `target_not_found` + `never_seen` | id 本身错 → 别重读,改参数 |
42
+ | 点了没反应 | `screenshot` 看画面;`network_list` 看请求 |
43
+ | 怀疑掉登录 | `auth_state` |
44
+ | 以上都不对 | `errors` |
45
+
46
+ ## 需要人帮忙
47
+
48
+ 登录/扫码/滑块/支付:**请用户在自己的窗口里操作**,他说"好了"再重读树确认。不要编造密码。
49
+
50
+ ## 按需深挖
51
+
52
+ - `references/cli.md` — CLI 全部方法与参数对照
53
+ - `references/troubleshooting.md` — 失败排查顺序
54
+ - `references/hard-pages.md` — canvas / 富文本 / iframe / 上传
55
+ - `references/network.md` — 抓请求、取结构化数据
56
+ - `references/tasks.md` — 发布文章 / 读正文 的配方