From 248ac19f483f2da034fcc0a96c3c67cb4d7e01ae Mon Sep 17 00:00:00 2001 From: coolxll Date: Wed, 25 Mar 2026 12:41:47 +0800 Subject: [PATCH] feat: add command-level CDP overrides --- docs/adapters/desktop/antigravity.md | 17 +++- src/cli.ts | 116 ++++++++++++++++----------- src/clis/antigravity/SKILL.md | 17 +++- src/clis/antigravity/serve.ts | 4 +- src/commanderAdapter.test.ts | 70 ++++++++++++++++ src/commanderAdapter.ts | 10 ++- src/runtime.test.ts | 55 +++++++++++++ src/runtime.ts | 47 +++++++++++ 8 files changed, 282 insertions(+), 54 deletions(-) create mode 100644 src/commanderAdapter.test.ts create mode 100644 src/runtime.test.ts diff --git a/docs/adapters/desktop/antigravity.md b/docs/adapters/desktop/antigravity.md index 4cdd72a4..0f76e0c7 100644 --- a/docs/adapters/desktop/antigravity.md +++ b/docs/adapters/desktop/antigravity.md @@ -16,12 +16,27 @@ Start the Antigravity desktop app with the Chrome DevTools `remote-debugging-por > Depending on your installation, the executable might be named differently, e.g., `Antigravity` instead of `Electron`. -Then set the target port: +Then either set the target port globally: ```bash export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224" ``` +Or pass it per command, which is usually better when you switch between multiple desktop apps or multiple CDP ports: + +```bash +opencli antigravity status --cdp-endpoint http://127.0.0.1:9224 +opencli antigravity send "hello" --cdp-endpoint http://127.0.0.1:9224 +``` + +If the endpoint exposes multiple inspectable windows, prefer the correct one per command: + +```bash +opencli antigravity status \ + --cdp-endpoint http://127.0.0.1:9224 \ + --cdp-target antigravity +``` + ## Commands ### `opencli antigravity status` diff --git a/src/cli.ts b/src/cli.ts index 6d005800..edcc71b4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,7 +10,7 @@ import chalk from 'chalk'; import { type CliCommand, fullName, getRegistry, strategyLabel } from './registry.js'; import { serializeCommand, formatArgSummary } from './serialization.js'; import { render as renderOutput } from './output.js'; -import { getBrowserFactory, browserSession } from './runtime.js'; +import { extractBrowserEnvOverrides, getBrowserFactory, browserSession, withBrowserEnvOverrides } from './runtime.js'; import { PKG_VERSION } from './version.js'; import { printCompletionScript } from './completion.js'; import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js'; @@ -133,22 +133,26 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { .option('--wait ', '', '3') .option('--auto', 'Enable interactive fuzzing') .option('--click ', 'Comma-separated labels to click before fuzzing') + .option('--cdp-endpoint ', 'Override the CDP endpoint for this command') + .option('--cdp-target ', 'Prefer a CDP target whose title or URL matches this pattern') .action(async (url, opts) => { - const { exploreUrl, renderExploreSummary } = await import('./explore.js'); - const clickLabels = opts.click - ? opts.click.split(',').map((s: string) => s.trim()) - : undefined; - const workspace = `explore:${inferHost(url, opts.site)}`; - const result = await exploreUrl(url, { - BrowserFactory: getBrowserFactory(), - site: opts.site, - goal: opts.goal, - waitSeconds: parseFloat(opts.wait), - auto: opts.auto, - clickLabels, - workspace, + await withBrowserEnvOverrides(extractBrowserEnvOverrides(opts), async () => { + const { exploreUrl, renderExploreSummary } = await import('./explore.js'); + const clickLabels = opts.click + ? opts.click.split(',').map((s: string) => s.trim()) + : undefined; + const workspace = `explore:${inferHost(url, opts.site)}`; + const result = await exploreUrl(url, { + BrowserFactory: getBrowserFactory(), + site: opts.site, + goal: opts.goal, + waitSeconds: parseFloat(opts.wait), + auto: opts.auto, + clickLabels, + workspace, + }); + console.log(renderExploreSummary(result)); }); - console.log(renderExploreSummary(result)); }); program @@ -167,18 +171,22 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { .argument('') .option('--goal ') .option('--site ') + .option('--cdp-endpoint ', 'Override the CDP endpoint for this command') + .option('--cdp-target ', 'Prefer a CDP target whose title or URL matches this pattern') .action(async (url, opts) => { - const { generateCliFromUrl, renderGenerateSummary } = await import('./generate.js'); - const workspace = `generate:${inferHost(url, opts.site)}`; - const r = await generateCliFromUrl({ - url, - BrowserFactory: getBrowserFactory(), - goal: opts.goal, - site: opts.site, - workspace, + await withBrowserEnvOverrides(extractBrowserEnvOverrides(opts), async () => { + const { generateCliFromUrl, renderGenerateSummary } = await import('./generate.js'); + const workspace = `generate:${inferHost(url, opts.site)}`; + const r = await generateCliFromUrl({ + url, + BrowserFactory: getBrowserFactory(), + goal: opts.goal, + site: opts.site, + workspace, + }); + console.log(renderGenerateSummary(r)); + process.exitCode = r.ok ? 0 : 1; }); - console.log(renderGenerateSummary(r)); - process.exitCode = r.ok ? 0 : 1; }); // ── Built-in: record ───────────────────────────────────────────────────── @@ -191,18 +199,22 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { .option('--out ', 'Output directory for candidates') .option('--poll ', 'Poll interval in milliseconds', '2000') .option('--timeout ', 'Auto-stop after N milliseconds (default: 60000)', '60000') + .option('--cdp-endpoint ', 'Override the CDP endpoint for this command') + .option('--cdp-target ', 'Prefer a CDP target whose title or URL matches this pattern') .action(async (url, opts) => { - const { recordSession, renderRecordSummary } = await import('./record.js'); - const result = await recordSession({ - BrowserFactory: getBrowserFactory(), - url, - site: opts.site, - outDir: opts.out, - pollMs: parseInt(opts.poll, 10), - timeoutMs: parseInt(opts.timeout, 10), + await withBrowserEnvOverrides(extractBrowserEnvOverrides(opts), async () => { + const { recordSession, renderRecordSummary } = await import('./record.js'); + const result = await recordSession({ + BrowserFactory: getBrowserFactory(), + url, + site: opts.site, + outDir: opts.out, + pollMs: parseInt(opts.poll, 10), + timeoutMs: parseInt(opts.timeout, 10), + }); + console.log(renderRecordSummary(result)); + process.exitCode = result.candidateCount > 0 ? 0 : 1; }); - console.log(renderRecordSummary(result)); - process.exitCode = result.candidateCount > 0 ? 0 : 1; }); program @@ -210,18 +222,22 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { .description('Strategy cascade: find simplest working strategy') .argument('') .option('--site ') + .option('--cdp-endpoint ', 'Override the CDP endpoint for this command') + .option('--cdp-target ', 'Prefer a CDP target whose title or URL matches this pattern') .action(async (url, opts) => { - const { cascadeProbe, renderCascadeResult } = await import('./cascade.js'); - const workspace = `cascade:${inferHost(url, opts.site)}`; - const result = await browserSession(getBrowserFactory(), async (page) => { - try { - const siteUrl = new URL(url); - await page.goto(`${siteUrl.protocol}//${siteUrl.host}`); - await page.wait(2); - } catch {} - return cascadeProbe(page, url); - }, { workspace }); - console.log(renderCascadeResult(result)); + await withBrowserEnvOverrides(extractBrowserEnvOverrides(opts), async () => { + const { cascadeProbe, renderCascadeResult } = await import('./cascade.js'); + const workspace = `cascade:${inferHost(url, opts.site)}`; + const result = await browserSession(getBrowserFactory(), async (page) => { + try { + const siteUrl = new URL(url); + await page.goto(`${siteUrl.protocol}//${siteUrl.host}`); + await page.wait(2); + } catch {} + return cascadeProbe(page, url); + }, { workspace }); + console.log(renderCascadeResult(result)); + }); }); // ── Built-in: doctor / completion ────────────────────────────────────────── @@ -439,9 +455,13 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { .command('serve') .description('Start Anthropic-compatible API proxy for Antigravity') .option('--port ', 'Server port (default: 8082)', '8082') + .option('--cdp-endpoint ', 'Override the CDP endpoint for this command') + .option('--cdp-target ', 'Prefer a CDP target whose title or URL matches this pattern') .action(async (opts) => { - const { startServe } = await import('./clis/antigravity/serve.js'); - await startServe({ port: parseInt(opts.port) }); + await withBrowserEnvOverrides(extractBrowserEnvOverrides(opts), async () => { + const { startServe } = await import('./clis/antigravity/serve.js'); + await startServe({ port: parseInt(opts.port) }); + }); }); // ── Dynamic adapter commands ────────────────────────────────────────────── diff --git a/src/clis/antigravity/SKILL.md b/src/clis/antigravity/SKILL.md index 83aaa60a..1ead8b9e 100644 --- a/src/clis/antigravity/SKILL.md +++ b/src/clis/antigravity/SKILL.md @@ -12,14 +12,21 @@ The target Electron application MUST be launched with the remote-debugging-port /Applications/Antigravity.app/Contents/MacOS/Electron --remote-debugging-port=9224 \`\`\` -The agent must configure the endpoint environment variable locally before invoking standard commands: +The agent can either configure the endpoint environment variable locally once: \`\`\`bash export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224" \`\`\` -If the endpoint exposes multiple inspectable targets, also set: +Or pass it per command, which is better when switching between multiple apps: +\`\`\`bash +opencli antigravity status --cdp-endpoint http://127.0.0.1:9224 +\`\`\` + +If the endpoint exposes multiple inspectable targets, also set or pass: \`\`\`bash export OPENCLI_CDP_TARGET="antigravity" +# or: +opencli antigravity status --cdp-endpoint http://127.0.0.1:9224 --cdp-target antigravity \`\`\` ## High-Level Capabilities @@ -39,6 +46,12 @@ opencli antigravity send "Write a python script to fetch HN top stories" opencli antigravity extract-code > hn_fetcher.py \`\`\` +Equivalent per-command form: +\`\`\`bash +opencli antigravity send "Write a python script to fetch HN top stories" --cdp-endpoint http://127.0.0.1:9224 +opencli antigravity extract-code --cdp-endpoint http://127.0.0.1:9224 > hn_fetcher.py +\`\`\` + ### Reading Real-time Logs Agents can run long-running streaming watch instances: \`\`\`bash diff --git a/src/clis/antigravity/serve.ts b/src/clis/antigravity/serve.ts index ad57a0e6..ad99c3de 100644 --- a/src/clis/antigravity/serve.ts +++ b/src/clis/antigravity/serve.ts @@ -7,6 +7,7 @@ * * Usage: * OPENCLI_CDP_ENDPOINT=http://127.0.0.1:9224 opencli antigravity serve --port 8082 + * opencli antigravity serve --port 8082 --cdp-endpoint http://127.0.0.1:9224 --cdp-target antigravity * ANTHROPIC_BASE_URL=http://localhost:8082 claude */ @@ -439,7 +440,8 @@ export async function startServe(opts: { port?: number } = {}): Promise { if (!endpoint) { throw new Error( 'OPENCLI_CDP_ENDPOINT is not set.\n' + - 'Usage: OPENCLI_CDP_ENDPOINT=http://127.0.0.1:9224 opencli antigravity serve' + 'Usage: OPENCLI_CDP_ENDPOINT=http://127.0.0.1:9224 opencli antigravity serve\n' + + ' or: opencli antigravity serve --cdp-endpoint http://127.0.0.1:9224' ); } diff --git a/src/commanderAdapter.test.ts b/src/commanderAdapter.test.ts new file mode 100644 index 00000000..ecd10b02 --- /dev/null +++ b/src/commanderAdapter.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Command } from 'commander'; +import type { CliCommand } from './registry.js'; + +const { mockExecuteCommand, mockRender } = vi.hoisted(() => ({ + mockExecuteCommand: vi.fn(), + mockRender: vi.fn(), +})); + +vi.mock('./execution.js', () => ({ + executeCommand: mockExecuteCommand, +})); + +vi.mock('./output.js', () => ({ + render: mockRender, +})); + +import { registerCommandToProgram } from './commanderAdapter.js'; + +describe('registerCommandToProgram', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + process.exitCode = undefined; + }); + + it('applies command-level CDP overrides only while a browser command executes', async () => { + const seen: Array<{ endpoint?: string; target?: string }> = []; + mockExecuteCommand.mockImplementation(async () => { + seen.push({ + endpoint: process.env.OPENCLI_CDP_ENDPOINT, + target: process.env.OPENCLI_CDP_TARGET, + }); + return []; + }); + + const cmd: CliCommand = { + site: 'antigravity', + name: 'status', + description: 'status', + browser: true, + args: [], + }; + + const program = new Command(); + const siteCmd = program.command('antigravity'); + registerCommandToProgram(siteCmd, cmd); + + await program.parseAsync([ + 'node', + 'opencli', + 'antigravity', + 'status', + '--cdp-endpoint', + 'http://127.0.0.1:9333', + '--cdp-target', + 'launchpad', + ]); + + expect(mockExecuteCommand).toHaveBeenCalledWith(cmd, {}, false); + expect(seen).toEqual([ + { + endpoint: 'http://127.0.0.1:9333', + target: 'launchpad', + }, + ]); + expect(process.env.OPENCLI_CDP_ENDPOINT).toBeUndefined(); + expect(process.env.OPENCLI_CDP_TARGET).toBeUndefined(); + }); +}); diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index a4fda706..ebcdb4c6 100644 --- a/src/commanderAdapter.ts +++ b/src/commanderAdapter.ts @@ -17,6 +17,7 @@ import { formatRegistryHelpText } from './serialization.js'; import { render as renderOutput } from './output.js'; import { executeCommand } from './execution.js'; import { CliError, ERROR_ICONS, getErrorMessage } from './errors.js'; +import { extractBrowserEnvOverrides, withBrowserEnvOverrides } from './runtime.js'; /** * Register a single CliCommand as a Commander subcommand. @@ -43,6 +44,11 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi subCmd .option('-f, --format ', 'Output format: table, json, yaml, md, csv', 'table') .option('-v, --verbose', 'Debug output', false); + if (cmd.browser) { + subCmd + .option('--cdp-endpoint ', 'Override the CDP endpoint for this command') + .option('--cdp-target ', 'Prefer a CDP target whose title or URL matches this pattern'); + } subCmd.addHelpText('after', formatRegistryHelpText(cmd)); @@ -69,8 +75,8 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi const verbose = optionsRecord.verbose === true; const format = typeof optionsRecord.format === 'string' ? optionsRecord.format : 'table'; if (verbose) process.env.OPENCLI_VERBOSE = '1'; - - const result = await executeCommand(cmd, kwargs, verbose); + const browserEnv = extractBrowserEnvOverrides(optionsRecord); + const result = await withBrowserEnvOverrides(browserEnv, async () => executeCommand(cmd, kwargs, verbose)); if (verbose && (!result || (Array.isArray(result) && result.length === 0))) { console.error(chalk.yellow('[Verbose] Warning: Command returned an empty result.')); diff --git a/src/runtime.test.ts b/src/runtime.test.ts new file mode 100644 index 00000000..a67faf01 --- /dev/null +++ b/src/runtime.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest'; +import { extractBrowserEnvOverrides, withBrowserEnvOverrides } from './runtime.js'; + +describe('browser env overrides', () => { + it('extracts browser overrides from commander-style option names', () => { + expect(extractBrowserEnvOverrides({ + 'cdp-endpoint': ' http://127.0.0.1:9333 ', + 'cdp-target': ' antigravity ', + })).toEqual({ + cdpEndpoint: 'http://127.0.0.1:9333', + cdpTarget: 'antigravity', + }); + }); + + it('temporarily applies overrides and restores previous values', async () => { + vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'http://127.0.0.1:9222'); + vi.stubEnv('OPENCLI_CDP_TARGET', 'codex'); + + let seenEndpoint: string | undefined; + let seenTarget: string | undefined; + + await withBrowserEnvOverrides({ + cdpEndpoint: 'http://127.0.0.1:9333', + cdpTarget: 'antigravity', + }, async () => { + seenEndpoint = process.env.OPENCLI_CDP_ENDPOINT; + seenTarget = process.env.OPENCLI_CDP_TARGET; + }); + + expect(seenEndpoint).toBe('http://127.0.0.1:9333'); + expect(seenTarget).toBe('antigravity'); + expect(process.env.OPENCLI_CDP_ENDPOINT).toBe('http://127.0.0.1:9222'); + expect(process.env.OPENCLI_CDP_TARGET).toBe('codex'); + }); + + it('leaves unrelated browser env unchanged when an override is omitted', async () => { + vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'http://127.0.0.1:9222'); + vi.stubEnv('OPENCLI_CDP_TARGET', 'cursor'); + + let seenEndpoint: string | undefined; + let seenTarget: string | undefined; + + await withBrowserEnvOverrides({ + cdpEndpoint: 'http://127.0.0.1:9333', + }, async () => { + seenEndpoint = process.env.OPENCLI_CDP_ENDPOINT; + seenTarget = process.env.OPENCLI_CDP_TARGET; + }); + + expect(seenEndpoint).toBe('http://127.0.0.1:9333'); + expect(seenTarget).toBe('cursor'); + expect(process.env.OPENCLI_CDP_ENDPOINT).toBe('http://127.0.0.1:9222'); + expect(process.env.OPENCLI_CDP_TARGET).toBe('cursor'); + }); +}); diff --git a/src/runtime.ts b/src/runtime.ts index f3b63961..e5bdb491 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -2,6 +2,11 @@ import { BrowserBridge, CDPBridge } from './browser/index.js'; import type { IPage } from './types.js'; import { TimeoutError } from './errors.js'; +export type BrowserEnvOverrides = { + cdpEndpoint?: string; + cdpTarget?: string; +}; + /** * Returns the appropriate browser factory based on environment config. * Uses CDPBridge when OPENCLI_CDP_ENDPOINT is set, otherwise BrowserBridge. @@ -10,6 +15,42 @@ export function getBrowserFactory(): new () => IBrowserFactory { return (process.env.OPENCLI_CDP_ENDPOINT ? CDPBridge : BrowserBridge) as unknown as new () => IBrowserFactory; } +export function extractBrowserEnvOverrides(options?: Record | null): BrowserEnvOverrides { + const input = options ?? {}; + return { + cdpEndpoint: readStringOption(input['cdp-endpoint'] ?? input.cdpEndpoint), + cdpTarget: readStringOption(input['cdp-target'] ?? input.cdpTarget), + }; +} + +export async function withBrowserEnvOverrides( + overrides: BrowserEnvOverrides, + fn: () => Promise, +): Promise { + const pairs: Array<[key: 'OPENCLI_CDP_ENDPOINT' | 'OPENCLI_CDP_TARGET', value: string | undefined]> = [ + ['OPENCLI_CDP_ENDPOINT', overrides.cdpEndpoint], + ['OPENCLI_CDP_TARGET', overrides.cdpTarget], + ]; + const previous = new Map(); + + for (const [key, value] of pairs) { + if (value === undefined) continue; + previous.set(key, process.env[key]); + process.env[key] = value; + } + + try { + return await fn(); + } finally { + for (const [key, value] of pairs) { + if (value === undefined) continue; + const prior = previous.get(key); + if (prior === undefined) delete process.env[key]; + else process.env[key] = prior; + } + } +} + function parseEnvTimeout(envVar: string, fallback: number): number { const raw = process.env[envVar]; if (raw === undefined) return fallback; @@ -21,6 +62,12 @@ function parseEnvTimeout(envVar: string, fallback: number): number { return parsed; } +function readStringOption(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + export const DEFAULT_BROWSER_CONNECT_TIMEOUT = parseEnvTimeout('OPENCLI_BROWSER_CONNECT_TIMEOUT', 30); export const DEFAULT_BROWSER_COMMAND_TIMEOUT = parseEnvTimeout('OPENCLI_BROWSER_COMMAND_TIMEOUT', 60); export const DEFAULT_BROWSER_EXPLORE_TIMEOUT = parseEnvTimeout('OPENCLI_BROWSER_EXPLORE_TIMEOUT', 120);