@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/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, kwargs, debug = false) {
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] = coerce(v, arg.type ?? 'str');
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] = coerce(v, arg.type ?? 'str');
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 { stepNavigate, stepClick, stepType, stepWait, stepPress, stepSnapshot, stepEvaluate } from './steps/browser.js';
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 = STEP_HANDLERS[op];
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "0.7.11",
3
+ "version": "0.8.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -111,13 +111,87 @@ export function findMcpServerPath(): string | null {
111
111
  return _cachedMcpServerPath;
112
112
  }
113
113
 
114
- function buildRuntimeArgs(input?: { executablePath?: string | null }): string[] {
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
- // CI: standalone mode — @playwright/mcp launches its own browser (headed by default).
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;
@@ -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'))
@@ -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';
@@ -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 useExtension = !!process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
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
  }
@@ -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 === 1) {
24
- let text = textParts[0].text;
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 may not work
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
- const resp = await fetch(url, { credentials: 'include', headers });
50
- if (resp.ok) {
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 > 0) {
54
- // Apply client-side type filter
55
- if (typeFilter !== 'all') {
56
- items = items.filter(i => {
57
- const t = ((i.raw || i).optionType || '').toLowerCase();
58
- return t === typeFilter;
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
- } catch(e) {}
78
-
79
- // Fallback: parse from DOM table
80
- try {
81
- const rows = document.querySelectorAll('tr[data-ng-repeat], tbody tr');
82
- const results = [];
83
- for (const row of rows) {
84
- const cells = row.querySelectorAll('td');
85
- if (cells.length < 6) continue;
86
- const getText = (idx) => cells[idx]?.textContent?.trim() || null;
87
- results.push({
88
- symbol: getText(0),
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
- if (results.length >= limit) break;
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 || !Array.isArray(data)) return [];
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(', ')})`