@jackwener/opencli 0.7.11 → 0.8.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/CDP.md +103 -0
- package/CDP.zh-CN.md +103 -0
- package/README.md +4 -0
- package/README.zh-CN.md +4 -0
- package/dist/browser/discover.d.ts +15 -0
- package/dist/browser/discover.js +68 -2
- package/dist/browser/errors.d.ts +2 -1
- package/dist/browser/errors.js +13 -0
- package/dist/browser/index.d.ts +1 -0
- package/dist/browser/index.js +1 -0
- package/dist/browser/mcp.js +8 -3
- package/dist/browser/page.js +11 -2
- package/dist/clis/barchart/flow.js +56 -58
- package/dist/doctor.js +8 -0
- package/dist/engine.d.ts +1 -1
- package/dist/engine.js +59 -1
- package/dist/main.js +2 -15
- package/dist/pipeline/executor.js +2 -24
- package/dist/pipeline/registry.d.ts +19 -0
- package/dist/pipeline/registry.js +41 -0
- package/package.json +1 -1
- package/src/browser/discover.ts +79 -5
- package/src/browser/errors.ts +17 -1
- package/src/browser/index.ts +1 -0
- package/src/browser/mcp.ts +8 -3
- package/src/browser/page.ts +21 -2
- package/src/clis/barchart/flow.ts +57 -58
- package/src/doctor.ts +9 -0
- package/src/engine.ts +58 -1
- package/src/main.ts +6 -11
- package/src/pipeline/executor.ts +2 -28
- package/src/pipeline/registry.ts +60 -0
package/dist/engine.js
CHANGED
|
@@ -155,10 +155,68 @@ function registerYamlCli(filePath, defaultSite) {
|
|
|
155
155
|
log.warn(`Failed to load ${filePath}: ${err.message}`);
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* Validates and coerces arguments based on the command's Arg definitions.
|
|
160
|
+
*/
|
|
161
|
+
function coerceAndValidateArgs(cmdArgs, kwargs) {
|
|
162
|
+
const result = { ...kwargs };
|
|
163
|
+
for (const argDef of cmdArgs) {
|
|
164
|
+
const val = result[argDef.name];
|
|
165
|
+
// 1. Check required
|
|
166
|
+
if (argDef.required && (val === undefined || val === null || val === '')) {
|
|
167
|
+
throw new Error(`Argument "${argDef.name}" is required.\n${argDef.help ? `Hint: ${argDef.help}` : ''}`);
|
|
168
|
+
}
|
|
169
|
+
if (val !== undefined && val !== null) {
|
|
170
|
+
// 2. Type coercion
|
|
171
|
+
if (argDef.type === 'int' || argDef.type === 'number') {
|
|
172
|
+
const num = Number(val);
|
|
173
|
+
if (Number.isNaN(num)) {
|
|
174
|
+
throw new Error(`Argument "${argDef.name}" must be a valid number. Received: "${val}"`);
|
|
175
|
+
}
|
|
176
|
+
result[argDef.name] = num;
|
|
177
|
+
}
|
|
178
|
+
else if (argDef.type === 'boolean' || argDef.type === 'bool') {
|
|
179
|
+
if (typeof val === 'string') {
|
|
180
|
+
const lower = val.toLowerCase();
|
|
181
|
+
if (lower === 'true' || lower === '1')
|
|
182
|
+
result[argDef.name] = true;
|
|
183
|
+
else if (lower === 'false' || lower === '0')
|
|
184
|
+
result[argDef.name] = false;
|
|
185
|
+
else
|
|
186
|
+
throw new Error(`Argument "${argDef.name}" must be a boolean (true/false). Received: "${val}"`);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
result[argDef.name] = Boolean(val);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// 3. Choices validation
|
|
193
|
+
const coercedVal = result[argDef.name];
|
|
194
|
+
if (argDef.choices && argDef.choices.length > 0) {
|
|
195
|
+
// Only stringent check for string/number types against choices array
|
|
196
|
+
if (!argDef.choices.map(String).includes(String(coercedVal))) {
|
|
197
|
+
throw new Error(`Argument "${argDef.name}" must be one of: ${argDef.choices.join(', ')}. Received: "${coercedVal}"`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
else if (argDef.default !== undefined) {
|
|
202
|
+
// Set default if value is missing
|
|
203
|
+
result[argDef.name] = argDef.default;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
158
208
|
/**
|
|
159
209
|
* Execute a CLI command. Handles lazy-loading of TS modules.
|
|
160
210
|
*/
|
|
161
|
-
export async function executeCommand(cmd, page,
|
|
211
|
+
export async function executeCommand(cmd, page, rawKwargs, debug = false) {
|
|
212
|
+
let kwargs;
|
|
213
|
+
try {
|
|
214
|
+
kwargs = coerceAndValidateArgs(cmd.args, rawKwargs);
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
// Re-throw validation errors clearly
|
|
218
|
+
throw new Error(`[Argument Validation Error]\n${err.message}`);
|
|
219
|
+
}
|
|
162
220
|
// Lazy-load TS module on first execution
|
|
163
221
|
const internal = cmd;
|
|
164
222
|
if (internal._lazy && internal._modulePath) {
|
package/dist/main.js
CHANGED
|
@@ -199,9 +199,7 @@ for (const [, cmd] of registry) {
|
|
|
199
199
|
const arg = positionalArgs[i];
|
|
200
200
|
const v = actionArgs[i];
|
|
201
201
|
if (v !== undefined)
|
|
202
|
-
kwargs[arg.name] =
|
|
203
|
-
else if (arg.default != null)
|
|
204
|
-
kwargs[arg.name] = arg.default;
|
|
202
|
+
kwargs[arg.name] = v;
|
|
205
203
|
}
|
|
206
204
|
// Collect named options
|
|
207
205
|
for (const arg of cmd.args) {
|
|
@@ -209,9 +207,7 @@ for (const [, cmd] of registry) {
|
|
|
209
207
|
continue;
|
|
210
208
|
const v = actionOpts[arg.name];
|
|
211
209
|
if (v !== undefined)
|
|
212
|
-
kwargs[arg.name] =
|
|
213
|
-
else if (arg.default != null)
|
|
214
|
-
kwargs[arg.name] = arg.default;
|
|
210
|
+
kwargs[arg.name] = v;
|
|
215
211
|
}
|
|
216
212
|
try {
|
|
217
213
|
if (actionOpts.verbose)
|
|
@@ -244,13 +240,4 @@ for (const [, cmd] of registry) {
|
|
|
244
240
|
}
|
|
245
241
|
});
|
|
246
242
|
}
|
|
247
|
-
function coerce(v, t) {
|
|
248
|
-
if (t === 'bool')
|
|
249
|
-
return ['1', 'true', 'yes', 'on'].includes(String(v).toLowerCase());
|
|
250
|
-
if (t === 'int')
|
|
251
|
-
return parseInt(String(v), 10);
|
|
252
|
-
if (t === 'float')
|
|
253
|
-
return parseFloat(String(v));
|
|
254
|
-
return String(v);
|
|
255
|
-
}
|
|
256
243
|
program.parse();
|
|
@@ -1,30 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pipeline executor: runs YAML pipeline steps sequentially.
|
|
3
3
|
*/
|
|
4
|
-
import {
|
|
5
|
-
import { stepFetch } from './steps/fetch.js';
|
|
6
|
-
import { stepSelect, stepMap, stepFilter, stepSort, stepLimit } from './steps/transform.js';
|
|
7
|
-
import { stepIntercept } from './steps/intercept.js';
|
|
8
|
-
import { stepTap } from './steps/tap.js';
|
|
4
|
+
import { getStep } from './registry.js';
|
|
9
5
|
import { log } from '../logger.js';
|
|
10
|
-
/** Registry of all available step handlers */
|
|
11
|
-
const STEP_HANDLERS = {
|
|
12
|
-
navigate: stepNavigate,
|
|
13
|
-
fetch: stepFetch,
|
|
14
|
-
select: stepSelect,
|
|
15
|
-
evaluate: stepEvaluate,
|
|
16
|
-
snapshot: stepSnapshot,
|
|
17
|
-
click: stepClick,
|
|
18
|
-
type: stepType,
|
|
19
|
-
wait: stepWait,
|
|
20
|
-
press: stepPress,
|
|
21
|
-
map: stepMap,
|
|
22
|
-
filter: stepFilter,
|
|
23
|
-
sort: stepSort,
|
|
24
|
-
limit: stepLimit,
|
|
25
|
-
intercept: stepIntercept,
|
|
26
|
-
tap: stepTap,
|
|
27
|
-
};
|
|
28
6
|
export async function executePipeline(page, pipeline, ctx = {}) {
|
|
29
7
|
const args = ctx.args ?? {};
|
|
30
8
|
const debug = ctx.debug ?? false;
|
|
@@ -37,7 +15,7 @@ export async function executePipeline(page, pipeline, ctx = {}) {
|
|
|
37
15
|
for (const [op, params] of Object.entries(step)) {
|
|
38
16
|
if (debug)
|
|
39
17
|
debugStepStart(i + 1, total, op, params);
|
|
40
|
-
const handler =
|
|
18
|
+
const handler = getStep(op);
|
|
41
19
|
if (handler) {
|
|
42
20
|
data = await handler(page, params, data, args);
|
|
43
21
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic registry for pipeline steps.
|
|
3
|
+
* Allows core and third-party plugins to register custom YAML operations.
|
|
4
|
+
*/
|
|
5
|
+
import type { IPage } from '../types.js';
|
|
6
|
+
/**
|
|
7
|
+
* Step handler: all pipeline steps conform to this generic interface.
|
|
8
|
+
* TData is the type of the `data` state flowing into the step.
|
|
9
|
+
* TResult is the expected return type.
|
|
10
|
+
*/
|
|
11
|
+
export type StepHandler<TData = any, TResult = any> = (page: IPage | null, params: any, data: TData, args: Record<string, any>) => Promise<TResult>;
|
|
12
|
+
/**
|
|
13
|
+
* Get a registered step handler by name.
|
|
14
|
+
*/
|
|
15
|
+
export declare function getStep(name: string): StepHandler | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Register a new custom step handler for the YAML pipeline.
|
|
18
|
+
*/
|
|
19
|
+
export declare function registerStep(name: string, handler: StepHandler): void;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic registry for pipeline steps.
|
|
3
|
+
* Allows core and third-party plugins to register custom YAML operations.
|
|
4
|
+
*/
|
|
5
|
+
// Import core steps
|
|
6
|
+
import { stepNavigate, stepClick, stepType, stepWait, stepPress, stepSnapshot, stepEvaluate } from './steps/browser.js';
|
|
7
|
+
import { stepFetch } from './steps/fetch.js';
|
|
8
|
+
import { stepSelect, stepMap, stepFilter, stepSort, stepLimit } from './steps/transform.js';
|
|
9
|
+
import { stepIntercept } from './steps/intercept.js';
|
|
10
|
+
import { stepTap } from './steps/tap.js';
|
|
11
|
+
const _stepRegistry = new Map();
|
|
12
|
+
/**
|
|
13
|
+
* Get a registered step handler by name.
|
|
14
|
+
*/
|
|
15
|
+
export function getStep(name) {
|
|
16
|
+
return _stepRegistry.get(name);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Register a new custom step handler for the YAML pipeline.
|
|
20
|
+
*/
|
|
21
|
+
export function registerStep(name, handler) {
|
|
22
|
+
_stepRegistry.set(name, handler);
|
|
23
|
+
}
|
|
24
|
+
// -------------------------------------------------------------
|
|
25
|
+
// Auto-Register Core Steps
|
|
26
|
+
// -------------------------------------------------------------
|
|
27
|
+
registerStep('navigate', stepNavigate);
|
|
28
|
+
registerStep('fetch', stepFetch);
|
|
29
|
+
registerStep('select', stepSelect);
|
|
30
|
+
registerStep('evaluate', stepEvaluate);
|
|
31
|
+
registerStep('snapshot', stepSnapshot);
|
|
32
|
+
registerStep('click', stepClick);
|
|
33
|
+
registerStep('type', stepType);
|
|
34
|
+
registerStep('wait', stepWait);
|
|
35
|
+
registerStep('press', stepPress);
|
|
36
|
+
registerStep('map', stepMap);
|
|
37
|
+
registerStep('filter', stepFilter);
|
|
38
|
+
registerStep('sort', stepSort);
|
|
39
|
+
registerStep('limit', stepLimit);
|
|
40
|
+
registerStep('intercept', stepIntercept);
|
|
41
|
+
registerStep('tap', stepTap);
|
package/package.json
CHANGED
package/src/browser/discover.ts
CHANGED
|
@@ -111,13 +111,87 @@ export function findMcpServerPath(): string | null {
|
|
|
111
111
|
return _cachedMcpServerPath;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Chrome 144+ auto-discovery: read DevToolsActivePort file to get CDP endpoint.
|
|
116
|
+
*
|
|
117
|
+
* Starting with Chrome 144, users can enable remote debugging from
|
|
118
|
+
* chrome://inspect#remote-debugging without any command-line flags.
|
|
119
|
+
* Chrome writes the active port and browser GUID to a DevToolsActivePort file
|
|
120
|
+
* in the user data directory, which we read to construct the WebSocket endpoint.
|
|
121
|
+
*/
|
|
122
|
+
export function discoverChromeEndpoint(): string | null {
|
|
123
|
+
const candidates: string[] = [];
|
|
124
|
+
|
|
125
|
+
// User-specified Chrome data dir takes highest priority
|
|
126
|
+
if (process.env.CHROME_USER_DATA_DIR) {
|
|
127
|
+
candidates.push(path.join(process.env.CHROME_USER_DATA_DIR, 'DevToolsActivePort'));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Standard Chrome/Edge user data dirs per platform
|
|
131
|
+
if (process.platform === 'win32') {
|
|
132
|
+
const localAppData = process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local');
|
|
133
|
+
candidates.push(path.join(localAppData, 'Google', 'Chrome', 'User Data', 'DevToolsActivePort'));
|
|
134
|
+
candidates.push(path.join(localAppData, 'Microsoft', 'Edge', 'User Data', 'DevToolsActivePort'));
|
|
135
|
+
} else if (process.platform === 'darwin') {
|
|
136
|
+
candidates.push(path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome', 'DevToolsActivePort'));
|
|
137
|
+
candidates.push(path.join(os.homedir(), 'Library', 'Application Support', 'Microsoft Edge', 'DevToolsActivePort'));
|
|
138
|
+
} else {
|
|
139
|
+
candidates.push(path.join(os.homedir(), '.config', 'google-chrome', 'DevToolsActivePort'));
|
|
140
|
+
candidates.push(path.join(os.homedir(), '.config', 'chromium', 'DevToolsActivePort'));
|
|
141
|
+
candidates.push(path.join(os.homedir(), '.config', 'microsoft-edge', 'DevToolsActivePort'));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const filePath of candidates) {
|
|
145
|
+
try {
|
|
146
|
+
const content = fs.readFileSync(filePath, 'utf-8').trim();
|
|
147
|
+
const lines = content.split('\n');
|
|
148
|
+
if (lines.length >= 2) {
|
|
149
|
+
const port = parseInt(lines[0], 10);
|
|
150
|
+
const browserPath = lines[1]; // e.g. /devtools/browser/<GUID>
|
|
151
|
+
if (port > 0 && browserPath.startsWith('/devtools/browser/')) {
|
|
152
|
+
return `ws://127.0.0.1:${port}${browserPath}`;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} catch {}
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function resolveCdpEndpoint(): { endpoint?: string; requestedCdp: boolean } {
|
|
161
|
+
const envVal = process.env.OPENCLI_CDP_ENDPOINT;
|
|
162
|
+
if (envVal === '1' || envVal?.toLowerCase() === 'true') {
|
|
163
|
+
const autoDiscovered = discoverChromeEndpoint();
|
|
164
|
+
return { endpoint: autoDiscovered ?? envVal, requestedCdp: true };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (envVal) {
|
|
168
|
+
return { endpoint: envVal, requestedCdp: true };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Fallback to auto-discovery if not explicitly set
|
|
172
|
+
const autoDiscovered = discoverChromeEndpoint();
|
|
173
|
+
if (autoDiscovered) {
|
|
174
|
+
return { endpoint: autoDiscovered, requestedCdp: true };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { requestedCdp: false };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function buildRuntimeArgs(input?: { executablePath?: string | null; cdpEndpoint?: string }): string[] {
|
|
115
181
|
const args: string[] = [];
|
|
182
|
+
|
|
183
|
+
// Priority 1: CDP endpoint (remote Chrome debugging or local Auto-Discovery)
|
|
184
|
+
if (input?.cdpEndpoint) {
|
|
185
|
+
args.push('--cdp-endpoint', input.cdpEndpoint);
|
|
186
|
+
return args;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Priority 2: Extension mode (local Chrome with MCP Bridge extension)
|
|
116
190
|
if (!process.env.CI) {
|
|
117
|
-
// Local: always connect to user's running Chrome via MCP Bridge extension
|
|
118
191
|
args.push('--extension');
|
|
119
192
|
}
|
|
120
|
-
|
|
193
|
+
|
|
194
|
+
// CI/standalone mode: @playwright/mcp launches its own browser (headed by default).
|
|
121
195
|
// xvfb provides a virtual display for headed mode in GitHub Actions.
|
|
122
196
|
if (input?.executablePath) {
|
|
123
197
|
args.push('--executable-path', input.executablePath);
|
|
@@ -125,11 +199,11 @@ function buildRuntimeArgs(input?: { executablePath?: string | null }): string[]
|
|
|
125
199
|
return args;
|
|
126
200
|
}
|
|
127
201
|
|
|
128
|
-
export function buildMcpArgs(input: { mcpPath: string; executablePath?: string | null }): string[] {
|
|
202
|
+
export function buildMcpArgs(input: { mcpPath: string; executablePath?: string | null; cdpEndpoint?: string }): string[] {
|
|
129
203
|
return [input.mcpPath, ...buildRuntimeArgs(input)];
|
|
130
204
|
}
|
|
131
205
|
|
|
132
|
-
export function buildMcpLaunchSpec(input: { mcpPath?: string | null; executablePath?: string | null }): {
|
|
206
|
+
export function buildMcpLaunchSpec(input: { mcpPath?: string | null; executablePath?: string | null; cdpEndpoint?: string }): {
|
|
133
207
|
command: string;
|
|
134
208
|
args: string[];
|
|
135
209
|
usedNpxFallback: boolean;
|
package/src/browser/errors.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
6
|
|
|
7
|
-
export type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'mcp-init' | 'process-exit' | 'unknown';
|
|
7
|
+
export type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'mcp-init' | 'process-exit' | 'cdp-connection-failed' | 'unknown';
|
|
8
8
|
|
|
9
9
|
export type ConnectFailureInput = {
|
|
10
10
|
kind: ConnectFailureKind;
|
|
@@ -26,6 +26,15 @@ export function formatBrowserConnectError(input: ConnectFailureInput): Error {
|
|
|
26
26
|
const suffix = stderr ? `\n\nMCP stderr:\n${stderr}` : '';
|
|
27
27
|
const tokenHint = input.tokenFingerprint ? ` Token fingerprint: ${input.tokenFingerprint}.` : '';
|
|
28
28
|
|
|
29
|
+
if (input.kind === 'cdp-connection-failed') {
|
|
30
|
+
return new Error(
|
|
31
|
+
`Failed to connect to remote Chrome via CDP endpoint.\n\n` +
|
|
32
|
+
`Check if Chrome is running with remote debugging enabled (--remote-debugging-port=9222) or DevToolsActivePort is available under chrome://inspect#remote-debugging.\n` +
|
|
33
|
+
`If you specified OPENCLI_CDP_ENDPOINT=1, auto-discovery might have failed.` +
|
|
34
|
+
suffix,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
29
38
|
if (input.kind === 'missing-token') {
|
|
30
39
|
return new Error(
|
|
31
40
|
'Failed to connect to Playwright MCP Bridge: PLAYWRIGHT_MCP_EXTENSION_TOKEN is not set.\n\n' +
|
|
@@ -74,9 +83,16 @@ export function inferConnectFailureKind(args: {
|
|
|
74
83
|
stderr: string;
|
|
75
84
|
rawMessage?: string;
|
|
76
85
|
exited?: boolean;
|
|
86
|
+
isCdpMode?: boolean;
|
|
77
87
|
}): ConnectFailureKind {
|
|
78
88
|
const haystack = `${args.rawMessage ?? ''}\n${args.stderr}`.toLowerCase();
|
|
79
89
|
|
|
90
|
+
if (args.isCdpMode) {
|
|
91
|
+
if (args.rawMessage?.startsWith('MCP init failed:')) return 'mcp-init';
|
|
92
|
+
if (args.exited) return 'cdp-connection-failed';
|
|
93
|
+
return 'cdp-connection-failed';
|
|
94
|
+
}
|
|
95
|
+
|
|
80
96
|
if (!args.hasExtensionToken)
|
|
81
97
|
return 'missing-token';
|
|
82
98
|
if (haystack.includes('extension connection timeout') || haystack.includes('playwright mcp bridge'))
|
package/src/browser/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export { Page } from './page.js';
|
|
|
9
9
|
export { PlaywrightMCP } from './mcp.js';
|
|
10
10
|
export { getTokenFingerprint, formatBrowserConnectError } from './errors.js';
|
|
11
11
|
export type { ConnectFailureKind, ConnectFailureInput } from './errors.js';
|
|
12
|
+
export { resolveCdpEndpoint } from './discover.js';
|
|
12
13
|
|
|
13
14
|
// Test-only helpers — exposed for unit tests
|
|
14
15
|
import { createJsonRpcRequest } from './mcp.js';
|
package/src/browser/mcp.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { withTimeoutMs, DEFAULT_BROWSER_CONNECT_TIMEOUT } from '../runtime.js';
|
|
|
9
9
|
import { PKG_VERSION } from '../version.js';
|
|
10
10
|
import { Page } from './page.js';
|
|
11
11
|
import { getTokenFingerprint, formatBrowserConnectError, inferConnectFailureKind } from './errors.js';
|
|
12
|
-
import { findMcpServerPath, buildMcpLaunchSpec } from './discover.js';
|
|
12
|
+
import { findMcpServerPath, buildMcpLaunchSpec, resolveCdpEndpoint } from './discover.js';
|
|
13
13
|
import { extractTabIdentities, extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
|
|
14
14
|
|
|
15
15
|
const STDERR_BUFFER_LIMIT = 16 * 1024;
|
|
@@ -114,7 +114,8 @@ export class PlaywrightMCP {
|
|
|
114
114
|
return new Promise<Page>((resolve, reject) => {
|
|
115
115
|
const isDebug = process.env.DEBUG?.includes('opencli:mcp');
|
|
116
116
|
const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`);
|
|
117
|
-
const
|
|
117
|
+
const { endpoint: cdpEndpoint, requestedCdp } = resolveCdpEndpoint();
|
|
118
|
+
const useExtension = !requestedCdp;
|
|
118
119
|
const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
|
|
119
120
|
const tokenFingerprint = getTokenFingerprint(extensionToken);
|
|
120
121
|
let stderrBuffer = '';
|
|
@@ -150,15 +151,17 @@ export class PlaywrightMCP {
|
|
|
150
151
|
settleError(inferConnectFailureKind({
|
|
151
152
|
hasExtensionToken: !!extensionToken,
|
|
152
153
|
stderr: stderrBuffer,
|
|
154
|
+
isCdpMode: requestedCdp,
|
|
153
155
|
}));
|
|
154
156
|
}, timeout * 1000);
|
|
155
157
|
|
|
156
158
|
const launchSpec = buildMcpLaunchSpec({
|
|
157
159
|
mcpPath,
|
|
158
160
|
executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH,
|
|
161
|
+
cdpEndpoint,
|
|
159
162
|
});
|
|
160
163
|
if (process.env.OPENCLI_VERBOSE) {
|
|
161
|
-
console.error(`[opencli] Mode: ${useExtension ? 'extension' : 'standalone'}`);
|
|
164
|
+
console.error(`[opencli] Mode: ${requestedCdp ? 'CDP' : useExtension ? 'extension' : 'standalone'}`);
|
|
162
165
|
if (useExtension) console.error(`[opencli] Extension token: fingerprint ${tokenFingerprint}`);
|
|
163
166
|
if (launchSpec.usedNpxFallback) {
|
|
164
167
|
console.error('[opencli] Playwright MCP not found locally; bootstrapping via npx @playwright/mcp@latest');
|
|
@@ -218,6 +221,7 @@ export class PlaywrightMCP {
|
|
|
218
221
|
hasExtensionToken: !!extensionToken,
|
|
219
222
|
stderr: stderrBuffer,
|
|
220
223
|
exited: true,
|
|
224
|
+
isCdpMode: requestedCdp,
|
|
221
225
|
}), { exitCode: code });
|
|
222
226
|
}
|
|
223
227
|
});
|
|
@@ -235,6 +239,7 @@ export class PlaywrightMCP {
|
|
|
235
239
|
hasExtensionToken: !!extensionToken,
|
|
236
240
|
stderr: stderrBuffer,
|
|
237
241
|
rawMessage: `MCP init failed: ${resp.error.message}`,
|
|
242
|
+
isCdpMode: requestedCdp,
|
|
238
243
|
}), { rawMessage: resp.error.message });
|
|
239
244
|
return;
|
|
240
245
|
}
|
package/src/browser/page.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { formatSnapshot } from '../snapshotFormatter.js';
|
|
|
6
6
|
import { normalizeEvaluateSource } from '../pipeline/template.js';
|
|
7
7
|
import { generateInterceptorJs, generateReadInterceptedJs } from '../interceptor.js';
|
|
8
8
|
import type { IPage } from '../types.js';
|
|
9
|
+
import { BrowserConnectError } from '../errors.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Page abstraction wrapping JSON-RPC calls to Playwright MCP.
|
|
@@ -18,10 +19,28 @@ export class Page implements IPage {
|
|
|
18
19
|
if (resp.error) throw new Error(`page.${method}: ${(resp.error as any).message ?? JSON.stringify(resp.error)}`);
|
|
19
20
|
// Extract text content from MCP result
|
|
20
21
|
const result = resp.result as any;
|
|
22
|
+
|
|
23
|
+
if (result?.isError) {
|
|
24
|
+
const errorText = result.content?.find((c: any) => c.type === 'text')?.text || 'Unknown MCP Error';
|
|
25
|
+
throw new BrowserConnectError(
|
|
26
|
+
errorText,
|
|
27
|
+
'Please check if the browser is running or if the Playwright MCP / CDP connection is configured correctly.'
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
if (result?.content) {
|
|
22
32
|
const textParts = result.content.filter((c: any) => c.type === 'text');
|
|
23
|
-
if (textParts.length
|
|
24
|
-
let text = textParts[
|
|
33
|
+
if (textParts.length >= 1) {
|
|
34
|
+
let text = textParts[textParts.length - 1].text; // Usually the main output is in the last text block
|
|
35
|
+
|
|
36
|
+
// Some versions of the MCP return error text without the `isError` boolean flag
|
|
37
|
+
if (typeof text === 'string' && text.trim().startsWith('### Error')) {
|
|
38
|
+
throw new BrowserConnectError(
|
|
39
|
+
text.trim(),
|
|
40
|
+
'Please check if the browser is running or if the Playwright MCP / CDP connection is configured correctly.'
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
25
44
|
// MCP browser_evaluate returns: "[JSON]\n### Ran Playwright code\n```js\n...\n```"
|
|
26
45
|
// Strip the "### Ran Playwright code" suffix to get clean JSON
|
|
27
46
|
const codeMarker = text.indexOf('### Ran Playwright code');
|
|
@@ -30,81 +30,80 @@ cli({
|
|
|
30
30
|
(async () => {
|
|
31
31
|
const limit = ${limit};
|
|
32
32
|
const typeFilter = '${optionType}'.toLowerCase();
|
|
33
|
-
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
34
|
-
const headers = { 'X-CSRF-TOKEN': csrf };
|
|
35
33
|
|
|
34
|
+
// Wait for CSRF token to appear (Angular may inject it after initial render)
|
|
35
|
+
let csrf = '';
|
|
36
|
+
for (let i = 0; i < 10; i++) {
|
|
37
|
+
csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
38
|
+
if (csrf) break;
|
|
39
|
+
await new Promise(r => setTimeout(r, 500));
|
|
40
|
+
}
|
|
41
|
+
if (!csrf) return { error: 'no-csrf' };
|
|
42
|
+
|
|
43
|
+
const headers = { 'X-CSRF-TOKEN': csrf };
|
|
36
44
|
const fields = [
|
|
37
45
|
'baseSymbol','strikePrice','expirationDate','optionType',
|
|
38
46
|
'lastPrice','volume','openInterest','volumeOpenInterestRatio','volatility',
|
|
39
47
|
].join(',');
|
|
40
48
|
|
|
41
|
-
// Fetch extra rows when filtering by type since server-side filter
|
|
49
|
+
// Fetch extra rows when filtering by type since server-side filter doesn't work
|
|
42
50
|
const fetchLimit = typeFilter !== 'all' ? limit * 3 : limit;
|
|
43
|
-
try {
|
|
44
|
-
const url = '/proxies/core-api/v1/options/get?list=options.unusual_activity.stocks.us'
|
|
45
|
-
+ '&fields=' + fields
|
|
46
|
-
+ '&orderBy=volumeOpenInterestRatio&orderDir=desc'
|
|
47
|
-
+ '&raw=1&limit=' + fetchLimit;
|
|
48
51
|
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
// Try unusual_activity first, fall back to mostActive (unusual_activity is
|
|
53
|
+
// empty outside market hours)
|
|
54
|
+
const lists = [
|
|
55
|
+
'options.unusual_activity.stocks.us',
|
|
56
|
+
'options.mostActive.us',
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
for (const list of lists) {
|
|
60
|
+
try {
|
|
61
|
+
const url = '/proxies/core-api/v1/options/get?list=' + list
|
|
62
|
+
+ '&fields=' + fields
|
|
63
|
+
+ '&orderBy=volumeOpenInterestRatio&orderDir=desc'
|
|
64
|
+
+ '&raw=1&limit=' + fetchLimit;
|
|
65
|
+
|
|
66
|
+
const resp = await fetch(url, { credentials: 'include', headers });
|
|
67
|
+
if (!resp.ok) continue;
|
|
51
68
|
const d = await resp.json();
|
|
52
69
|
let items = d?.data || [];
|
|
53
|
-
if (items.length
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
return items.slice(0, limit).map(i => {
|
|
62
|
-
const r = i.raw || i;
|
|
63
|
-
return {
|
|
64
|
-
symbol: r.baseSymbol || r.symbol,
|
|
65
|
-
type: r.optionType,
|
|
66
|
-
strike: r.strikePrice,
|
|
67
|
-
expiration: r.expirationDate,
|
|
68
|
-
last: r.lastPrice,
|
|
69
|
-
volume: r.volume,
|
|
70
|
-
openInterest: r.openInterest,
|
|
71
|
-
volOiRatio: r.volumeOpenInterestRatio,
|
|
72
|
-
iv: r.volatility,
|
|
73
|
-
};
|
|
70
|
+
if (items.length === 0) continue;
|
|
71
|
+
|
|
72
|
+
// Apply client-side type filter
|
|
73
|
+
if (typeFilter !== 'all') {
|
|
74
|
+
items = items.filter(i => {
|
|
75
|
+
const t = ((i.raw || i).optionType || '').toLowerCase();
|
|
76
|
+
return t === typeFilter;
|
|
74
77
|
});
|
|
75
78
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
type: getText(1),
|
|
90
|
-
strike: getText(2),
|
|
91
|
-
expiration: getText(3),
|
|
92
|
-
last: getText(4),
|
|
93
|
-
volume: getText(5),
|
|
94
|
-
openInterest: cells.length > 6 ? getText(6) : null,
|
|
95
|
-
volOiRatio: cells.length > 7 ? getText(7) : null,
|
|
96
|
-
iv: cells.length > 8 ? getText(8) : null,
|
|
79
|
+
return items.slice(0, limit).map(i => {
|
|
80
|
+
const r = i.raw || i;
|
|
81
|
+
return {
|
|
82
|
+
symbol: r.baseSymbol || r.symbol,
|
|
83
|
+
type: r.optionType,
|
|
84
|
+
strike: r.strikePrice,
|
|
85
|
+
expiration: r.expirationDate,
|
|
86
|
+
last: r.lastPrice,
|
|
87
|
+
volume: r.volume,
|
|
88
|
+
openInterest: r.openInterest,
|
|
89
|
+
volOiRatio: r.volumeOpenInterestRatio,
|
|
90
|
+
iv: r.volatility,
|
|
91
|
+
};
|
|
97
92
|
});
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
return results;
|
|
101
|
-
} catch(e) {
|
|
102
|
-
return [];
|
|
93
|
+
} catch(e) {}
|
|
103
94
|
}
|
|
95
|
+
|
|
96
|
+
return [];
|
|
104
97
|
})()
|
|
105
98
|
`);
|
|
106
99
|
|
|
107
|
-
if (!data
|
|
100
|
+
if (!data) return [];
|
|
101
|
+
|
|
102
|
+
if (data.error === 'no-csrf') {
|
|
103
|
+
throw new Error('Could not extract CSRF token from barchart.com. Make sure you are logged in.');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!Array.isArray(data)) return [];
|
|
108
107
|
|
|
109
108
|
return data.slice(0, limit).map(r => ({
|
|
110
109
|
symbol: r.symbol || '',
|
package/src/doctor.ts
CHANGED
|
@@ -591,6 +591,15 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
|
|
|
591
591
|
const hasMismatch = uniqueFingerprints.length > 1;
|
|
592
592
|
const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), ''];
|
|
593
593
|
|
|
594
|
+
// CDP endpoint mode (for remote/server environments)
|
|
595
|
+
const cdpEndpoint = process.env.OPENCLI_CDP_ENDPOINT;
|
|
596
|
+
if (cdpEndpoint) {
|
|
597
|
+
lines.push(statusLine('OK', `CDP endpoint: ${chalk.cyan(cdpEndpoint)}`));
|
|
598
|
+
lines.push(chalk.dim(' → Remote Chrome mode: extension token not required'));
|
|
599
|
+
lines.push('');
|
|
600
|
+
return lines.join('\n');
|
|
601
|
+
}
|
|
602
|
+
|
|
594
603
|
const installStatus: ReportStatus = report.extensionInstalled ? 'OK' : 'MISSING';
|
|
595
604
|
const installDetail = report.extensionInstalled
|
|
596
605
|
? `Extension installed (${report.extensionBrowsers.join(', ')})`
|