@jackwener/opencli 1.7.4 → 1.7.6
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/README.md +76 -51
- package/README.zh-CN.md +78 -62
- package/cli-manifest.json +4558 -2979
- package/clis/antigravity/serve.js +71 -25
- package/clis/baidu-scholar/search.js +87 -0
- package/clis/baidu-scholar/search.test.js +23 -0
- package/clis/bilibili/video.js +61 -0
- package/clis/bilibili/video.test.js +81 -0
- package/clis/deepseek/ask.js +94 -0
- package/clis/deepseek/ask.test.js +73 -0
- package/clis/deepseek/history.js +25 -0
- package/clis/deepseek/new.js +20 -0
- package/clis/deepseek/read.js +22 -0
- package/clis/deepseek/status.js +24 -0
- package/clis/deepseek/utils.js +291 -0
- package/clis/deepseek/utils.test.js +37 -0
- package/clis/eastmoney/_secid.js +78 -0
- package/clis/eastmoney/announcement.js +52 -0
- package/clis/eastmoney/convertible.js +73 -0
- package/clis/eastmoney/etf.js +65 -0
- package/clis/eastmoney/holders.js +78 -0
- package/clis/eastmoney/index-board.js +96 -0
- package/clis/eastmoney/kline.js +87 -0
- package/clis/eastmoney/kuaixun.js +54 -0
- package/clis/eastmoney/longhu.js +67 -0
- package/clis/eastmoney/money-flow.js +78 -0
- package/clis/eastmoney/northbound.js +57 -0
- package/clis/eastmoney/quote.js +107 -0
- package/clis/eastmoney/rank.js +94 -0
- package/clis/eastmoney/sectors.js +76 -0
- package/clis/google-scholar/search.js +58 -0
- package/clis/google-scholar/search.test.js +23 -0
- package/clis/gov-law/commands.test.js +39 -0
- package/clis/gov-law/recent.js +22 -0
- package/clis/gov-law/search.js +41 -0
- package/clis/gov-law/shared.js +51 -0
- package/clis/gov-policy/commands.test.js +27 -0
- package/clis/gov-policy/recent.js +47 -0
- package/clis/gov-policy/search.js +48 -0
- package/clis/jianyu/search.js +139 -3
- package/clis/jianyu/search.test.js +25 -0
- package/clis/jianyu/shared/procurement-detail.js +15 -0
- package/clis/jianyu/shared/procurement-detail.test.js +12 -0
- package/clis/nowcoder/companies.js +23 -0
- package/clis/nowcoder/creators.js +27 -0
- package/clis/nowcoder/detail.js +61 -0
- package/clis/nowcoder/experience.js +36 -0
- package/clis/nowcoder/hot.js +24 -0
- package/clis/nowcoder/jobs.js +21 -0
- package/clis/nowcoder/notifications.js +29 -0
- package/clis/nowcoder/papers.js +40 -0
- package/clis/nowcoder/practice.js +37 -0
- package/clis/nowcoder/recommend.js +30 -0
- package/clis/nowcoder/referral.js +39 -0
- package/clis/nowcoder/salary.js +40 -0
- package/clis/nowcoder/search.js +49 -0
- package/clis/nowcoder/suggest.js +33 -0
- package/clis/nowcoder/topics.js +27 -0
- package/clis/nowcoder/trending.js +25 -0
- package/clis/twitter/list-add.js +337 -0
- package/clis/twitter/list-add.test.js +15 -0
- package/clis/twitter/list-remove.js +297 -0
- package/clis/twitter/list-remove.test.js +14 -0
- package/clis/twitter/list-tweets.js +185 -0
- package/clis/twitter/list-tweets.test.js +108 -0
- package/clis/twitter/lists.js +134 -47
- package/clis/twitter/lists.test.js +105 -38
- package/clis/twitter/shared.js +7 -2
- package/clis/twitter/tweets.js +218 -0
- package/clis/twitter/tweets.test.js +125 -0
- package/clis/wanfang/search.js +66 -0
- package/clis/wanfang/search.test.js +23 -0
- package/clis/web/read.js +1 -1
- package/clis/weixin/download.js +3 -2
- package/clis/xiaohongshu/publish.js +149 -28
- package/clis/xiaohongshu/publish.test.js +319 -6
- package/clis/xiaoyuzhou/download.js +8 -4
- package/clis/xiaoyuzhou/download.test.js +23 -13
- package/clis/xiaoyuzhou/episode.js +9 -4
- package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
- package/clis/xiaoyuzhou/podcast.js +9 -4
- package/clis/xiaoyuzhou/utils.js +0 -40
- package/clis/xiaoyuzhou/utils.test.js +15 -75
- package/clis/youtube/channel.js +35 -0
- package/clis/zsxq/dynamics.js +1 -1
- package/clis/zsxq/utils.js +6 -3
- package/clis/zsxq/utils.test.js +31 -0
- package/dist/src/browser/base-page.d.ts +14 -4
- package/dist/src/browser/base-page.js +35 -25
- package/dist/src/browser/bridge.d.ts +1 -0
- package/dist/src/browser/bridge.js +1 -1
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/cdp.js +13 -4
- package/dist/src/browser/compound.d.ts +59 -0
- package/dist/src/browser/compound.js +112 -0
- package/dist/src/browser/compound.test.js +175 -0
- package/dist/src/browser/daemon-client.d.ts +6 -4
- package/dist/src/browser/daemon-client.js +6 -1
- package/dist/src/browser/daemon-client.test.js +40 -1
- package/dist/src/browser/dom-snapshot.d.ts +7 -0
- package/dist/src/browser/dom-snapshot.js +83 -5
- package/dist/src/browser/dom-snapshot.test.js +65 -0
- package/dist/src/browser/extract.d.ts +69 -0
- package/dist/src/browser/extract.js +132 -0
- package/dist/src/browser/extract.test.js +129 -0
- package/dist/src/browser/find.d.ts +76 -0
- package/dist/src/browser/find.js +179 -0
- package/dist/src/browser/find.test.js +120 -0
- package/dist/src/browser/html-tree.d.ts +75 -0
- package/dist/src/browser/html-tree.js +112 -0
- package/dist/src/browser/html-tree.test.d.ts +1 -0
- package/dist/src/browser/html-tree.test.js +181 -0
- package/dist/src/browser/network-cache.d.ts +48 -0
- package/dist/src/browser/network-cache.js +66 -0
- package/dist/src/browser/network-cache.test.d.ts +1 -0
- package/dist/src/browser/network-cache.test.js +58 -0
- package/dist/src/browser/network-key.d.ts +22 -0
- package/dist/src/browser/network-key.js +66 -0
- package/dist/src/browser/network-key.test.d.ts +1 -0
- package/dist/src/browser/network-key.test.js +49 -0
- package/dist/src/browser/page.d.ts +14 -4
- package/dist/src/browser/page.js +48 -7
- package/dist/src/browser/page.test.js +97 -0
- package/dist/src/browser/shape-filter.d.ts +52 -0
- package/dist/src/browser/shape-filter.js +101 -0
- package/dist/src/browser/shape-filter.test.d.ts +1 -0
- package/dist/src/browser/shape-filter.test.js +101 -0
- package/dist/src/browser/shape.d.ts +23 -0
- package/dist/src/browser/shape.js +95 -0
- package/dist/src/browser/shape.test.d.ts +1 -0
- package/dist/src/browser/shape.test.js +82 -0
- package/dist/src/browser/target-errors.d.ts +14 -1
- package/dist/src/browser/target-errors.js +13 -0
- package/dist/src/browser/target-errors.test.js +39 -6
- package/dist/src/browser/target-resolver.d.ts +57 -10
- package/dist/src/browser/target-resolver.js +195 -75
- package/dist/src/browser/target-resolver.test.js +80 -5
- package/dist/src/cli.js +849 -267
- package/dist/src/cli.test.js +961 -90
- package/dist/src/commanderAdapter.d.ts +0 -1
- package/dist/src/commanderAdapter.js +2 -16
- package/dist/src/commanderAdapter.test.js +1 -1
- package/dist/src/completion-shared.js +2 -5
- package/dist/src/daemon.js +8 -0
- package/dist/src/download/article-download.d.ts +1 -0
- package/dist/src/download/article-download.js +3 -0
- package/dist/src/download/article-download.test.d.ts +1 -0
- package/dist/src/download/article-download.test.js +39 -0
- package/dist/src/execution.js +7 -2
- package/dist/src/execution.test.js +54 -0
- package/dist/src/main.js +16 -0
- package/dist/src/plugin.d.ts +1 -8
- package/dist/src/plugin.js +1 -27
- package/dist/src/plugin.test.js +1 -59
- package/dist/src/registry.d.ts +1 -0
- package/dist/src/registry.js +3 -2
- package/dist/src/registry.test.js +22 -0
- package/dist/src/types.d.ts +32 -8
- package/package.json +1 -1
- package/clis/twitter/lists-parser.js +0 -77
- package/clis/twitter/lists.d.ts +0 -5
- package/dist/src/cascade.d.ts +0 -46
- package/dist/src/cascade.js +0 -135
- package/dist/src/explore.d.ts +0 -99
- package/dist/src/explore.js +0 -402
- package/dist/src/generate-verified.d.ts +0 -105
- package/dist/src/generate-verified.js +0 -696
- package/dist/src/generate-verified.test.js +0 -925
- package/dist/src/generate.d.ts +0 -46
- package/dist/src/generate.js +0 -117
- package/dist/src/record.d.ts +0 -96
- package/dist/src/record.js +0 -657
- package/dist/src/record.test.js +0 -293
- package/dist/src/skill-generate.d.ts +0 -30
- package/dist/src/skill-generate.js +0 -75
- package/dist/src/skill-generate.test.js +0 -173
- package/dist/src/synthesize.d.ts +0 -97
- package/dist/src/synthesize.js +0 -208
- /package/dist/src/{generate-verified.test.d.ts → browser/compound.test.d.ts} +0 -0
- /package/dist/src/{record.test.d.ts → browser/extract.test.d.ts} +0 -0
- /package/dist/src/{skill-generate.test.d.ts → browser/find.test.d.ts} +0 -0
package/dist/src/cli.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Dynamic adapter commands are registered via commanderAdapter.ts.
|
|
6
6
|
*/
|
|
7
7
|
import * as fs from 'node:fs';
|
|
8
|
+
import * as os from 'node:os';
|
|
8
9
|
import * as path from 'node:path';
|
|
9
10
|
import { fileURLToPath } from 'node:url';
|
|
10
11
|
import { Command } from 'commander';
|
|
@@ -13,7 +14,6 @@ import { findPackageRoot, getBuiltEntryCandidates } from './package-paths.js';
|
|
|
13
14
|
import { fullName, getRegistry, strategyLabel } from './registry.js';
|
|
14
15
|
import { serializeCommand, formatArgSummary } from './serialization.js';
|
|
15
16
|
import { render as renderOutput } from './output.js';
|
|
16
|
-
import { getBrowserFactory, browserSession } from './runtime.js';
|
|
17
17
|
import { PKG_VERSION } from './version.js';
|
|
18
18
|
import { printCompletionScript } from './completion.js';
|
|
19
19
|
import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js';
|
|
@@ -21,14 +21,188 @@ import { registerAllCommands } from './commanderAdapter.js';
|
|
|
21
21
|
import { EXIT_CODES, getErrorMessage, BrowserConnectError } from './errors.js';
|
|
22
22
|
import { TargetError } from './browser/target-errors.js';
|
|
23
23
|
import { resolveTargetJs, getTextResolvedJs, getValueResolvedJs, getAttributesResolvedJs, selectResolvedJs, isAutocompleteResolvedJs } from './browser/target-resolver.js';
|
|
24
|
+
import { buildFindJs, isFindError } from './browser/find.js';
|
|
25
|
+
import { inferShape } from './browser/shape.js';
|
|
26
|
+
import { assignKeys } from './browser/network-key.js';
|
|
27
|
+
import { DEFAULT_TTL_MS, findEntry, loadNetworkCache, saveNetworkCache } from './browser/network-cache.js';
|
|
28
|
+
import { parseFilter, shapeMatchesFilter } from './browser/shape-filter.js';
|
|
29
|
+
import { buildHtmlTreeJs } from './browser/html-tree.js';
|
|
30
|
+
import { buildExtractHtmlJs, runExtractFromHtml } from './browser/extract.js';
|
|
24
31
|
import { daemonStatus, daemonStop } from './commands/daemon.js';
|
|
25
32
|
import { log } from './logger.js';
|
|
26
33
|
const CLI_FILE = fileURLToPath(import.meta.url);
|
|
34
|
+
const DEFAULT_BROWSER_WORKSPACE = 'browser:default';
|
|
35
|
+
const BROWSER_TAB_OPTION_DESCRIPTION = 'Target tab/page identity returned by "browser open", "browser tab new", or "browser tab list"';
|
|
36
|
+
/**
|
|
37
|
+
* Normalize raw capture entries (from daemon/CDP `readNetworkCapture` or
|
|
38
|
+
* the JS interceptor's `window.__opencli_net`) into a consistent shape.
|
|
39
|
+
* Response preview is parsed as JSON when possible, otherwise kept as string.
|
|
40
|
+
* `bodyFullSize` / `bodyTruncated` surface capture-layer truncation so the
|
|
41
|
+
* agent-facing envelope can warn when the body isn't whole.
|
|
42
|
+
*/
|
|
43
|
+
async function captureNetworkItems(page) {
|
|
44
|
+
if (page.readNetworkCapture) {
|
|
45
|
+
const raw = await page.readNetworkCapture();
|
|
46
|
+
return raw.map((e) => {
|
|
47
|
+
const preview = e.responsePreview ?? null;
|
|
48
|
+
let body = null;
|
|
49
|
+
if (preview) {
|
|
50
|
+
try {
|
|
51
|
+
body = JSON.parse(preview);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
body = preview;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const fullSize = typeof e.responseBodyFullSize === 'number'
|
|
58
|
+
? e.responseBodyFullSize
|
|
59
|
+
: (preview ? preview.length : 0);
|
|
60
|
+
const truncated = e.responseBodyTruncated === true;
|
|
61
|
+
return {
|
|
62
|
+
url: e.url || '',
|
|
63
|
+
method: e.method || 'GET',
|
|
64
|
+
status: e.responseStatus || 0,
|
|
65
|
+
size: fullSize,
|
|
66
|
+
ct: e.responseContentType || '',
|
|
67
|
+
body,
|
|
68
|
+
bodyFullSize: fullSize,
|
|
69
|
+
bodyTruncated: truncated,
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const raw = await page.evaluate(`(function(){ return JSON.stringify(window.__opencli_net || []); })()`);
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(raw);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Drop static-resource / telemetry noise so agents see only API-shaped traffic. */
|
|
82
|
+
function filterNetworkItems(items) {
|
|
83
|
+
return items.filter((r) => (r.ct?.includes('json') || r.ct?.includes('xml') || r.ct?.includes('text/plain')) &&
|
|
84
|
+
!/\.(js|css|png|jpg|gif|svg|woff|ico|map)(\?|$)/i.test(r.url) &&
|
|
85
|
+
!/analytics|tracking|telemetry|beacon|pixel|gtag|fbevents/i.test(r.url));
|
|
86
|
+
}
|
|
87
|
+
/** Emit a structured error JSON so agents can branch on `error.code` without regex. */
|
|
88
|
+
function emitNetworkError(code, message, extra = {}) {
|
|
89
|
+
console.log(JSON.stringify({ error: { code, message, ...extra } }, null, 2));
|
|
90
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
91
|
+
}
|
|
92
|
+
function getBrowserCacheDir() {
|
|
93
|
+
return process.env.OPENCLI_CACHE_DIR || path.join(os.homedir(), '.opencli', 'cache');
|
|
94
|
+
}
|
|
95
|
+
function getBrowserTargetStatePath(scope = DEFAULT_BROWSER_WORKSPACE) {
|
|
96
|
+
const safeWorkspace = scope.replace(/[^a-zA-Z0-9_-]+/g, '_');
|
|
97
|
+
return path.join(getBrowserCacheDir(), 'browser-state', `${safeWorkspace}.json`);
|
|
98
|
+
}
|
|
99
|
+
function loadBrowserTargetState(scope = DEFAULT_BROWSER_WORKSPACE) {
|
|
100
|
+
try {
|
|
101
|
+
const raw = fs.readFileSync(getBrowserTargetStatePath(scope), 'utf-8');
|
|
102
|
+
const parsed = JSON.parse(raw);
|
|
103
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function saveBrowserTargetState(defaultPage, scope = DEFAULT_BROWSER_WORKSPACE) {
|
|
110
|
+
const target = getBrowserTargetStatePath(scope);
|
|
111
|
+
if (!defaultPage) {
|
|
112
|
+
fs.rmSync(target, { force: true });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
116
|
+
fs.writeFileSync(target, JSON.stringify({ defaultPage, updatedAt: new Date().toISOString() }), 'utf-8');
|
|
117
|
+
}
|
|
118
|
+
function hasBrowserTabTarget(tabs, targetPage) {
|
|
119
|
+
return tabs.some((tab) => {
|
|
120
|
+
return typeof tab === 'object'
|
|
121
|
+
&& tab !== null
|
|
122
|
+
&& 'page' in tab
|
|
123
|
+
&& typeof tab.page === 'string'
|
|
124
|
+
&& tab.page === targetPage;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
async function resolveBrowserTargetInSession(page, targetPage, opts) {
|
|
128
|
+
const candidate = targetPage.trim();
|
|
129
|
+
if (!candidate)
|
|
130
|
+
return undefined;
|
|
131
|
+
let tabs;
|
|
132
|
+
try {
|
|
133
|
+
tabs = await page.tabs();
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
if (opts.source === 'saved') {
|
|
137
|
+
saveBrowserTargetState(undefined, opts.scope);
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
throw new Error(`Target tab ${candidate} could not be validated in the current browser session. ` +
|
|
141
|
+
'The Browser Bridge workspace may have restarted; re-run "opencli browser tab list" and choose a current target.', { cause: err });
|
|
142
|
+
}
|
|
143
|
+
if (Array.isArray(tabs) && hasBrowserTabTarget(tabs, candidate)) {
|
|
144
|
+
return candidate;
|
|
145
|
+
}
|
|
146
|
+
if (opts.source === 'saved') {
|
|
147
|
+
saveBrowserTargetState(undefined, opts.scope);
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
throw new Error(`Target tab ${candidate} is not part of the current browser session. ` +
|
|
151
|
+
'The Browser Bridge workspace may have restarted; re-run "opencli browser tab list" and choose a current target.');
|
|
152
|
+
}
|
|
153
|
+
async function resolveStoredBrowserTarget(page, scope = DEFAULT_BROWSER_WORKSPACE) {
|
|
154
|
+
const defaultPage = loadBrowserTargetState(scope)?.defaultPage?.trim();
|
|
155
|
+
if (!defaultPage)
|
|
156
|
+
return undefined;
|
|
157
|
+
return resolveBrowserTargetInSession(page, defaultPage, { scope, source: 'saved' });
|
|
158
|
+
}
|
|
27
159
|
/** Create a browser page for browser commands. Uses a dedicated browser workspace for session persistence. */
|
|
28
|
-
async function getBrowserPage() {
|
|
160
|
+
async function getBrowserPage(targetPage) {
|
|
29
161
|
const { BrowserBridge } = await import('./browser/index.js');
|
|
30
162
|
const bridge = new BrowserBridge();
|
|
31
|
-
|
|
163
|
+
const envTimeout = process.env.OPENCLI_BROWSER_TIMEOUT;
|
|
164
|
+
const idleTimeout = envTimeout ? parseInt(envTimeout, 10) : undefined;
|
|
165
|
+
const page = await bridge.connect({
|
|
166
|
+
timeout: 30,
|
|
167
|
+
workspace: DEFAULT_BROWSER_WORKSPACE,
|
|
168
|
+
...(idleTimeout && idleTimeout > 0 && { idleTimeout }),
|
|
169
|
+
});
|
|
170
|
+
const resolvedTargetPage = targetPage
|
|
171
|
+
? await resolveBrowserTargetInSession(page, targetPage, { scope: DEFAULT_BROWSER_WORKSPACE, source: 'explicit' })
|
|
172
|
+
: await resolveStoredBrowserTarget(page, DEFAULT_BROWSER_WORKSPACE);
|
|
173
|
+
if (resolvedTargetPage) {
|
|
174
|
+
if (!page.setActivePage) {
|
|
175
|
+
throw new Error('This browser session does not support explicit tab targeting');
|
|
176
|
+
}
|
|
177
|
+
page.setActivePage(resolvedTargetPage);
|
|
178
|
+
}
|
|
179
|
+
return page;
|
|
180
|
+
}
|
|
181
|
+
function addBrowserTabOption(command) {
|
|
182
|
+
return command.option('--tab <targetId>', BROWSER_TAB_OPTION_DESCRIPTION);
|
|
183
|
+
}
|
|
184
|
+
function getBrowserTargetId(command) {
|
|
185
|
+
if (!command)
|
|
186
|
+
return undefined;
|
|
187
|
+
const opts = command.optsWithGlobals ? command.optsWithGlobals() : command.opts();
|
|
188
|
+
return typeof opts.tab === 'string' && opts.tab.trim() ? opts.tab.trim() : undefined;
|
|
189
|
+
}
|
|
190
|
+
function resolveBrowserTabTarget(targetId, opts) {
|
|
191
|
+
if (typeof targetId === 'string' && targetId.trim())
|
|
192
|
+
return targetId.trim();
|
|
193
|
+
if (typeof opts?.tab === 'string' && opts.tab.trim())
|
|
194
|
+
return opts.tab.trim();
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
function parsePositiveIntOption(val, label, fallback) {
|
|
198
|
+
if (val === undefined)
|
|
199
|
+
return fallback;
|
|
200
|
+
const parsed = parseInt(val, 10);
|
|
201
|
+
if (Number.isNaN(parsed) || parsed <= 0) {
|
|
202
|
+
console.error(`[cli] Invalid ${label}="${val}", using default ${fallback}`);
|
|
203
|
+
return fallback;
|
|
204
|
+
}
|
|
205
|
+
return parsed;
|
|
32
206
|
}
|
|
33
207
|
function applyVerbose(opts) {
|
|
34
208
|
if (opts.verbose)
|
|
@@ -131,119 +305,6 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
131
305
|
console.log(renderVerifyReport(r));
|
|
132
306
|
process.exitCode = r.ok ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR;
|
|
133
307
|
});
|
|
134
|
-
// ── Built-in: explore / synthesize / generate / cascade ───────────────────
|
|
135
|
-
program
|
|
136
|
-
.command('explore')
|
|
137
|
-
.alias('probe')
|
|
138
|
-
.description('Explore a website: discover APIs, stores, and recommend strategies')
|
|
139
|
-
.argument('<url>')
|
|
140
|
-
.option('--site <name>')
|
|
141
|
-
.option('--goal <text>')
|
|
142
|
-
.option('--wait <s>', '', '3')
|
|
143
|
-
.option('--auto', 'Enable interactive fuzzing')
|
|
144
|
-
.option('--click <labels>', 'Comma-separated labels to click before fuzzing')
|
|
145
|
-
.option('-v, --verbose', 'Debug output')
|
|
146
|
-
.action(async (url, opts) => {
|
|
147
|
-
applyVerbose(opts);
|
|
148
|
-
const { exploreUrl, renderExploreSummary } = await import('./explore.js');
|
|
149
|
-
const clickLabels = opts.click
|
|
150
|
-
? opts.click.split(',').map((s) => s.trim())
|
|
151
|
-
: undefined;
|
|
152
|
-
const workspace = `explore:${inferHost(url, opts.site)}`;
|
|
153
|
-
const result = await exploreUrl(url, {
|
|
154
|
-
BrowserFactory: getBrowserFactory(),
|
|
155
|
-
site: opts.site,
|
|
156
|
-
goal: opts.goal,
|
|
157
|
-
waitSeconds: parseFloat(opts.wait),
|
|
158
|
-
auto: opts.auto,
|
|
159
|
-
clickLabels,
|
|
160
|
-
workspace,
|
|
161
|
-
});
|
|
162
|
-
console.log(renderExploreSummary(result));
|
|
163
|
-
});
|
|
164
|
-
program
|
|
165
|
-
.command('synthesize')
|
|
166
|
-
.description('Synthesize CLIs from explore')
|
|
167
|
-
.argument('<target>')
|
|
168
|
-
.option('--top <n>', '', '3')
|
|
169
|
-
.option('-v, --verbose', 'Debug output')
|
|
170
|
-
.action(async (target, opts) => {
|
|
171
|
-
applyVerbose(opts);
|
|
172
|
-
const { synthesizeFromExplore, renderSynthesizeSummary } = await import('./synthesize.js');
|
|
173
|
-
console.log(renderSynthesizeSummary(synthesizeFromExplore(target, { top: parseInt(opts.top) })));
|
|
174
|
-
});
|
|
175
|
-
program
|
|
176
|
-
.command('generate')
|
|
177
|
-
.description('One-shot: explore → synthesize → verify → register')
|
|
178
|
-
.argument('<url>')
|
|
179
|
-
.option('--goal <text>')
|
|
180
|
-
.option('--site <name>')
|
|
181
|
-
.option('--format <fmt>', 'Output format: table, json', 'table')
|
|
182
|
-
.option('--no-register', 'Verify the generated adapter without registering it')
|
|
183
|
-
.option('-v, --verbose', 'Debug output')
|
|
184
|
-
.action(async (url, opts) => {
|
|
185
|
-
applyVerbose(opts);
|
|
186
|
-
const { generateVerifiedFromUrl, renderGenerateVerifiedSummary } = await import('./generate-verified.js');
|
|
187
|
-
const workspace = `generate:${inferHost(url, opts.site)}`;
|
|
188
|
-
const r = await generateVerifiedFromUrl({
|
|
189
|
-
url,
|
|
190
|
-
BrowserFactory: getBrowserFactory(),
|
|
191
|
-
goal: opts.goal,
|
|
192
|
-
site: opts.site,
|
|
193
|
-
workspace,
|
|
194
|
-
noRegister: opts.register === false,
|
|
195
|
-
});
|
|
196
|
-
if (opts.format === 'json')
|
|
197
|
-
console.log(JSON.stringify(r, null, 2));
|
|
198
|
-
else
|
|
199
|
-
console.log(renderGenerateVerifiedSummary(r));
|
|
200
|
-
process.exitCode = r.status === 'success' ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR;
|
|
201
|
-
});
|
|
202
|
-
// ── Built-in: record ─────────────────────────────────────────────────────
|
|
203
|
-
program
|
|
204
|
-
.command('record')
|
|
205
|
-
.description('Record API calls from a live browser session → generate YAML candidates')
|
|
206
|
-
.argument('<url>', 'URL to open and record')
|
|
207
|
-
.option('--site <name>', 'Site name (inferred from URL if omitted)')
|
|
208
|
-
.option('--out <dir>', 'Output directory for candidates')
|
|
209
|
-
.option('--poll <ms>', 'Poll interval in milliseconds', '2000')
|
|
210
|
-
.option('--timeout <ms>', 'Auto-stop after N milliseconds (default: 60000)', '60000')
|
|
211
|
-
.option('-v, --verbose', 'Debug output')
|
|
212
|
-
.action(async (url, opts) => {
|
|
213
|
-
applyVerbose(opts);
|
|
214
|
-
const { recordSession, renderRecordSummary } = await import('./record.js');
|
|
215
|
-
const result = await recordSession({
|
|
216
|
-
BrowserFactory: getBrowserFactory(),
|
|
217
|
-
url,
|
|
218
|
-
site: opts.site,
|
|
219
|
-
outDir: opts.out,
|
|
220
|
-
pollMs: parseInt(opts.poll, 10),
|
|
221
|
-
timeoutMs: parseInt(opts.timeout, 10),
|
|
222
|
-
});
|
|
223
|
-
console.log(renderRecordSummary(result));
|
|
224
|
-
process.exitCode = result.candidateCount > 0 ? EXIT_CODES.SUCCESS : EXIT_CODES.EMPTY_RESULT;
|
|
225
|
-
});
|
|
226
|
-
program
|
|
227
|
-
.command('cascade')
|
|
228
|
-
.description('Strategy cascade: find simplest working strategy')
|
|
229
|
-
.argument('<url>')
|
|
230
|
-
.option('--site <name>')
|
|
231
|
-
.option('-v, --verbose', 'Debug output')
|
|
232
|
-
.action(async (url, opts) => {
|
|
233
|
-
applyVerbose(opts);
|
|
234
|
-
const { cascadeProbe, renderCascadeResult } = await import('./cascade.js');
|
|
235
|
-
const workspace = `cascade:${inferHost(url, opts.site)}`;
|
|
236
|
-
const result = await browserSession(getBrowserFactory(), async (page) => {
|
|
237
|
-
try {
|
|
238
|
-
const siteUrl = new URL(url);
|
|
239
|
-
await page.goto(`${siteUrl.protocol}//${siteUrl.host}`);
|
|
240
|
-
await page.wait(2);
|
|
241
|
-
}
|
|
242
|
-
catch { }
|
|
243
|
-
return cascadeProbe(page, url);
|
|
244
|
-
}, { workspace });
|
|
245
|
-
console.log(renderCascadeResult(result));
|
|
246
|
-
});
|
|
247
308
|
// ── Built-in: browser (browser control for Claude Code skill) ───────────────
|
|
248
309
|
//
|
|
249
310
|
// Make websites accessible for AI agents.
|
|
@@ -251,18 +312,58 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
251
312
|
const browser = program
|
|
252
313
|
.command('browser')
|
|
253
314
|
.description('Browser control — navigate, click, type, extract, wait (no LLM needed)');
|
|
254
|
-
/**
|
|
255
|
-
|
|
256
|
-
|
|
315
|
+
/**
|
|
316
|
+
* Resolve a `<target>` (numeric ref or CSS selector) via the unified resolver.
|
|
317
|
+
* Returns the CSS match count so callers can propagate `matches_n` into the
|
|
318
|
+
* JSON envelope printed back to the agent.
|
|
319
|
+
*/
|
|
320
|
+
async function resolveRef(page, ref, opts = {}) {
|
|
321
|
+
const resolution = await page.evaluate(resolveTargetJs(ref, opts));
|
|
257
322
|
if (!resolution.ok) {
|
|
258
|
-
throw new TargetError(
|
|
323
|
+
throw new TargetError({
|
|
324
|
+
code: resolution.code,
|
|
325
|
+
message: resolution.message,
|
|
326
|
+
hint: resolution.hint,
|
|
327
|
+
candidates: resolution.candidates,
|
|
328
|
+
matches_n: resolution.matches_n,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
return { matches_n: resolution.matches_n, match_level: resolution.match_level };
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Parse `--nth <n>` flag, returning the parsed 0-based index or a usage error.
|
|
335
|
+
* The surface mirrors `--depth` etc. in `browser get html --as json`: the flag
|
|
336
|
+
* is optional, must be a non-negative integer when present, and on failure we
|
|
337
|
+
* emit the structured error envelope rather than throwing past the command.
|
|
338
|
+
*/
|
|
339
|
+
function parseNthFlag(raw) {
|
|
340
|
+
if (raw === undefined || raw === null || raw === '')
|
|
341
|
+
return null;
|
|
342
|
+
const str = String(raw);
|
|
343
|
+
if (!/^\d+$/.test(str)) {
|
|
344
|
+
return { error: `--nth must be a non-negative integer, got "${str}"` };
|
|
259
345
|
}
|
|
346
|
+
return Number.parseInt(str, 10);
|
|
347
|
+
}
|
|
348
|
+
/** Emit the `{ error: { code, message, hint?, candidates?, matches_n? } }` envelope used by the selector-first commands. */
|
|
349
|
+
function emitTargetError(err) {
|
|
350
|
+
console.log(JSON.stringify({
|
|
351
|
+
error: {
|
|
352
|
+
code: err.code,
|
|
353
|
+
message: err.message,
|
|
354
|
+
hint: err.hint,
|
|
355
|
+
...(err.candidates && { candidates: err.candidates }),
|
|
356
|
+
...(err.matches_n !== undefined && { matches_n: err.matches_n }),
|
|
357
|
+
},
|
|
358
|
+
}, null, 2));
|
|
260
359
|
}
|
|
261
360
|
/** Wrap browser actions with error handling and optional --json output */
|
|
262
361
|
function browserAction(fn) {
|
|
263
362
|
return async (...args) => {
|
|
264
363
|
try {
|
|
265
|
-
const
|
|
364
|
+
const command = args.at(-1) instanceof Command ? args.at(-1) : undefined;
|
|
365
|
+
const targetPage = getBrowserTargetId(command);
|
|
366
|
+
const page = await getBrowserPage(targetPage);
|
|
266
367
|
await fn(page, ...args);
|
|
267
368
|
}
|
|
268
369
|
catch (err) {
|
|
@@ -272,13 +373,11 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
272
373
|
log.error(`Hint: ${err.hint}`);
|
|
273
374
|
}
|
|
274
375
|
else if (err instanceof TargetError) {
|
|
376
|
+
// Agent-facing structured envelope on stdout + short human line on stderr.
|
|
377
|
+
emitTargetError(err);
|
|
275
378
|
log.error(`[${err.code}] ${err.message}`);
|
|
276
379
|
if (err.hint)
|
|
277
380
|
log.error(`Hint: ${err.hint}`);
|
|
278
|
-
if (err.candidates?.length) {
|
|
279
|
-
log.error('Candidates:');
|
|
280
|
-
err.candidates.forEach((c, i) => log.error(` ${i + 1}. ${c}`));
|
|
281
|
-
}
|
|
282
381
|
}
|
|
283
382
|
else {
|
|
284
383
|
const msg = getErrorMessage(err);
|
|
@@ -293,10 +392,77 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
293
392
|
}
|
|
294
393
|
};
|
|
295
394
|
}
|
|
395
|
+
const browserTab = browser
|
|
396
|
+
.command('tab')
|
|
397
|
+
.description('Tab management — list, create, and close tabs in the automation window');
|
|
398
|
+
browserTab.command('list')
|
|
399
|
+
.description('List tabs in the automation window with target IDs')
|
|
400
|
+
.action(browserAction(async (page) => {
|
|
401
|
+
const tabs = await page.tabs();
|
|
402
|
+
console.log(JSON.stringify(tabs, null, 2));
|
|
403
|
+
}));
|
|
404
|
+
browserTab.command('new')
|
|
405
|
+
.argument('[url]', 'Optional URL to open in the new tab')
|
|
406
|
+
.description('Create a new tab and print its target ID')
|
|
407
|
+
.action(browserAction(async (page, url) => {
|
|
408
|
+
if (!page.newTab) {
|
|
409
|
+
throw new Error('This browser session does not support creating tabs');
|
|
410
|
+
}
|
|
411
|
+
const createdPage = await page.newTab(url);
|
|
412
|
+
console.log(JSON.stringify({
|
|
413
|
+
page: createdPage,
|
|
414
|
+
url: url ?? null,
|
|
415
|
+
}, null, 2));
|
|
416
|
+
}));
|
|
417
|
+
addBrowserTabOption(browserTab.command('select')
|
|
418
|
+
.argument('[targetId]', 'Target tab/page identity returned by "browser open", "browser tab new", or "browser tab list"')
|
|
419
|
+
.description('Select a tab by target ID and make it the default browser tab'))
|
|
420
|
+
.action(browserAction(async (page, targetId, opts) => {
|
|
421
|
+
const resolvedTarget = resolveBrowserTabTarget(targetId, opts);
|
|
422
|
+
if (!resolvedTarget) {
|
|
423
|
+
throw new Error('Target tab required. Pass it as an argument or --tab <targetId>.');
|
|
424
|
+
}
|
|
425
|
+
await page.selectTab(resolvedTarget);
|
|
426
|
+
saveBrowserTargetState(resolvedTarget, DEFAULT_BROWSER_WORKSPACE);
|
|
427
|
+
console.log(JSON.stringify({ selected: resolvedTarget }, null, 2));
|
|
428
|
+
}));
|
|
429
|
+
addBrowserTabOption(browserTab.command('close')
|
|
430
|
+
.argument('[targetId]', 'Target tab/page identity returned by "browser open", "browser tab new", or "browser tab list"')
|
|
431
|
+
.description('Close a tab by target ID'))
|
|
432
|
+
.action(browserAction(async (page, targetId, opts) => {
|
|
433
|
+
const resolvedTarget = resolveBrowserTabTarget(targetId, opts);
|
|
434
|
+
if (!page.closeTab) {
|
|
435
|
+
throw new Error('This browser session does not support closing tabs');
|
|
436
|
+
}
|
|
437
|
+
if (!resolvedTarget) {
|
|
438
|
+
throw new Error('Target tab required. Pass it as an argument or --tab <targetId>.');
|
|
439
|
+
}
|
|
440
|
+
const validatedTarget = await resolveBrowserTargetInSession(page, resolvedTarget, {
|
|
441
|
+
scope: DEFAULT_BROWSER_WORKSPACE,
|
|
442
|
+
source: 'explicit',
|
|
443
|
+
});
|
|
444
|
+
if (!validatedTarget) {
|
|
445
|
+
throw new Error(`Target tab ${resolvedTarget} is not part of the current browser session.`);
|
|
446
|
+
}
|
|
447
|
+
await page.closeTab(validatedTarget);
|
|
448
|
+
if (loadBrowserTargetState(DEFAULT_BROWSER_WORKSPACE)?.defaultPage === validatedTarget) {
|
|
449
|
+
saveBrowserTargetState(undefined, DEFAULT_BROWSER_WORKSPACE);
|
|
450
|
+
}
|
|
451
|
+
console.log(JSON.stringify({ closed: validatedTarget }, null, 2));
|
|
452
|
+
}));
|
|
296
453
|
// ── Navigation ──
|
|
297
|
-
/**
|
|
298
|
-
|
|
299
|
-
|
|
454
|
+
/**
|
|
455
|
+
* Network interceptor JS — injected on every open/navigate to capture
|
|
456
|
+
* fetch/XHR bodies when the session-level capture channel (CDP/extension)
|
|
457
|
+
* isn't available. Keeps parity with the CDP path's truncation contract:
|
|
458
|
+
* when a body exceeds the per-entry cap, we keep a string prefix and set
|
|
459
|
+
* `bodyTruncated: true` + `bodyFullSize: <original length>` so `browser
|
|
460
|
+
* network` can propagate a visible signal to the agent instead of
|
|
461
|
+
* silently dropping the body. Per-entry cap is 1 MiB and the ring is
|
|
462
|
+
* capped at 200 entries, bounding worst-case in-page memory.
|
|
463
|
+
*/
|
|
464
|
+
const NETWORK_INTERCEPTOR_JS = `(function(){if(window.__opencli_net)return;window.__opencli_net=[];var M=200,B=1048576,F=window.fetch;function capture(url,method,status,text,ct){if(window.__opencli_net.length>=M)return;var full=text?text.length:0,trunc=full>B,stored=trunc?text.slice(0,B):text,body=null;if(stored){if(trunc){body=stored}else{try{body=JSON.parse(stored)}catch(e){body=stored}}}var e={url:url,method:method||'GET',status:status,size:full,ct:ct,body:body};if(trunc){e.bodyTruncated=true;e.bodyFullSize=full}window.__opencli_net.push(e)}window.fetch=async function(){var r=await F.apply(this,arguments);try{var ct=r.headers.get('content-type')||'';if(ct.includes('json')||ct.includes('text')){var c=r.clone(),t=await c.text();capture(r.url||(arguments[0]&&arguments[0].url)||String(arguments[0]),(arguments[1]&&arguments[1].method)||'GET',r.status,t,ct)}}catch(e){}return r};var X=XMLHttpRequest.prototype,O=X.open,S=X.send;X.open=function(m,u){this._om=m;this._ou=u;return O.apply(this,arguments)};X.send=function(){var x=this;x.addEventListener('load',function(){try{var ct=x.getResponseHeader('content-type')||'';if(ct.includes('json')||ct.includes('text')){capture(x._ou,x._om||'GET',x.status,x.responseText||'',ct)}}catch(e){}});return S.apply(this,arguments)}})()`;
|
|
465
|
+
addBrowserTabOption(browser.command('open').argument('<url>').description('Open URL in automation window'))
|
|
300
466
|
.action(browserAction(async (page, url) => {
|
|
301
467
|
// Start session-level capture before navigation (catches initial requests)
|
|
302
468
|
const hasSessionCapture = await page.startNetworkCapture?.() ?? false;
|
|
@@ -309,15 +475,18 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
309
475
|
}
|
|
310
476
|
catch { /* non-fatal */ }
|
|
311
477
|
}
|
|
312
|
-
console.log(
|
|
478
|
+
console.log(JSON.stringify({
|
|
479
|
+
url: await page.getCurrentUrl?.() ?? url,
|
|
480
|
+
...(page.getActivePage?.() ? { page: page.getActivePage?.() } : {}),
|
|
481
|
+
}, null, 2));
|
|
313
482
|
}));
|
|
314
|
-
browser.command('back').description('Go back in browser history')
|
|
483
|
+
addBrowserTabOption(browser.command('back').description('Go back in browser history'))
|
|
315
484
|
.action(browserAction(async (page) => {
|
|
316
485
|
await page.evaluate('history.back()');
|
|
317
486
|
await page.wait(2);
|
|
318
487
|
console.log('Navigated back');
|
|
319
488
|
}));
|
|
320
|
-
browser.command('scroll').argument('<direction>', 'up or down').option('--amount <pixels>', 'Pixels to scroll', '500')
|
|
489
|
+
addBrowserTabOption(browser.command('scroll').argument('<direction>', 'up or down').option('--amount <pixels>', 'Pixels to scroll', '500'))
|
|
321
490
|
.description('Scroll page')
|
|
322
491
|
.action(browserAction(async (page, direction, opts) => {
|
|
323
492
|
if (direction !== 'up' && direction !== 'down') {
|
|
@@ -329,14 +498,19 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
329
498
|
console.log(`Scrolled ${direction}`);
|
|
330
499
|
}));
|
|
331
500
|
// ── Inspect ──
|
|
332
|
-
browser.command('state').description('Page state: URL, title, interactive elements with [N] indices')
|
|
501
|
+
addBrowserTabOption(browser.command('state').description('Page state: URL, title, interactive elements with [N] indices'))
|
|
333
502
|
.action(browserAction(async (page) => {
|
|
334
503
|
const snapshot = await page.snapshot({ viewportExpand: 2000 });
|
|
335
504
|
const url = await page.getCurrentUrl?.() ?? '';
|
|
336
505
|
console.log(`URL: ${url}\n`);
|
|
337
506
|
console.log(typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot, null, 2));
|
|
338
507
|
}));
|
|
339
|
-
browser.command('
|
|
508
|
+
addBrowserTabOption(browser.command('frames').description('List cross-origin iframe targets in snapshot order'))
|
|
509
|
+
.action(browserAction(async (page) => {
|
|
510
|
+
const frames = await page.frames?.() ?? [];
|
|
511
|
+
console.log(JSON.stringify(frames, null, 2));
|
|
512
|
+
}));
|
|
513
|
+
addBrowserTabOption(browser.command('screenshot').argument('[path]', 'Save to file (base64 if omitted)'))
|
|
340
514
|
.description('Take screenshot')
|
|
341
515
|
.action(browserAction(async (page, path) => {
|
|
342
516
|
if (path) {
|
|
@@ -347,84 +521,335 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
347
521
|
console.log(await page.screenshot({ format: 'png' }));
|
|
348
522
|
}
|
|
349
523
|
}));
|
|
524
|
+
// ── Find (structured CSS query, agent-native) ──
|
|
525
|
+
//
|
|
526
|
+
// `browser find --css <sel>` lets agents jump straight from a semantic
|
|
527
|
+
// selector to a JSON list of matching elements, without having to parse
|
|
528
|
+
// the free-text state snapshot to recover indices.
|
|
529
|
+
addBrowserTabOption(browser.command('find')
|
|
530
|
+
.option('--css <selector>', 'CSS selector (required)')
|
|
531
|
+
.option('--limit <n>', 'Max entries returned', '50')
|
|
532
|
+
.option('--text-max <n>', 'Max chars of trimmed text per entry', '120')
|
|
533
|
+
.description('Find DOM elements by CSS selector — returns JSON {matches_n, entries[]}'))
|
|
534
|
+
.action(browserAction(async (page, opts) => {
|
|
535
|
+
if (!opts.css || typeof opts.css !== 'string') {
|
|
536
|
+
console.log(JSON.stringify({
|
|
537
|
+
error: {
|
|
538
|
+
code: 'usage_error',
|
|
539
|
+
message: '--css <selector> is required',
|
|
540
|
+
hint: 'Example: opencli browser find --css ".btn.primary"',
|
|
541
|
+
},
|
|
542
|
+
}, null, 2));
|
|
543
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const limit = parseNthFlag(opts.limit);
|
|
547
|
+
if (limit && typeof limit === 'object' && 'error' in limit) {
|
|
548
|
+
console.log(JSON.stringify({ error: { code: 'usage_error', message: limit.error.replace('--nth', '--limit') } }, null, 2));
|
|
549
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
const textMax = parseNthFlag(opts.textMax);
|
|
553
|
+
if (textMax && typeof textMax === 'object' && 'error' in textMax) {
|
|
554
|
+
console.log(JSON.stringify({ error: { code: 'usage_error', message: textMax.error.replace('--nth', '--text-max') } }, null, 2));
|
|
555
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const result = await page.evaluate(buildFindJs(opts.css, {
|
|
559
|
+
limit: limit ?? undefined,
|
|
560
|
+
textMax: textMax ?? undefined,
|
|
561
|
+
}));
|
|
562
|
+
if (isFindError(result)) {
|
|
563
|
+
console.log(JSON.stringify(result, null, 2));
|
|
564
|
+
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
console.log(JSON.stringify(result, null, 2));
|
|
568
|
+
}));
|
|
350
569
|
// ── Get commands (structured data extraction) ──
|
|
351
570
|
const get = browser.command('get').description('Get page properties');
|
|
352
|
-
get.command('title').description('Page title')
|
|
571
|
+
addBrowserTabOption(get.command('title').description('Page title'))
|
|
353
572
|
.action(browserAction(async (page) => {
|
|
354
573
|
console.log(await page.evaluate('document.title'));
|
|
355
574
|
}));
|
|
356
|
-
get.command('url').description('Current page URL')
|
|
575
|
+
addBrowserTabOption(get.command('url').description('Current page URL'))
|
|
357
576
|
.action(browserAction(async (page) => {
|
|
358
577
|
console.log(await page.getCurrentUrl?.() ?? await page.evaluate('location.href'));
|
|
359
578
|
}));
|
|
360
|
-
get
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
579
|
+
// Read commands (`get text/value/attributes`) always emit a JSON envelope:
|
|
580
|
+
//
|
|
581
|
+
// { value, matches_n } — success
|
|
582
|
+
// { error: { code, message, hint, matches_n? } } — structured failure
|
|
583
|
+
//
|
|
584
|
+
// `<target>` accepts either a numeric ref (from `browser state`/`browser find`)
|
|
585
|
+
// or a CSS selector. On multi-match CSS, the first element wins and the real
|
|
586
|
+
// match count is exposed via `matches_n`; `--nth <n>` picks a specific one.
|
|
587
|
+
const runGetCommand = async (page, target, opts, evalJs, field) => {
|
|
588
|
+
const nth = parseNthFlag(opts.nth);
|
|
589
|
+
if (nth && typeof nth === 'object' && 'error' in nth) {
|
|
590
|
+
console.log(JSON.stringify({ error: { code: 'usage_error', message: nth.error } }, null, 2));
|
|
591
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const { matches_n, match_level } = await resolveRef(page, String(target), {
|
|
595
|
+
firstOnMulti: nth === null,
|
|
596
|
+
...(typeof nth === 'number' ? { nth } : {}),
|
|
597
|
+
});
|
|
598
|
+
const raw = await page.evaluate(evalJs);
|
|
599
|
+
let value;
|
|
600
|
+
if (field === 'attributes') {
|
|
601
|
+
// getAttributesResolvedJs stringifies the attribute record — parse it back so
|
|
602
|
+
// the JSON envelope contains a real object rather than a nested JSON string.
|
|
603
|
+
try {
|
|
604
|
+
value = raw == null ? {} : JSON.parse(String(raw));
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
value = raw;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
value = raw ?? null;
|
|
612
|
+
}
|
|
613
|
+
console.log(JSON.stringify({ value, matches_n, match_level }, null, 2));
|
|
614
|
+
};
|
|
615
|
+
addBrowserTabOption(get.command('text')
|
|
616
|
+
.argument('<target>', 'Numeric ref (from browser state / find) or CSS selector')
|
|
617
|
+
.option('--nth <n>', 'Pick the nth match (0-based) when <target> is a multi-match CSS selector')
|
|
618
|
+
.description('Element text content — JSON envelope {value, matches_n}'))
|
|
619
|
+
.action(browserAction(async (page, target, opts) => runGetCommand(page, String(target), opts ?? {}, getTextResolvedJs(), 'text')));
|
|
620
|
+
addBrowserTabOption(get.command('value')
|
|
621
|
+
.argument('<target>', 'Numeric ref (from browser state / find) or CSS selector')
|
|
622
|
+
.option('--nth <n>', 'Pick the nth match (0-based) when <target> is a multi-match CSS selector')
|
|
623
|
+
.description('Input/textarea value — JSON envelope {value, matches_n}'))
|
|
624
|
+
.action(browserAction(async (page, target, opts) => runGetCommand(page, String(target), opts ?? {}, getValueResolvedJs(), 'value')));
|
|
625
|
+
addBrowserTabOption(get.command('html')
|
|
626
|
+
.option('--selector <css>', 'CSS selector scope (first match)')
|
|
627
|
+
.option('--as <format>', 'Output format: "html" (default) or "json" for structured tree', 'html')
|
|
628
|
+
.option('--max <n>', 'Max characters of raw HTML to return (0 = unlimited)', '0')
|
|
629
|
+
.option('--depth <n>', '(--as json) Max tree depth below root (0 = root only, 0 disables = unlimited via empty)', '')
|
|
630
|
+
.option('--children-max <n>', '(--as json) Max element children kept per node (empty = unlimited)', '')
|
|
631
|
+
.option('--text-max <n>', '(--as json) Max chars of direct text kept per node (empty = unlimited)', '')
|
|
632
|
+
.description('Page HTML (or scoped); use --as json for a {tag, attrs, text, children} tree'))
|
|
373
633
|
.action(browserAction(async (page, opts) => {
|
|
634
|
+
const format = String(opts.as || 'html').toLowerCase();
|
|
635
|
+
if (format !== 'html' && format !== 'json') {
|
|
636
|
+
console.log(JSON.stringify({ error: { code: 'invalid_format', message: `--as must be "html" or "json", got "${opts.as}"` } }, null, 2));
|
|
637
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
// `--max` is validated up-front (before touching the page) so a bad value
|
|
641
|
+
// gets the same structured error regardless of selector/format path.
|
|
642
|
+
const rawMax = String(opts.max ?? '0');
|
|
643
|
+
if (!/^\d+$/.test(rawMax)) {
|
|
644
|
+
console.log(JSON.stringify({ error: { code: 'invalid_max', message: `--max must be a non-negative integer, got "${opts.max}"` } }, null, 2));
|
|
645
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
const max = Number.parseInt(rawMax, 10);
|
|
649
|
+
if (format === 'json') {
|
|
650
|
+
const parseBudget = (flag, value) => {
|
|
651
|
+
const raw = value === undefined || value === null ? '' : String(value);
|
|
652
|
+
if (raw === '')
|
|
653
|
+
return null;
|
|
654
|
+
if (!/^\d+$/.test(raw))
|
|
655
|
+
return { error: `${flag} must be a non-negative integer, got "${raw}"` };
|
|
656
|
+
return Number.parseInt(raw, 10);
|
|
657
|
+
};
|
|
658
|
+
const depth = parseBudget('--depth', opts.depth);
|
|
659
|
+
const childrenMax = parseBudget('--children-max', opts.childrenMax);
|
|
660
|
+
const textMax = parseBudget('--text-max', opts.textMax);
|
|
661
|
+
for (const budget of [depth, childrenMax, textMax]) {
|
|
662
|
+
if (budget && typeof budget === 'object' && 'error' in budget) {
|
|
663
|
+
console.log(JSON.stringify({ error: { code: 'invalid_budget', message: budget.error } }, null, 2));
|
|
664
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
const js = buildHtmlTreeJs({
|
|
669
|
+
selector: opts.selector ?? null,
|
|
670
|
+
depth: depth,
|
|
671
|
+
childrenMax: childrenMax,
|
|
672
|
+
textMax: textMax,
|
|
673
|
+
});
|
|
674
|
+
const result = await page.evaluate(js);
|
|
675
|
+
if (result && typeof result === 'object' && 'invalidSelector' in result && result.invalidSelector) {
|
|
676
|
+
console.log(JSON.stringify({
|
|
677
|
+
error: { code: 'invalid_selector', message: `Selector "${opts.selector}" is not a valid CSS selector: ${result.reason}` },
|
|
678
|
+
}, null, 2));
|
|
679
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
const ok = result;
|
|
683
|
+
if (!ok || ok.matched === 0) {
|
|
684
|
+
console.log(JSON.stringify({
|
|
685
|
+
error: {
|
|
686
|
+
code: 'selector_not_found',
|
|
687
|
+
message: opts.selector
|
|
688
|
+
? `Selector "${opts.selector}" matched 0 elements.`
|
|
689
|
+
: 'Page has no documentElement.',
|
|
690
|
+
},
|
|
691
|
+
}, null, 2));
|
|
692
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
console.log(JSON.stringify(ok, null, 2));
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
// Raw HTML path — unbounded by default; --max optionally caps with a visible marker.
|
|
699
|
+
// Selector lookup is wrapped in try/catch inside page context so an invalid
|
|
700
|
+
// selector returns a structured signal instead of throwing through page.evaluate.
|
|
374
701
|
const sel = opts.selector ? JSON.stringify(opts.selector) : 'null';
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
702
|
+
const rawResult = await page.evaluate(`(() => {
|
|
703
|
+
const s = ${sel};
|
|
704
|
+
if (s) {
|
|
705
|
+
try {
|
|
706
|
+
const el = document.querySelector(s);
|
|
707
|
+
return { kind: 'ok', html: el ? el.outerHTML : null };
|
|
708
|
+
} catch (e) {
|
|
709
|
+
return { kind: 'invalid_selector', reason: (e && e.message) || String(e) };
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
return { kind: 'ok', html: document.documentElement ? document.documentElement.outerHTML : null };
|
|
713
|
+
})()`);
|
|
714
|
+
if (rawResult.kind === 'invalid_selector') {
|
|
715
|
+
console.log(JSON.stringify({
|
|
716
|
+
error: { code: 'invalid_selector', message: `Selector "${opts.selector}" is not a valid CSS selector: ${rawResult.reason}` },
|
|
717
|
+
}, null, 2));
|
|
718
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
const html = rawResult.html;
|
|
722
|
+
if (html === null) {
|
|
723
|
+
if (opts.selector) {
|
|
724
|
+
console.log(JSON.stringify({
|
|
725
|
+
error: { code: 'selector_not_found', message: `Selector "${opts.selector}" matched 0 elements.` },
|
|
726
|
+
}, null, 2));
|
|
727
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
console.log('(empty)');
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
if (max > 0 && html.length > max) {
|
|
734
|
+
console.log(`<!-- opencli: truncated ${max} of ${html.length} chars; re-run without --max (or --max 0) for full -->\n${html.slice(0, max)}`);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
console.log(html);
|
|
383
738
|
}));
|
|
739
|
+
addBrowserTabOption(get.command('attributes')
|
|
740
|
+
.argument('<target>', 'Numeric ref (from browser state / find) or CSS selector')
|
|
741
|
+
.option('--nth <n>', 'Pick the nth match (0-based) when <target> is a multi-match CSS selector')
|
|
742
|
+
.description('Element attributes — JSON envelope {value, matches_n}'))
|
|
743
|
+
.action(browserAction(async (page, target, opts) => runGetCommand(page, String(target), opts ?? {}, getAttributesResolvedJs(), 'attributes')));
|
|
384
744
|
// ── Interact ──
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
745
|
+
//
|
|
746
|
+
// Write commands (`click/type/select`) share the same `<target>` contract
|
|
747
|
+
// as the read commands but *reject* multi-match CSS as `selector_ambiguous`
|
|
748
|
+
// unless the caller passes `--nth <n>`. That asymmetry is intentional:
|
|
749
|
+
// clicking "one of three buttons" at random is almost never what the agent
|
|
750
|
+
// meant. Every branch emits a JSON envelope on stdout; error envelopes go
|
|
751
|
+
// through the unified TargetError handler in browserAction.
|
|
752
|
+
/**
|
|
753
|
+
* Parse the `--nth` flag and convert it to `ResolveOptions`.
|
|
754
|
+
* Returns `{ error }` when the flag was malformed (so the command can
|
|
755
|
+
* print the structured usage error and exit) or `{ opts }` to feed
|
|
756
|
+
* into resolveRef / page.click / page.typeText.
|
|
757
|
+
*/
|
|
758
|
+
function nthToResolveOpts(raw) {
|
|
759
|
+
const parsed = parseNthFlag(raw);
|
|
760
|
+
if (parsed && typeof parsed === 'object' && 'error' in parsed)
|
|
761
|
+
return parsed;
|
|
762
|
+
if (typeof parsed === 'number')
|
|
763
|
+
return { opts: { nth: parsed } };
|
|
764
|
+
return { opts: {} };
|
|
765
|
+
}
|
|
766
|
+
addBrowserTabOption(browser.command('click')
|
|
767
|
+
.argument('<target>', 'Numeric ref (from browser state / find) or CSS selector')
|
|
768
|
+
.option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
|
|
769
|
+
.description('Click element — JSON envelope {clicked, target, matches_n}'))
|
|
770
|
+
.action(browserAction(async (page, target, opts) => {
|
|
771
|
+
const parsed = nthToResolveOpts(opts?.nth);
|
|
772
|
+
if ('error' in parsed) {
|
|
773
|
+
console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
|
|
774
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
const { matches_n, match_level } = await page.click(String(target), parsed.opts);
|
|
778
|
+
console.log(JSON.stringify({ clicked: true, target: String(target), matches_n, match_level }, null, 2));
|
|
389
779
|
}));
|
|
390
|
-
browser.command('type')
|
|
391
|
-
.
|
|
392
|
-
.
|
|
393
|
-
|
|
780
|
+
addBrowserTabOption(browser.command('type')
|
|
781
|
+
.argument('<target>', 'Numeric ref (from browser state / find) or CSS selector')
|
|
782
|
+
.argument('<text>', 'Text to type')
|
|
783
|
+
.option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
|
|
784
|
+
.description('Click element, then type text — JSON envelope {typed, text, target, matches_n, autocomplete}'))
|
|
785
|
+
.action(browserAction(async (page, target, text, opts) => {
|
|
786
|
+
const parsed = nthToResolveOpts(opts?.nth);
|
|
787
|
+
if ('error' in parsed) {
|
|
788
|
+
console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
|
|
789
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
// Click first (focuses the field), wait briefly, then type.
|
|
793
|
+
await page.click(String(target), parsed.opts);
|
|
394
794
|
await page.wait(0.3);
|
|
395
|
-
await page.typeText(
|
|
396
|
-
//
|
|
397
|
-
// __resolved is already set by typeText's resolver call
|
|
795
|
+
const { matches_n, match_level } = await page.typeText(String(target), String(text), parsed.opts);
|
|
796
|
+
// __resolved is already set by the resolver call inside page.typeText
|
|
398
797
|
const isAutocomplete = await page.evaluate(isAutocompleteResolvedJs());
|
|
399
|
-
if (isAutocomplete)
|
|
798
|
+
if (isAutocomplete)
|
|
400
799
|
await page.wait(0.4);
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
800
|
+
console.log(JSON.stringify({
|
|
801
|
+
typed: true,
|
|
802
|
+
text: String(text),
|
|
803
|
+
target: String(target),
|
|
804
|
+
matches_n,
|
|
805
|
+
match_level,
|
|
806
|
+
autocomplete: !!isAutocomplete,
|
|
807
|
+
}, null, 2));
|
|
406
808
|
}));
|
|
407
|
-
browser.command('select')
|
|
408
|
-
.
|
|
409
|
-
.
|
|
410
|
-
|
|
411
|
-
|
|
809
|
+
addBrowserTabOption(browser.command('select')
|
|
810
|
+
.argument('<target>', 'Numeric ref (from browser state / find) or CSS selector of a <select> element')
|
|
811
|
+
.argument('<option>', 'Option text (or value) to select')
|
|
812
|
+
.option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
|
|
813
|
+
.description('Select dropdown option — JSON envelope {selected, target, matches_n}'))
|
|
814
|
+
.action(browserAction(async (page, target, option, opts) => {
|
|
815
|
+
const parsed = nthToResolveOpts(opts?.nth);
|
|
816
|
+
if ('error' in parsed) {
|
|
817
|
+
console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
|
|
818
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
const { matches_n, match_level } = await resolveRef(page, String(target), parsed.opts);
|
|
822
|
+
const result = await page.evaluate(selectResolvedJs(String(option)));
|
|
412
823
|
if (result?.error) {
|
|
413
|
-
|
|
824
|
+
// The select-specific "Not a <select>" / "Option not found" errors
|
|
825
|
+
// are domain-level failures — emit a structured envelope so agents
|
|
826
|
+
// can branch on code rather than scrape a log line.
|
|
827
|
+
console.log(JSON.stringify({
|
|
828
|
+
error: {
|
|
829
|
+
code: result.error === 'Not a <select>' ? 'not_a_select' : 'option_not_found',
|
|
830
|
+
message: result.error,
|
|
831
|
+
...(result.available && { available: result.available }),
|
|
832
|
+
matches_n,
|
|
833
|
+
},
|
|
834
|
+
}, null, 2));
|
|
414
835
|
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
836
|
+
return;
|
|
415
837
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
838
|
+
console.log(JSON.stringify({
|
|
839
|
+
selected: result?.selected ?? String(option),
|
|
840
|
+
target: String(target),
|
|
841
|
+
matches_n,
|
|
842
|
+
match_level,
|
|
843
|
+
}, null, 2));
|
|
419
844
|
}));
|
|
420
|
-
browser.command('keys').argument('<key>', 'Key to press (Enter, Escape, Tab, Control+a)')
|
|
845
|
+
addBrowserTabOption(browser.command('keys').argument('<key>', 'Key to press (Enter, Escape, Tab, Control+a)'))
|
|
421
846
|
.description('Press keyboard key')
|
|
422
847
|
.action(browserAction(async (page, key) => {
|
|
423
848
|
await page.pressKey(key);
|
|
424
849
|
console.log(`Pressed: ${key}`);
|
|
425
850
|
}));
|
|
426
851
|
// ── Wait commands ──
|
|
427
|
-
browser.command('wait')
|
|
852
|
+
addBrowserTabOption(browser.command('wait'))
|
|
428
853
|
.argument('<type>', 'selector, text, or time')
|
|
429
854
|
.argument('[value]', 'CSS selector, text string, or seconds')
|
|
430
855
|
.option('--timeout <ms>', 'Timeout in milliseconds', '10000')
|
|
@@ -460,94 +885,258 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
460
885
|
}
|
|
461
886
|
}));
|
|
462
887
|
// ── Extract ──
|
|
463
|
-
browser.command('eval')
|
|
464
|
-
.
|
|
465
|
-
|
|
888
|
+
addBrowserTabOption(browser.command('eval')
|
|
889
|
+
.argument('<js>', 'JavaScript code')
|
|
890
|
+
.option('--frame <index>', 'Cross-origin iframe index from "browser frames"')
|
|
891
|
+
.description('Execute JS in page context, return result'))
|
|
892
|
+
.action(browserAction(async (page, js, opts) => {
|
|
893
|
+
let result;
|
|
894
|
+
if (opts.frame !== undefined) {
|
|
895
|
+
const frameIndex = Number.parseInt(opts.frame, 10);
|
|
896
|
+
if (!Number.isInteger(frameIndex) || frameIndex < 0) {
|
|
897
|
+
console.error(`Invalid frame index "${opts.frame}". Use a 0-based index from "browser frames".`);
|
|
898
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
if (!page.evaluateInFrame) {
|
|
902
|
+
throw new Error('This browser session does not support frame-targeted evaluation');
|
|
903
|
+
}
|
|
904
|
+
result = await page.evaluateInFrame(js, frameIndex);
|
|
905
|
+
}
|
|
906
|
+
else {
|
|
907
|
+
result = await page.evaluate(js);
|
|
908
|
+
}
|
|
466
909
|
if (typeof result === 'string')
|
|
467
910
|
console.log(result);
|
|
468
911
|
else
|
|
469
912
|
console.log(JSON.stringify(result, null, 2));
|
|
470
913
|
}));
|
|
914
|
+
// ── Extract (content reading) ──
|
|
915
|
+
//
|
|
916
|
+
// `extract` answers the "read this page" question that `get html` / `get text`
|
|
917
|
+
// can't: denoise → markdown → paragraph-aware chunking. Agents walk long pages
|
|
918
|
+
// by passing back the `next_start_char` cursor instead of juggling selectors.
|
|
919
|
+
addBrowserTabOption(browser.command('extract')
|
|
920
|
+
.option('--selector <css>', 'CSS selector scope; defaults to <main>/<article>/<body>')
|
|
921
|
+
.option('--chunk-size <chars>', 'Target chunk size in chars', '20000')
|
|
922
|
+
.option('--start <char>', 'Start offset (use next_start_char from a previous extract)', '0')
|
|
923
|
+
.description('Extract page content as markdown, paragraph-aware chunks for long pages'))
|
|
924
|
+
.action(browserAction(async (page, opts) => {
|
|
925
|
+
const rawChunk = String(opts.chunkSize ?? '20000');
|
|
926
|
+
if (!/^\d+$/.test(rawChunk) || Number.parseInt(rawChunk, 10) <= 0) {
|
|
927
|
+
console.log(JSON.stringify({ error: { code: 'invalid_chunk_size', message: `--chunk-size must be a positive integer, got "${opts.chunkSize}"` } }, null, 2));
|
|
928
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
const rawStart = String(opts.start ?? '0');
|
|
932
|
+
if (!/^\d+$/.test(rawStart)) {
|
|
933
|
+
console.log(JSON.stringify({ error: { code: 'invalid_start', message: `--start must be a non-negative integer, got "${opts.start}"` } }, null, 2));
|
|
934
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
const chunkSize = Number.parseInt(rawChunk, 10);
|
|
938
|
+
const start = Number.parseInt(rawStart, 10);
|
|
939
|
+
const selector = typeof opts.selector === 'string' && opts.selector.length > 0 ? opts.selector : null;
|
|
940
|
+
const js = buildExtractHtmlJs(selector);
|
|
941
|
+
const res = await page.evaluate(js);
|
|
942
|
+
if (!res) {
|
|
943
|
+
console.log(JSON.stringify({ error: { code: 'extract_failed', message: 'Page returned no root element.' } }, null, 2));
|
|
944
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
if ('invalidSelector' in res) {
|
|
948
|
+
console.log(JSON.stringify({ error: { code: 'invalid_selector', message: `Selector "${selector}" is not a valid CSS selector: ${res.reason}` } }, null, 2));
|
|
949
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
if ('notFound' in res) {
|
|
953
|
+
console.log(JSON.stringify({ error: { code: 'selector_not_found', message: selector ? `Selector "${selector}" matched 0 elements.` : 'Page has no body/main/article element.' } }, null, 2));
|
|
954
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
const envelope = runExtractFromHtml({
|
|
958
|
+
html: res.html,
|
|
959
|
+
url: res.url,
|
|
960
|
+
title: res.title,
|
|
961
|
+
selector,
|
|
962
|
+
start,
|
|
963
|
+
chunkSize,
|
|
964
|
+
});
|
|
965
|
+
console.log(JSON.stringify(envelope, null, 2));
|
|
966
|
+
}));
|
|
471
967
|
// ── Network (API discovery) ──
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
968
|
+
//
|
|
969
|
+
// Default output is JSON (agent-native). Each entry carries a stable `key`
|
|
970
|
+
// (GraphQL operationName or `METHOD host+pathname`) so agents can fetch
|
|
971
|
+
// full bodies with `--detail <key>` even after subsequent commands.
|
|
972
|
+
// Captures are persisted per workspace under ~/.opencli/cache/browser-network/.
|
|
973
|
+
addBrowserTabOption(browser.command('network'))
|
|
974
|
+
.option('--detail <key>', 'Emit full body for the entry with this key')
|
|
975
|
+
.option('--all', 'Include static resources (js/css/images/telemetry)')
|
|
976
|
+
.option('--raw', 'Emit full bodies for every entry (skip shape preview)')
|
|
977
|
+
.option('--filter <fields>', 'Comma-separated field names; keep only entries whose body shape has ALL names as path segments')
|
|
978
|
+
.option('--max-body <chars>', 'With --detail: cap the emitted body at N chars (0 = unlimited, default)', '0')
|
|
979
|
+
.option('--ttl <ms>', 'Cache TTL in ms for --detail lookups', String(DEFAULT_TTL_MS))
|
|
980
|
+
.description('Capture network requests as shape previews; retrieve full bodies by key')
|
|
476
981
|
.action(browserAction(async (page, opts) => {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
body = JSON.parse(preview);
|
|
489
|
-
}
|
|
490
|
-
catch {
|
|
491
|
-
body = preview;
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
return {
|
|
495
|
-
url: e.url || '',
|
|
496
|
-
method: e.method || 'GET',
|
|
497
|
-
status: e.responseStatus || 0,
|
|
498
|
-
size: preview ? preview.length : 0,
|
|
499
|
-
ct: e.responseContentType || '',
|
|
500
|
-
body,
|
|
501
|
-
};
|
|
502
|
-
});
|
|
982
|
+
const ttlMs = parsePositiveIntOption(opts.ttl, 'ttl', DEFAULT_TTL_MS);
|
|
983
|
+
const workspace = DEFAULT_BROWSER_WORKSPACE;
|
|
984
|
+
const hasDetail = typeof opts.detail === 'string' && opts.detail.length > 0;
|
|
985
|
+
const hasFilter = typeof opts.filter === 'string';
|
|
986
|
+
// --detail and --filter do different things (one request by key vs. narrow
|
|
987
|
+
// the list by shape), don't compose, and combining them has no sensible
|
|
988
|
+
// semantic. Reject up front with a structured error instead of silently
|
|
989
|
+
// dropping one.
|
|
990
|
+
if (hasDetail && hasFilter) {
|
|
991
|
+
emitNetworkError('invalid_args', '--filter and --detail cannot be used together (one narrows a list, the other fetches a specific entry).');
|
|
992
|
+
return;
|
|
503
993
|
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
try {
|
|
511
|
-
items = JSON.parse(requests);
|
|
994
|
+
let filterFields = null;
|
|
995
|
+
if (hasFilter) {
|
|
996
|
+
const parsed = parseFilter(opts.filter);
|
|
997
|
+
if ('reason' in parsed) {
|
|
998
|
+
emitNetworkError('invalid_filter', parsed.reason);
|
|
999
|
+
return;
|
|
512
1000
|
}
|
|
513
|
-
|
|
514
|
-
|
|
1001
|
+
filterFields = parsed.fields;
|
|
1002
|
+
}
|
|
1003
|
+
// --detail short-circuits: read from cache only, no live capture needed.
|
|
1004
|
+
if (hasDetail) {
|
|
1005
|
+
const res = loadNetworkCache(workspace, { ttlMs });
|
|
1006
|
+
if (res.status === 'missing') {
|
|
1007
|
+
emitNetworkError('cache_missing', `No cached capture. Run "browser network" first (in workspace "${workspace}").`);
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
if (res.status === 'expired') {
|
|
1011
|
+
emitNetworkError('cache_expired', `Cache is stale (age ${res.ageMs}ms > ttl ${ttlMs}ms). Re-run "browser network" to refresh.`);
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
if (res.status === 'corrupt' || !res.file) {
|
|
1015
|
+
emitNetworkError('cache_corrupt', 'Cache file is malformed; re-run "browser network" to regenerate.');
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
const entry = findEntry(res.file, opts.detail);
|
|
1019
|
+
if (!entry) {
|
|
1020
|
+
emitNetworkError('key_not_found', `Key "${opts.detail}" not in cache.`, {
|
|
1021
|
+
available_keys: res.file.entries.map((e) => e.key),
|
|
1022
|
+
});
|
|
515
1023
|
return;
|
|
516
1024
|
}
|
|
1025
|
+
const rawMaxBody = String(opts.maxBody ?? '0');
|
|
1026
|
+
if (!/^\d+$/.test(rawMaxBody)) {
|
|
1027
|
+
emitNetworkError('invalid_max_body', `--max-body must be a non-negative integer, got "${opts.maxBody}"`);
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
const maxBody = Number.parseInt(rawMaxBody, 10);
|
|
1031
|
+
// Body shape/source:
|
|
1032
|
+
// - If capture already truncated it (entry.body_truncated), the body is a string.
|
|
1033
|
+
// - If the adapter stored a JSON value, it parsed cleanly at capture time; leave it.
|
|
1034
|
+
// - --max-body applies a transport-level cap when the caller wants to keep output small.
|
|
1035
|
+
let outputBody = entry.body;
|
|
1036
|
+
let transportTruncated = false;
|
|
1037
|
+
if (maxBody > 0 && typeof entry.body === 'string' && entry.body.length > maxBody) {
|
|
1038
|
+
outputBody = entry.body.slice(0, maxBody);
|
|
1039
|
+
transportTruncated = true;
|
|
1040
|
+
}
|
|
1041
|
+
const captureTruncated = entry.body_truncated === true;
|
|
1042
|
+
const detailEnvelope = {
|
|
1043
|
+
key: entry.key,
|
|
1044
|
+
url: entry.url,
|
|
1045
|
+
method: entry.method,
|
|
1046
|
+
status: entry.status,
|
|
1047
|
+
ct: entry.ct,
|
|
1048
|
+
size: entry.size,
|
|
1049
|
+
shape: inferShape(entry.body),
|
|
1050
|
+
body: outputBody,
|
|
1051
|
+
};
|
|
1052
|
+
if (captureTruncated || transportTruncated) {
|
|
1053
|
+
detailEnvelope.body_truncated = true;
|
|
1054
|
+
detailEnvelope.body_full_size = entry.body_full_size ?? entry.size;
|
|
1055
|
+
detailEnvelope.body_truncation_reason = captureTruncated
|
|
1056
|
+
? 'capture-limit'
|
|
1057
|
+
: 'max-body';
|
|
1058
|
+
}
|
|
1059
|
+
console.log(JSON.stringify(detailEnvelope, null, 2));
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
// Fresh capture path.
|
|
1063
|
+
let rawItems;
|
|
1064
|
+
try {
|
|
1065
|
+
rawItems = await captureNetworkItems(page);
|
|
517
1066
|
}
|
|
518
|
-
|
|
519
|
-
|
|
1067
|
+
catch (err) {
|
|
1068
|
+
emitNetworkError('capture_failed', `Could not read network capture: ${err.message}`);
|
|
520
1069
|
return;
|
|
521
1070
|
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
1071
|
+
const items = opts.all ? rawItems : filterNetworkItems(rawItems);
|
|
1072
|
+
const filteredOut = rawItems.length - items.length;
|
|
1073
|
+
const keyed = assignKeys(items);
|
|
1074
|
+
const cacheEntries = keyed.map((it) => ({
|
|
1075
|
+
key: it.key,
|
|
1076
|
+
url: it.url,
|
|
1077
|
+
method: it.method,
|
|
1078
|
+
status: it.status,
|
|
1079
|
+
size: it.size,
|
|
1080
|
+
ct: it.ct,
|
|
1081
|
+
body: it.body,
|
|
1082
|
+
...(it.bodyTruncated ? { body_truncated: true } : {}),
|
|
1083
|
+
...(it.bodyTruncated && typeof it.bodyFullSize === 'number'
|
|
1084
|
+
? { body_full_size: it.bodyFullSize }
|
|
1085
|
+
: {}),
|
|
1086
|
+
}));
|
|
1087
|
+
// Soft failure: the caller already has the data, so surface a warning
|
|
1088
|
+
// via the output envelope rather than erroring out the whole command.
|
|
1089
|
+
let cacheWarning = null;
|
|
1090
|
+
try {
|
|
1091
|
+
saveNetworkCache(workspace, cacheEntries);
|
|
1092
|
+
}
|
|
1093
|
+
catch (err) {
|
|
1094
|
+
cacheWarning = `Could not persist capture cache: ${err.message}. --detail lookups may miss this capture.`;
|
|
1095
|
+
}
|
|
1096
|
+
// Pair each cache entry with its shape up front so --filter can read
|
|
1097
|
+
// segments without recomputing, and the --raw view can keep the full
|
|
1098
|
+
// body. Cache persistence above stored the unfiltered set on purpose:
|
|
1099
|
+
// later `--detail <key>` lookups must still see requests that the
|
|
1100
|
+
// current --filter narrowed out.
|
|
1101
|
+
const shaped = cacheEntries.map((e) => ({ entry: e, shape: inferShape(e.body) }));
|
|
1102
|
+
const visible = filterFields
|
|
1103
|
+
? shaped.filter((s) => shapeMatchesFilter(s.shape, filterFields))
|
|
1104
|
+
: shaped;
|
|
1105
|
+
const filterDropped = filterFields ? shaped.length - visible.length : 0;
|
|
1106
|
+
const envelope = {
|
|
1107
|
+
workspace,
|
|
1108
|
+
captured_at: new Date().toISOString(),
|
|
1109
|
+
count: visible.length,
|
|
1110
|
+
filtered_out: filteredOut,
|
|
1111
|
+
};
|
|
1112
|
+
if (filterFields) {
|
|
1113
|
+
envelope.filter = filterFields;
|
|
1114
|
+
envelope.filter_dropped = filterDropped;
|
|
1115
|
+
}
|
|
1116
|
+
if (cacheWarning)
|
|
1117
|
+
envelope.cache_warning = cacheWarning;
|
|
1118
|
+
const truncatedCount = visible.filter((s) => s.entry.body_truncated).length;
|
|
1119
|
+
if (truncatedCount > 0) {
|
|
1120
|
+
envelope.body_truncated_count = truncatedCount;
|
|
1121
|
+
envelope.body_truncated_hint = 'Some bodies exceeded the capture limit; their `shape` reflects only the captured prefix.';
|
|
1122
|
+
}
|
|
1123
|
+
if (opts.raw) {
|
|
1124
|
+
envelope.entries = visible.map((s) => s.entry);
|
|
540
1125
|
}
|
|
541
1126
|
else {
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
1127
|
+
envelope.entries = visible.map((s) => ({
|
|
1128
|
+
key: s.entry.key,
|
|
1129
|
+
method: s.entry.method,
|
|
1130
|
+
status: s.entry.status,
|
|
1131
|
+
url: s.entry.url,
|
|
1132
|
+
ct: s.entry.ct,
|
|
1133
|
+
size: s.entry.size,
|
|
1134
|
+
shape: s.shape,
|
|
1135
|
+
...(s.entry.body_truncated ? { body_truncated: true } : {}),
|
|
1136
|
+
}));
|
|
1137
|
+
envelope.detail_hint = 'Run "browser network --detail <key>" for full body.';
|
|
550
1138
|
}
|
|
1139
|
+
console.log(JSON.stringify(envelope, null, 2));
|
|
551
1140
|
}));
|
|
552
1141
|
// ── Init (adapter scaffolding) ──
|
|
553
1142
|
browser.command('init')
|
|
@@ -1071,10 +1660,14 @@ cli({
|
|
|
1071
1660
|
.command('serve')
|
|
1072
1661
|
.description('Start Anthropic-compatible API proxy for Antigravity')
|
|
1073
1662
|
.option('--port <port>', 'Server port (default: 8082)', '8082')
|
|
1663
|
+
.option('--timeout <seconds>', 'Maximum time to wait for a reply (default: 120s)')
|
|
1074
1664
|
.action(async (opts) => {
|
|
1075
1665
|
// @ts-expect-error JS adapter — no type declarations
|
|
1076
1666
|
const { startServe } = await import('../clis/antigravity/serve.js');
|
|
1077
|
-
await startServe({
|
|
1667
|
+
await startServe({
|
|
1668
|
+
port: parseInt(opts.port, 10),
|
|
1669
|
+
timeout: opts.timeout ? parsePositiveIntOption(opts.timeout, '--timeout', 120) : undefined,
|
|
1670
|
+
});
|
|
1078
1671
|
});
|
|
1079
1672
|
// ── Dynamic adapter commands ──────────────────────────────────────────────
|
|
1080
1673
|
const siteGroups = new Map();
|
|
@@ -1132,14 +1725,3 @@ export function resolveBrowserVerifyInvocation(opts = {}) {
|
|
|
1132
1725
|
...(platform === 'win32' ? { shell: true } : {}),
|
|
1133
1726
|
};
|
|
1134
1727
|
}
|
|
1135
|
-
/** Infer a workspace-friendly hostname from a URL, with site override. */
|
|
1136
|
-
function inferHost(url, site) {
|
|
1137
|
-
if (site)
|
|
1138
|
-
return site;
|
|
1139
|
-
try {
|
|
1140
|
-
return new URL(url).host;
|
|
1141
|
-
}
|
|
1142
|
-
catch {
|
|
1143
|
-
return 'default';
|
|
1144
|
-
}
|
|
1145
|
-
}
|