@jackwener/opencli 0.7.6 → 0.7.8

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 (55) hide show
  1. package/.agents/skills/cross-project-adapter-migration/SKILL.md +249 -0
  2. package/.agents/workflows/cross-project-adapter-migration.md +54 -0
  3. package/dist/browser/discover.d.ts +8 -0
  4. package/dist/browser/discover.js +83 -0
  5. package/dist/browser/errors.d.ts +21 -0
  6. package/dist/browser/errors.js +54 -0
  7. package/dist/browser/index.d.ts +22 -0
  8. package/dist/browser/index.js +22 -0
  9. package/dist/browser/mcp.d.ts +33 -0
  10. package/dist/browser/mcp.js +304 -0
  11. package/dist/browser/page.d.ts +41 -0
  12. package/dist/browser/page.js +142 -0
  13. package/dist/browser/tabs.d.ts +13 -0
  14. package/dist/browser/tabs.js +70 -0
  15. package/dist/browser.test.js +1 -1
  16. package/dist/completion.js +2 -2
  17. package/dist/doctor.js +7 -7
  18. package/dist/engine.js +6 -4
  19. package/dist/errors.d.ts +25 -0
  20. package/dist/errors.js +42 -0
  21. package/dist/logger.d.ts +22 -0
  22. package/dist/logger.js +47 -0
  23. package/dist/main.js +8 -2
  24. package/dist/pipeline/executor.js +8 -8
  25. package/dist/pipeline/steps/browser.d.ts +7 -7
  26. package/dist/pipeline/steps/intercept.d.ts +1 -1
  27. package/dist/pipeline/steps/tap.d.ts +1 -1
  28. package/dist/setup.js +1 -1
  29. package/package.json +3 -3
  30. package/scripts/clean-yaml.cjs +19 -0
  31. package/scripts/copy-yaml.cjs +21 -0
  32. package/scripts/postinstall.js +30 -9
  33. package/src/bilibili.ts +1 -1
  34. package/src/browser/discover.ts +90 -0
  35. package/src/browser/errors.ts +89 -0
  36. package/src/browser/index.ts +26 -0
  37. package/src/browser/mcp.ts +305 -0
  38. package/src/browser/page.ts +152 -0
  39. package/src/browser/tabs.ts +76 -0
  40. package/src/browser.test.ts +1 -1
  41. package/src/completion.ts +2 -2
  42. package/src/doctor.ts +13 -1
  43. package/src/engine.ts +9 -4
  44. package/src/errors.ts +48 -0
  45. package/src/logger.ts +57 -0
  46. package/src/main.ts +10 -3
  47. package/src/pipeline/executor.ts +8 -7
  48. package/src/pipeline/steps/browser.ts +18 -18
  49. package/src/pipeline/steps/intercept.ts +8 -8
  50. package/src/pipeline/steps/tap.ts +2 -2
  51. package/src/setup.ts +1 -1
  52. package/tsconfig.json +1 -2
  53. package/dist/browser.d.ts +0 -105
  54. package/dist/browser.js +0 -644
  55. package/src/browser.ts +0 -698
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Playwright MCP process manager.
3
+ * Handles lifecycle management, JSON-RPC communication, and browser session orchestration.
4
+ */
5
+ import { spawn } from 'node:child_process';
6
+ import { withTimeoutMs, DEFAULT_BROWSER_CONNECT_TIMEOUT } from '../runtime.js';
7
+ import { PKG_VERSION } from '../version.js';
8
+ import { Page } from './page.js';
9
+ import { getTokenFingerprint, formatBrowserConnectError, inferConnectFailureKind } from './errors.js';
10
+ import { findMcpServerPath, buildMcpArgs } from './discover.js';
11
+ import { extractTabIdentities, extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
12
+ const STDERR_BUFFER_LIMIT = 16 * 1024;
13
+ const INITIAL_TABS_TIMEOUT_MS = 1500;
14
+ const TAB_CLEANUP_TIMEOUT_MS = 2000;
15
+ // JSON-RPC helpers
16
+ let _nextId = 1;
17
+ export function createJsonRpcRequest(method, params = {}) {
18
+ const id = _nextId++;
19
+ return {
20
+ id,
21
+ message: JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n',
22
+ };
23
+ }
24
+ /**
25
+ * Playwright MCP process manager.
26
+ */
27
+ export class PlaywrightMCP {
28
+ static _activeInsts = new Set();
29
+ static _cleanupRegistered = false;
30
+ static _registerGlobalCleanup() {
31
+ if (this._cleanupRegistered)
32
+ return;
33
+ this._cleanupRegistered = true;
34
+ const cleanup = () => {
35
+ for (const inst of this._activeInsts) {
36
+ if (inst._proc && !inst._proc.killed) {
37
+ try {
38
+ inst._proc.kill('SIGKILL');
39
+ }
40
+ catch { }
41
+ }
42
+ }
43
+ };
44
+ process.on('exit', cleanup);
45
+ process.on('SIGINT', () => { cleanup(); process.exit(130); });
46
+ process.on('SIGTERM', () => { cleanup(); process.exit(143); });
47
+ }
48
+ _proc = null;
49
+ _buffer = '';
50
+ _pending = new Map();
51
+ _initialTabIdentities = [];
52
+ _closingPromise = null;
53
+ _state = 'idle';
54
+ _page = null;
55
+ get state() {
56
+ return this._state;
57
+ }
58
+ _sendRequest(method, params = {}) {
59
+ return new Promise((resolve, reject) => {
60
+ if (!this._proc?.stdin?.writable) {
61
+ reject(new Error('Playwright MCP process is not writable'));
62
+ return;
63
+ }
64
+ const { id, message } = createJsonRpcRequest(method, params);
65
+ this._pending.set(id, { resolve, reject });
66
+ this._proc.stdin.write(message, (err) => {
67
+ if (!err)
68
+ return;
69
+ this._pending.delete(id);
70
+ reject(err);
71
+ });
72
+ });
73
+ }
74
+ _rejectPendingRequests(error) {
75
+ const pending = [...this._pending.values()];
76
+ this._pending.clear();
77
+ for (const waiter of pending)
78
+ waiter.reject(error);
79
+ }
80
+ _resetAfterFailedConnect() {
81
+ const proc = this._proc;
82
+ this._page = null;
83
+ this._proc = null;
84
+ this._buffer = '';
85
+ this._initialTabIdentities = [];
86
+ this._rejectPendingRequests(new Error('Playwright MCP connect failed'));
87
+ PlaywrightMCP._activeInsts.delete(this);
88
+ if (proc && !proc.killed) {
89
+ try {
90
+ proc.kill('SIGKILL');
91
+ }
92
+ catch { }
93
+ }
94
+ }
95
+ async connect(opts = {}) {
96
+ if (this._state === 'connected' && this._page)
97
+ return this._page;
98
+ if (this._state === 'connecting')
99
+ throw new Error('Playwright MCP is already connecting');
100
+ if (this._state === 'closing')
101
+ throw new Error('Playwright MCP is closing');
102
+ if (this._state === 'closed')
103
+ throw new Error('Playwright MCP session is closed');
104
+ const mcpPath = findMcpServerPath();
105
+ if (!mcpPath)
106
+ throw new Error('Playwright MCP server not found. Install: npm install -D @playwright/mcp');
107
+ PlaywrightMCP._registerGlobalCleanup();
108
+ PlaywrightMCP._activeInsts.add(this);
109
+ this._state = 'connecting';
110
+ const timeout = opts.timeout ?? DEFAULT_BROWSER_CONNECT_TIMEOUT;
111
+ return new Promise((resolve, reject) => {
112
+ const isDebug = process.env.DEBUG?.includes('opencli:mcp');
113
+ const debugLog = (msg) => isDebug && console.error(`[opencli:mcp] ${msg}`);
114
+ const useExtension = !!process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
115
+ const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
116
+ const tokenFingerprint = getTokenFingerprint(extensionToken);
117
+ let stderrBuffer = '';
118
+ let settled = false;
119
+ const settleError = (kind, extra = {}) => {
120
+ if (settled)
121
+ return;
122
+ settled = true;
123
+ this._state = 'idle';
124
+ clearTimeout(timer);
125
+ this._resetAfterFailedConnect();
126
+ reject(formatBrowserConnectError({
127
+ kind,
128
+ timeout,
129
+ hasExtensionToken: !!extensionToken,
130
+ tokenFingerprint,
131
+ stderr: stderrBuffer,
132
+ exitCode: extra.exitCode,
133
+ rawMessage: extra.rawMessage,
134
+ }));
135
+ };
136
+ const settleSuccess = (pageToResolve) => {
137
+ if (settled)
138
+ return;
139
+ settled = true;
140
+ this._state = 'connected';
141
+ clearTimeout(timer);
142
+ resolve(pageToResolve);
143
+ };
144
+ const timer = setTimeout(() => {
145
+ debugLog('Connection timed out');
146
+ settleError(inferConnectFailureKind({
147
+ hasExtensionToken: !!extensionToken,
148
+ stderr: stderrBuffer,
149
+ }));
150
+ }, timeout * 1000);
151
+ const mcpArgs = buildMcpArgs({
152
+ mcpPath,
153
+ executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH,
154
+ });
155
+ if (process.env.OPENCLI_VERBOSE) {
156
+ console.error(`[opencli] Mode: ${useExtension ? 'extension' : 'standalone'}`);
157
+ if (useExtension)
158
+ console.error(`[opencli] Extension token: fingerprint ${tokenFingerprint}`);
159
+ }
160
+ debugLog(`Spawning node ${mcpArgs.join(' ')}`);
161
+ this._proc = spawn('node', mcpArgs, {
162
+ stdio: ['pipe', 'pipe', 'pipe'],
163
+ env: { ...process.env },
164
+ });
165
+ // Increase max listeners to avoid warnings
166
+ this._proc.setMaxListeners(20);
167
+ if (this._proc.stdout)
168
+ this._proc.stdout.setMaxListeners(20);
169
+ const page = new Page((method, params = {}) => this._sendRequest(method, params));
170
+ this._page = page;
171
+ this._proc.stdout?.on('data', (chunk) => {
172
+ this._buffer += chunk.toString();
173
+ const lines = this._buffer.split('\n');
174
+ this._buffer = lines.pop() ?? '';
175
+ for (const line of lines) {
176
+ if (!line.trim())
177
+ continue;
178
+ debugLog(`RECV: ${line}`);
179
+ try {
180
+ const parsed = JSON.parse(line);
181
+ if (typeof parsed?.id === 'number') {
182
+ const waiter = this._pending.get(parsed.id);
183
+ if (waiter) {
184
+ this._pending.delete(parsed.id);
185
+ waiter.resolve(parsed);
186
+ }
187
+ }
188
+ }
189
+ catch (e) {
190
+ debugLog(`Parse error: ${e}`);
191
+ }
192
+ }
193
+ });
194
+ this._proc.stderr?.on('data', (chunk) => {
195
+ const text = chunk.toString();
196
+ stderrBuffer = appendLimited(stderrBuffer, text, STDERR_BUFFER_LIMIT);
197
+ debugLog(`STDERR: ${text}`);
198
+ });
199
+ this._proc.on('error', (err) => {
200
+ debugLog(`Subprocess error: ${err.message}`);
201
+ this._rejectPendingRequests(new Error(`Playwright MCP process error: ${err.message}`));
202
+ settleError('process-exit', { rawMessage: err.message });
203
+ });
204
+ this._proc.on('close', (code) => {
205
+ debugLog(`Subprocess closed with code ${code}`);
206
+ this._rejectPendingRequests(new Error(`Playwright MCP process exited before response${code == null ? '' : ` (code ${code})`}`));
207
+ if (!settled) {
208
+ settleError(inferConnectFailureKind({
209
+ hasExtensionToken: !!extensionToken,
210
+ stderr: stderrBuffer,
211
+ exited: true,
212
+ }), { exitCode: code });
213
+ }
214
+ });
215
+ // Initialize: send initialize request
216
+ debugLog('Waiting for initialize response...');
217
+ this._sendRequest('initialize', {
218
+ protocolVersion: '2024-11-05',
219
+ capabilities: {},
220
+ clientInfo: { name: 'opencli', version: PKG_VERSION },
221
+ }).then((resp) => {
222
+ debugLog('Got initialize response');
223
+ if (resp.error) {
224
+ settleError(inferConnectFailureKind({
225
+ hasExtensionToken: !!extensionToken,
226
+ stderr: stderrBuffer,
227
+ rawMessage: `MCP init failed: ${resp.error.message}`,
228
+ }), { rawMessage: resp.error.message });
229
+ return;
230
+ }
231
+ const initializedMsg = JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n';
232
+ debugLog(`SEND: ${initializedMsg.trim()}`);
233
+ this._proc?.stdin?.write(initializedMsg);
234
+ // Use tabs as a readiness probe and for tab cleanup bookkeeping.
235
+ debugLog('Fetching initial tabs count...');
236
+ withTimeoutMs(page.tabs(), INITIAL_TABS_TIMEOUT_MS, 'Timed out fetching initial tabs').then((tabs) => {
237
+ debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`);
238
+ this._initialTabIdentities = extractTabIdentities(tabs);
239
+ settleSuccess(page);
240
+ }).catch((err) => {
241
+ debugLog(`Tabs fetch error: ${err.message}`);
242
+ settleSuccess(page);
243
+ });
244
+ }).catch((err) => {
245
+ debugLog(`Init promise rejected: ${err.message}`);
246
+ settleError('mcp-init', { rawMessage: err.message });
247
+ });
248
+ });
249
+ }
250
+ async close() {
251
+ if (this._closingPromise)
252
+ return this._closingPromise;
253
+ if (this._state === 'closed')
254
+ return;
255
+ this._state = 'closing';
256
+ this._closingPromise = (async () => {
257
+ try {
258
+ // Extension mode opens bridge/session tabs that we can clean up best-effort.
259
+ if (this._page && this._proc && !this._proc.killed) {
260
+ try {
261
+ const tabs = await withTimeoutMs(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup');
262
+ const tabEntries = extractTabEntries(tabs);
263
+ const tabsToClose = diffTabIndexes(this._initialTabIdentities, tabEntries);
264
+ for (const index of tabsToClose) {
265
+ try {
266
+ await this._page.closeTab(index);
267
+ }
268
+ catch { }
269
+ }
270
+ }
271
+ catch { }
272
+ }
273
+ if (this._proc && !this._proc.killed) {
274
+ this._proc.kill('SIGTERM');
275
+ const exited = await new Promise((res) => {
276
+ let done = false;
277
+ const finish = (value) => {
278
+ if (done)
279
+ return;
280
+ done = true;
281
+ res(value);
282
+ };
283
+ this._proc?.once('exit', () => finish(true));
284
+ setTimeout(() => finish(false), 3000);
285
+ });
286
+ if (!exited && this._proc && !this._proc.killed) {
287
+ try {
288
+ this._proc.kill('SIGKILL');
289
+ }
290
+ catch { }
291
+ }
292
+ }
293
+ }
294
+ finally {
295
+ this._rejectPendingRequests(new Error('Playwright MCP session closed'));
296
+ this._page = null;
297
+ this._proc = null;
298
+ this._state = 'closed';
299
+ PlaywrightMCP._activeInsts.delete(this);
300
+ }
301
+ })();
302
+ return this._closingPromise;
303
+ }
304
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Page abstraction wrapping JSON-RPC calls to Playwright MCP.
3
+ */
4
+ import type { IPage } from '../types.js';
5
+ /**
6
+ * Page abstraction wrapping JSON-RPC calls to Playwright MCP.
7
+ */
8
+ export declare class Page implements IPage {
9
+ private _request;
10
+ constructor(_request: (method: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>>);
11
+ call(method: string, params?: Record<string, unknown>): Promise<any>;
12
+ goto(url: string): Promise<void>;
13
+ evaluate(js: string): Promise<any>;
14
+ snapshot(opts?: {
15
+ interactive?: boolean;
16
+ compact?: boolean;
17
+ maxDepth?: number;
18
+ raw?: boolean;
19
+ }): Promise<any>;
20
+ click(ref: string): Promise<void>;
21
+ typeText(ref: string, text: string): Promise<void>;
22
+ pressKey(key: string): Promise<void>;
23
+ wait(options: number | {
24
+ text?: string;
25
+ time?: number;
26
+ timeout?: number;
27
+ }): Promise<void>;
28
+ tabs(): Promise<any>;
29
+ closeTab(index?: number): Promise<void>;
30
+ newTab(): Promise<void>;
31
+ selectTab(index: number): Promise<void>;
32
+ networkRequests(includeStatic?: boolean): Promise<any>;
33
+ consoleMessages(level?: string): Promise<any>;
34
+ scroll(direction?: string, _amount?: number): Promise<void>;
35
+ autoScroll(options?: {
36
+ times?: number;
37
+ delayMs?: number;
38
+ }): Promise<void>;
39
+ installInterceptor(pattern: string): Promise<void>;
40
+ getInterceptedRequests(): Promise<any[]>;
41
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Page abstraction wrapping JSON-RPC calls to Playwright MCP.
3
+ */
4
+ import { formatSnapshot } from '../snapshotFormatter.js';
5
+ import { normalizeEvaluateSource } from '../pipeline/template.js';
6
+ import { generateInterceptorJs, generateReadInterceptedJs } from '../interceptor.js';
7
+ /**
8
+ * Page abstraction wrapping JSON-RPC calls to Playwright MCP.
9
+ */
10
+ export class Page {
11
+ _request;
12
+ constructor(_request) {
13
+ this._request = _request;
14
+ }
15
+ async call(method, params = {}) {
16
+ const resp = await this._request(method, params);
17
+ if (resp.error)
18
+ throw new Error(`page.${method}: ${resp.error.message ?? JSON.stringify(resp.error)}`);
19
+ // Extract text content from MCP result
20
+ const result = resp.result;
21
+ if (result?.content) {
22
+ const textParts = result.content.filter((c) => c.type === 'text');
23
+ if (textParts.length === 1) {
24
+ let text = textParts[0].text;
25
+ // MCP browser_evaluate returns: "[JSON]\n### Ran Playwright code\n```js\n...\n```"
26
+ // Strip the "### Ran Playwright code" suffix to get clean JSON
27
+ const codeMarker = text.indexOf('### Ran Playwright code');
28
+ if (codeMarker !== -1) {
29
+ text = text.slice(0, codeMarker).trim();
30
+ }
31
+ // Also handle "### Result\n[JSON]" format (some MCP versions)
32
+ const resultMarker = text.indexOf('### Result\n');
33
+ if (resultMarker !== -1) {
34
+ text = text.slice(resultMarker + '### Result\n'.length).trim();
35
+ }
36
+ try {
37
+ return JSON.parse(text);
38
+ }
39
+ catch {
40
+ return text;
41
+ }
42
+ }
43
+ }
44
+ return result;
45
+ }
46
+ // --- High-level methods ---
47
+ async goto(url) {
48
+ await this.call('tools/call', { name: 'browser_navigate', arguments: { url } });
49
+ }
50
+ async evaluate(js) {
51
+ // Normalize IIFE format to function format expected by MCP browser_evaluate
52
+ const normalized = normalizeEvaluateSource(js);
53
+ return this.call('tools/call', { name: 'browser_evaluate', arguments: { function: normalized } });
54
+ }
55
+ async snapshot(opts = {}) {
56
+ const raw = await this.call('tools/call', { name: 'browser_snapshot', arguments: {} });
57
+ if (opts.raw)
58
+ return raw;
59
+ if (typeof raw === 'string')
60
+ return formatSnapshot(raw, opts);
61
+ return raw;
62
+ }
63
+ async click(ref) {
64
+ await this.call('tools/call', { name: 'browser_click', arguments: { element: 'click target', ref } });
65
+ }
66
+ async typeText(ref, text) {
67
+ await this.call('tools/call', { name: 'browser_type', arguments: { element: 'type target', ref, text } });
68
+ }
69
+ async pressKey(key) {
70
+ await this.call('tools/call', { name: 'browser_press_key', arguments: { key } });
71
+ }
72
+ async wait(options) {
73
+ if (typeof options === 'number') {
74
+ await this.call('tools/call', { name: 'browser_wait_for', arguments: { time: options } });
75
+ }
76
+ else {
77
+ // Pass directly to native wait_for, which supports natively awaiting text strings without heavy DOM polling
78
+ await this.call('tools/call', { name: 'browser_wait_for', arguments: options });
79
+ }
80
+ }
81
+ async tabs() {
82
+ return this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'list' } });
83
+ }
84
+ async closeTab(index) {
85
+ await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'close', ...(index !== undefined ? { index } : {}) } });
86
+ }
87
+ async newTab() {
88
+ await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'new' } });
89
+ }
90
+ async selectTab(index) {
91
+ await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'select', index } });
92
+ }
93
+ async networkRequests(includeStatic = false) {
94
+ return this.call('tools/call', { name: 'browser_network_requests', arguments: { includeStatic } });
95
+ }
96
+ async consoleMessages(level = 'info') {
97
+ return this.call('tools/call', { name: 'browser_console_messages', arguments: { level } });
98
+ }
99
+ async scroll(direction = 'down', _amount = 500) {
100
+ await this.call('tools/call', { name: 'browser_press_key', arguments: { key: direction === 'down' ? 'PageDown' : 'PageUp' } });
101
+ }
102
+ async autoScroll(options = {}) {
103
+ const times = options.times ?? 3;
104
+ const delayMs = options.delayMs ?? 2000;
105
+ const js = `
106
+ async () => {
107
+ const maxTimes = ${times};
108
+ const maxWaitMs = ${delayMs};
109
+ for (let i = 0; i < maxTimes; i++) {
110
+ const lastHeight = document.body.scrollHeight;
111
+ window.scrollTo(0, lastHeight);
112
+ await new Promise(resolve => {
113
+ let timeoutId;
114
+ const observer = new MutationObserver(() => {
115
+ if (document.body.scrollHeight > lastHeight) {
116
+ clearTimeout(timeoutId);
117
+ observer.disconnect();
118
+ setTimeout(resolve, 100); // Small debounce for rendering
119
+ }
120
+ });
121
+ observer.observe(document.body, { childList: true, subtree: true });
122
+ timeoutId = setTimeout(() => {
123
+ observer.disconnect();
124
+ resolve(null);
125
+ }, maxWaitMs);
126
+ });
127
+ }
128
+ }
129
+ `;
130
+ await this.evaluate(js);
131
+ }
132
+ async installInterceptor(pattern) {
133
+ await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
134
+ arrayName: '__opencli_xhr',
135
+ patchGuard: '__opencli_interceptor_patched',
136
+ }));
137
+ }
138
+ async getInterceptedRequests() {
139
+ const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
140
+ return result || [];
141
+ }
142
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Browser tab management helpers: extract, diff, and cleanup tab state.
3
+ */
4
+ export declare function extractTabEntries(raw: unknown): Array<{
5
+ index: number;
6
+ identity: string;
7
+ }>;
8
+ export declare function extractTabIdentities(raw: unknown): string[];
9
+ export declare function diffTabIndexes(initialIdentities: string[], currentTabs: Array<{
10
+ index: number;
11
+ identity: string;
12
+ }>): number[];
13
+ export declare function appendLimited(current: string, chunk: string, limit: number): string;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Browser tab management helpers: extract, diff, and cleanup tab state.
3
+ */
4
+ export function extractTabEntries(raw) {
5
+ if (Array.isArray(raw)) {
6
+ return raw.map((tab, index) => ({
7
+ index,
8
+ identity: [
9
+ tab?.id ?? '',
10
+ tab?.url ?? '',
11
+ tab?.title ?? '',
12
+ tab?.name ?? '',
13
+ ].join('|'),
14
+ }));
15
+ }
16
+ if (typeof raw === 'string') {
17
+ return raw
18
+ .split('\n')
19
+ .map(line => line.trim())
20
+ .filter(Boolean)
21
+ .map(line => {
22
+ // Match actual Playwright MCP format: "- 0: (current) [title](url)" or "- 1: [title](url)"
23
+ const mcpMatch = line.match(/^-\s+(\d+):\s*(.*)$/);
24
+ if (mcpMatch) {
25
+ return {
26
+ index: parseInt(mcpMatch[1], 10),
27
+ identity: mcpMatch[2].trim() || `tab-${mcpMatch[1]}`,
28
+ };
29
+ }
30
+ // Legacy format: "Tab 0 ..."
31
+ const legacyMatch = line.match(/Tab\s+(\d+)\s*(.*)$/);
32
+ if (legacyMatch) {
33
+ return {
34
+ index: parseInt(legacyMatch[1], 10),
35
+ identity: legacyMatch[2].trim() || `tab-${legacyMatch[1]}`,
36
+ };
37
+ }
38
+ return null;
39
+ })
40
+ .filter((entry) => entry !== null);
41
+ }
42
+ return [];
43
+ }
44
+ export function extractTabIdentities(raw) {
45
+ return extractTabEntries(raw).map(tab => tab.identity);
46
+ }
47
+ export function diffTabIndexes(initialIdentities, currentTabs) {
48
+ if (initialIdentities.length === 0 || currentTabs.length === 0)
49
+ return [];
50
+ const remaining = new Map();
51
+ for (const identity of initialIdentities) {
52
+ remaining.set(identity, (remaining.get(identity) ?? 0) + 1);
53
+ }
54
+ const tabsToClose = [];
55
+ for (const tab of currentTabs) {
56
+ const count = remaining.get(tab.identity) ?? 0;
57
+ if (count > 0) {
58
+ remaining.set(tab.identity, count - 1);
59
+ continue;
60
+ }
61
+ tabsToClose.push(tab.index);
62
+ }
63
+ return tabsToClose.sort((a, b) => b - a);
64
+ }
65
+ export function appendLimited(current, chunk, limit) {
66
+ const next = current + chunk;
67
+ if (next.length <= limit)
68
+ return next;
69
+ return next.slice(-limit);
70
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { PlaywrightMCP, __test__ } from './browser.js';
2
+ import { PlaywrightMCP, __test__ } from './browser/index.js';
3
3
  describe('browser helpers', () => {
4
4
  it('creates JSON-RPC requests with unique ids', () => {
5
5
  const first = __test__.createJsonRpcRequest('tools/call', { name: 'browser_tabs' });
@@ -6,6 +6,7 @@
6
6
  * - Dynamic completion logic that returns candidates for the current cursor position
7
7
  */
8
8
  import { getRegistry } from './registry.js';
9
+ import { CliError } from './errors.js';
9
10
  // ── Dynamic completion logic ───────────────────────────────────────────────
10
11
  /**
11
12
  * Built-in (non-dynamic) top-level commands.
@@ -110,7 +111,6 @@ export function printCompletionScript(shell) {
110
111
  process.stdout.write(fishCompletionScript());
111
112
  break;
112
113
  default:
113
- console.error(`Unsupported shell: ${shell}. Supported: bash, zsh, fish`);
114
- process.exitCode = 1;
114
+ throw new CliError('UNSUPPORTED_SHELL', `Unsupported shell: ${shell}. Supported: bash, zsh, fish`);
115
115
  }
116
116
  }
package/dist/doctor.js CHANGED
@@ -4,7 +4,7 @@ import * as path from 'node:path';
4
4
  import { createInterface } from 'node:readline/promises';
5
5
  import { stdin as input, stdout as output } from 'node:process';
6
6
  import chalk from 'chalk';
7
- import { PlaywrightMCP, getTokenFingerprint } from './browser.js';
7
+ import { PlaywrightMCP, getTokenFingerprint } from './browser/index.js';
8
8
  const PLAYWRIGHT_SERVER_NAME = 'playwright';
9
9
  export const PLAYWRIGHT_TOKEN_ENV = 'PLAYWRIGHT_MCP_EXTENSION_TOKEN';
10
10
  const PLAYWRIGHT_EXTENSION_ID = 'mmlmfjhmonkocbjadbfplnigmagldckm';
@@ -266,14 +266,14 @@ export function discoverExtensionToken() {
266
266
  const platform = os.platform();
267
267
  const bases = [];
268
268
  if (platform === 'darwin') {
269
- bases.push(path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'), path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary'), path.join(home, 'Library', 'Application Support', 'Chromium'), path.join(home, 'Library', 'Application Support', 'Microsoft Edge'));
269
+ bases.push(path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'), path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Dev'), path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Beta'), path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary'), path.join(home, 'Library', 'Application Support', 'Chromium'), path.join(home, 'Library', 'Application Support', 'Microsoft Edge'));
270
270
  }
271
271
  else if (platform === 'linux') {
272
- bases.push(path.join(home, '.config', 'google-chrome'), path.join(home, '.config', 'chromium'), path.join(home, '.config', 'microsoft-edge'));
272
+ bases.push(path.join(home, '.config', 'google-chrome'), path.join(home, '.config', 'google-chrome-unstable'), path.join(home, '.config', 'google-chrome-beta'), path.join(home, '.config', 'chromium'), path.join(home, '.config', 'microsoft-edge'));
273
273
  }
274
274
  else if (platform === 'win32') {
275
275
  const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
276
- bases.push(path.join(appData, 'Google', 'Chrome', 'User Data'), path.join(appData, 'Microsoft', 'Edge', 'User Data'));
276
+ bases.push(path.join(appData, 'Google', 'Chrome', 'User Data'), path.join(appData, 'Google', 'Chrome Dev', 'User Data'), path.join(appData, 'Google', 'Chrome Beta', 'User Data'), path.join(appData, 'Microsoft', 'Edge', 'User Data'));
277
277
  }
278
278
  const profiles = enumerateProfiles(bases);
279
279
  const tokenRe = /([A-Za-z0-9_-]{40,50})/;
@@ -388,14 +388,14 @@ export function checkExtensionInstalled() {
388
388
  const platform = os.platform();
389
389
  const browserDirs = [];
390
390
  if (platform === 'darwin') {
391
- browserDirs.push({ name: 'Chrome', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome') }, { name: 'Chrome Canary', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary') }, { name: 'Chromium', base: path.join(home, 'Library', 'Application Support', 'Chromium') }, { name: 'Edge', base: path.join(home, 'Library', 'Application Support', 'Microsoft Edge') });
391
+ browserDirs.push({ name: 'Chrome', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome') }, { name: 'Chrome Dev', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Dev') }, { name: 'Chrome Beta', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Beta') }, { name: 'Chrome Canary', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary') }, { name: 'Chromium', base: path.join(home, 'Library', 'Application Support', 'Chromium') }, { name: 'Edge', base: path.join(home, 'Library', 'Application Support', 'Microsoft Edge') });
392
392
  }
393
393
  else if (platform === 'linux') {
394
- browserDirs.push({ name: 'Chrome', base: path.join(home, '.config', 'google-chrome') }, { name: 'Chromium', base: path.join(home, '.config', 'chromium') }, { name: 'Edge', base: path.join(home, '.config', 'microsoft-edge') });
394
+ browserDirs.push({ name: 'Chrome', base: path.join(home, '.config', 'google-chrome') }, { name: 'Chrome Dev', base: path.join(home, '.config', 'google-chrome-unstable') }, { name: 'Chrome Beta', base: path.join(home, '.config', 'google-chrome-beta') }, { name: 'Chromium', base: path.join(home, '.config', 'chromium') }, { name: 'Edge', base: path.join(home, '.config', 'microsoft-edge') });
395
395
  }
396
396
  else if (platform === 'win32') {
397
397
  const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
398
- browserDirs.push({ name: 'Chrome', base: path.join(appData, 'Google', 'Chrome', 'User Data') }, { name: 'Edge', base: path.join(appData, 'Microsoft', 'Edge', 'User Data') });
398
+ browserDirs.push({ name: 'Chrome', base: path.join(appData, 'Google', 'Chrome', 'User Data') }, { name: 'Chrome Dev', base: path.join(appData, 'Google', 'Chrome Dev', 'User Data') }, { name: 'Chrome Beta', base: path.join(appData, 'Google', 'Chrome Beta', 'User Data') }, { name: 'Edge', base: path.join(appData, 'Microsoft', 'Edge', 'User Data') });
399
399
  }
400
400
  const profiles = enumerateProfiles(browserDirs.map(d => d.base));
401
401
  const foundBrowsers = [];