From 36c6a9241ffef80d8c6ba23fc4d874b402ca8bd5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 25 Apr 2026 13:32:14 -0700 Subject: [PATCH] refactor(cli): split index.ts into per-verb handler modules (#1089) --- src/cli/args.ts | 120 ++ src/cli/browser.test.ts | 242 +++ src/cli/dispatch.ts | 58 + src/cli/flags.ts | 95 + src/cli/format.ts | 249 +++ src/cli/handlers/browser-capture.ts | 94 + src/cli/handlers/browser-cookie.ts | 73 + src/cli/handlers/browser-env.ts | 115 ++ src/cli/handlers/browser-interact.ts | 224 +++ src/cli/handlers/browser-nav.ts | 135 ++ src/cli/handlers/browser-storage.ts | 51 + src/cli/handlers/browser-tab.ts | 60 + src/cli/handlers/core.ts | 16 + src/cli/handlers/repo.ts | 38 + src/cli/handlers/terminal.ts | 152 ++ src/cli/handlers/worktree.ts | 69 + src/cli/help.ts | 296 +++ src/cli/index.test.ts | 538 +----- src/cli/index.ts | 2584 +------------------------- src/cli/selectors.ts | 152 ++ src/cli/specs/browser-advanced.ts | 279 +++ src/cli/specs/browser-basic.ts | 184 ++ src/cli/specs/core.ts | 199 ++ src/cli/specs/index.ts | 10 + src/cli/test-fixtures.ts | 62 + 25 files changed, 3039 insertions(+), 3056 deletions(-) create mode 100644 src/cli/args.ts create mode 100644 src/cli/browser.test.ts create mode 100644 src/cli/dispatch.ts create mode 100644 src/cli/flags.ts create mode 100644 src/cli/format.ts create mode 100644 src/cli/handlers/browser-capture.ts create mode 100644 src/cli/handlers/browser-cookie.ts create mode 100644 src/cli/handlers/browser-env.ts create mode 100644 src/cli/handlers/browser-interact.ts create mode 100644 src/cli/handlers/browser-nav.ts create mode 100644 src/cli/handlers/browser-storage.ts create mode 100644 src/cli/handlers/browser-tab.ts create mode 100644 src/cli/handlers/core.ts create mode 100644 src/cli/handlers/repo.ts create mode 100644 src/cli/handlers/terminal.ts create mode 100644 src/cli/handlers/worktree.ts create mode 100644 src/cli/help.ts create mode 100644 src/cli/selectors.ts create mode 100644 src/cli/specs/browser-advanced.ts create mode 100644 src/cli/specs/browser-basic.ts create mode 100644 src/cli/specs/core.ts create mode 100644 src/cli/specs/index.ts create mode 100644 src/cli/test-fixtures.ts diff --git a/src/cli/args.ts b/src/cli/args.ts new file mode 100644 index 000000000..e65c0b290 --- /dev/null +++ b/src/cli/args.ts @@ -0,0 +1,120 @@ +import { RuntimeClientError } from './runtime-client' + +export type ParsedArgs = { + commandPath: string[] + flags: Map +} + +export type CommandSpec = { + path: string[] + summary: string + usage: string + allowedFlags: string[] + examples?: string[] + notes?: string[] +} + +export const GLOBAL_FLAGS = ['help', 'json'] + +export function parseArgs(argv: string[]): ParsedArgs { + const commandPath: string[] = [] + const flags = new Map() + + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i] + if (!token.startsWith('--')) { + commandPath.push(token) + continue + } + + const flag = token.slice(2) + const next = argv[i + 1] + if (!next || next.startsWith('--')) { + flags.set(flag, true) + continue + } + flags.set(flag, next) + i += 1 + } + + return { commandPath, flags } +} + +export function resolveHelpPath(parsed: ParsedArgs): string[] | null { + if (parsed.commandPath[0] === 'help') { + return parsed.commandPath.slice(1) + } + if (parsed.flags.has('help')) { + return parsed.commandPath + } + return null +} + +export function matches(actual: string[], expected: string[]): boolean { + return ( + actual.length === expected.length && actual.every((value, index) => value === expected[index]) + ) +} + +export function supportsBrowserPageFlag(commandPath: string[]): boolean { + const joined = commandPath.join(' ') + if (['open', 'status'].includes(commandPath[0])) { + return false + } + if (['repo', 'worktree', 'terminal'].includes(commandPath[0])) { + return false + } + return !['tab list', 'tab create'].includes(joined) +} + +export function isCommandGroup(commandPath: string[]): boolean { + return ( + (commandPath.length === 1 && + [ + 'repo', + 'worktree', + 'terminal', + 'tab', + 'cookie', + 'intercept', + 'capture', + 'mouse', + 'set', + 'clipboard', + 'dialog', + 'storage' + ].includes(commandPath[0])) || + (commandPath.length === 2 && + commandPath[0] === 'storage' && + ['local', 'session'].includes(commandPath[1])) + ) +} + +export function findCommandSpec( + specs: CommandSpec[], + commandPath: string[] +): CommandSpec | undefined { + return specs.find((spec) => matches(spec.path, commandPath)) +} + +export function validateCommandAndFlags(specs: CommandSpec[], parsed: ParsedArgs): void { + const spec = findCommandSpec(specs, parsed.commandPath) + if (!spec) { + throw new RuntimeClientError( + 'invalid_argument', + `Unknown command: ${parsed.commandPath.join(' ')}` + ) + } + + for (const flag of parsed.flags.keys()) { + if ( + !spec.allowedFlags.includes(flag) && + !(flag === 'page' && supportsBrowserPageFlag(spec.path)) + ) { + throw new RuntimeClientError( + 'invalid_argument', + `Unknown flag --${flag} for command: ${spec.path.join(' ')}` + ) + } + } +} diff --git a/src/cli/browser.test.ts b/src/cli/browser.test.ts new file mode 100644 index 000000000..c0f518b8e --- /dev/null +++ b/src/cli/browser.test.ts @@ -0,0 +1,242 @@ +import path from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const callMock = vi.fn() + +vi.mock('./runtime-client', () => { + class RuntimeClient { + call = callMock + getCliStatus = vi.fn() + openOrca = vi.fn() + } + + class RuntimeClientError extends Error { + readonly code: string + + constructor(code: string, message: string) { + super(message) + this.code = code + } + } + + class RuntimeRpcFailureError extends RuntimeClientError { + readonly response: unknown + + constructor(response: unknown) { + super('runtime_error', 'runtime_error') + this.response = response + } + } + + return { + RuntimeClient, + RuntimeClientError, + RuntimeRpcFailureError + } +}) + +import { main } from './index' +import { RuntimeClientError } from './runtime-client' +import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures' + +describe('orca cli browser page targeting', () => { + beforeEach(() => { + callMock.mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('passes explicit page ids to snapshot without resolving the current worktree', async () => { + queueFixtures( + callMock, + okFixture('req_snapshot', { + browserPageId: 'page-1', + snapshot: 'tree', + refs: [], + url: 'https://example.com', + title: 'Example' + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['snapshot', '--page', 'page-1', '--json'], '/tmp/not-an-orca-worktree') + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.snapshot', { page: 'page-1' }) + }) + + it('resolves current worktree only when --page is combined with --worktree current', async () => { + queueFixtures( + callMock, + worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo')]), + okFixture('req_snapshot', { + browserPageId: 'page-1', + snapshot: 'tree', + refs: [], + url: 'https://example.com', + title: 'Example' + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['snapshot', '--page', 'page-1', '--worktree', 'current', '--json'], + '/tmp/repo/feature/src' + ) + + expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) + expect(callMock).toHaveBeenNthCalledWith(2, 'browser.snapshot', { + page: 'page-1', + worktree: `path:${path.resolve('/tmp/repo/feature')}` + }) + }) + + it('passes page-targeted tab switches through without auto-scoping to the current worktree', async () => { + queueFixtures(callMock, okFixture('req_switch', { switched: 2, browserPageId: 'page-2' })) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['tab', 'switch', '--page', 'page-2', '--json'], '/tmp/repo/feature/src') + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.tabSwitch', { + index: undefined, + page: 'page-2' + }) + }) + + it('still resolves the current worktree when tab switch --page is combined with --worktree current', async () => { + queueFixtures( + callMock, + worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo')]), + okFixture('req_switch', { switched: 2, browserPageId: 'page-2' }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['tab', 'switch', '--page', 'page-2', '--worktree', 'current', '--json'], + '/tmp/repo/feature/src' + ) + + expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) + expect(callMock).toHaveBeenNthCalledWith(2, 'browser.tabSwitch', { + index: undefined, + page: 'page-2', + worktree: `path:${path.resolve('/tmp/repo/feature')}` + }) + }) +}) + +describe('orca cli browser waits and viewport flags', () => { + beforeEach(() => { + callMock.mockReset() + process.exitCode = undefined + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('gives selector waits an explicit RPC timeout budget', async () => { + queueFixtures(callMock, okFixture('req_wait', { ok: true })) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['wait', '--selector', '#ready', '--worktree', 'all', '--json'], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).toHaveBeenCalledWith( + 'browser.wait', + { + selector: '#ready', + timeout: undefined, + text: undefined, + url: undefined, + load: undefined, + fn: undefined, + state: undefined, + worktree: undefined + }, + { timeoutMs: 60_000 } + ) + }) + + it('extends selector wait RPC timeout when the user passes --timeout', async () => { + queueFixtures(callMock, okFixture('req_wait', { ok: true })) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['wait', '--selector', '#ready', '--timeout', '12000', '--worktree', 'all', '--json'], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).toHaveBeenCalledWith( + 'browser.wait', + { + selector: '#ready', + timeout: 12000, + text: undefined, + url: undefined, + load: undefined, + fn: undefined, + state: undefined, + worktree: undefined + }, + { timeoutMs: 17000 } + ) + }) + + it('does not tell users Orca is down for a generic runtime timeout', async () => { + callMock.mockRejectedValueOnce( + new RuntimeClientError( + 'runtime_timeout', + 'Timed out waiting for the Orca runtime to respond.' + ) + ) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['wait', '--selector', '#ready', '--worktree', 'all'], '/tmp/not-an-orca-worktree') + + expect(errorSpy).toHaveBeenCalledWith('Timed out waiting for the Orca runtime to respond.') + }) + + it('passes the mobile viewport flag through to browser.viewport', async () => { + queueFixtures( + callMock, + okFixture('req_viewport', { + width: 375, + height: 812, + deviceScaleFactor: 2, + mobile: true + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'viewport', + '--width', + '375', + '--height', + '812', + '--scale', + '2', + '--mobile', + '--worktree', + 'all', + '--json' + ], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).toHaveBeenCalledWith('browser.viewport', { + width: 375, + height: 812, + deviceScaleFactor: 2, + mobile: true, + worktree: undefined + }) + }) +}) diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts new file mode 100644 index 000000000..fdde0baef --- /dev/null +++ b/src/cli/dispatch.ts @@ -0,0 +1,58 @@ +import type { RuntimeClient } from './runtime-client' +import { RuntimeClientError } from './runtime-client' +import { CORE_HANDLERS } from './handlers/core' +import { REPO_HANDLERS } from './handlers/repo' +import { WORKTREE_HANDLERS } from './handlers/worktree' +import { TERMINAL_HANDLERS } from './handlers/terminal' +import { BROWSER_NAV_HANDLERS } from './handlers/browser-nav' +import { BROWSER_INTERACT_HANDLERS } from './handlers/browser-interact' +import { BROWSER_TAB_HANDLERS } from './handlers/browser-tab' +import { BROWSER_COOKIE_HANDLERS } from './handlers/browser-cookie' +import { BROWSER_CAPTURE_HANDLERS } from './handlers/browser-capture' +import { BROWSER_ENV_HANDLERS } from './handlers/browser-env' +import { BROWSER_STORAGE_HANDLERS } from './handlers/browser-storage' + +export type HandlerContext = { + flags: Map + client: RuntimeClient + cwd: string + json: boolean +} + +export type CommandHandler = (ctx: HandlerContext) => Promise + +function buildHandlers(): Map { + const table = new Map() + const groups = [ + CORE_HANDLERS, + REPO_HANDLERS, + WORKTREE_HANDLERS, + TERMINAL_HANDLERS, + BROWSER_NAV_HANDLERS, + BROWSER_INTERACT_HANDLERS, + BROWSER_TAB_HANDLERS, + BROWSER_COOKIE_HANDLERS, + BROWSER_CAPTURE_HANDLERS, + BROWSER_ENV_HANDLERS, + BROWSER_STORAGE_HANDLERS + ] + for (const group of groups) { + for (const [key, handler] of Object.entries(group)) { + if (table.has(key)) { + throw new Error(`Duplicate CLI handler registration for "${key}"`) + } + table.set(key, handler) + } + } + return table +} + +const HANDLERS = buildHandlers() + +export async function dispatch(commandPath: string[], ctx: HandlerContext): Promise { + const handler = HANDLERS.get(commandPath.join(' ')) + if (!handler) { + throw new RuntimeClientError('invalid_argument', `Unknown command: ${commandPath.join(' ')}`) + } + await handler(ctx) +} diff --git a/src/cli/flags.ts b/src/cli/flags.ts new file mode 100644 index 000000000..3c3383dd6 --- /dev/null +++ b/src/cli/flags.ts @@ -0,0 +1,95 @@ +import { RuntimeClientError } from './runtime-client' + +export function getRequiredStringFlag(flags: Map, name: string): string { + const value = flags.get(name) + if (typeof value === 'string' && value.length > 0) { + return value + } + throw new RuntimeClientError('invalid_argument', `Missing required --${name}`) +} + +export function getOptionalStringFlag( + flags: Map, + name: string +): string | undefined { + const value = flags.get(name) + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +export function getOptionalNumberFlag( + flags: Map, + name: string +): number | undefined { + const value = flags.get(name) + if (typeof value !== 'string' || value.length === 0) { + return undefined + } + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + throw new RuntimeClientError('invalid_argument', `Invalid numeric value for --${name}`) + } + return parsed +} + +export function getOptionalPositiveIntegerFlag( + flags: Map, + name: string +): number | undefined { + const value = getOptionalNumberFlag(flags, name) + if (value === undefined) { + return undefined + } + if (!Number.isInteger(value) || value <= 0) { + throw new RuntimeClientError('invalid_argument', `Invalid positive integer for --${name}`) + } + return value +} + +export function getOptionalNonNegativeIntegerFlag( + flags: Map, + name: string +): number | undefined { + const value = getOptionalNumberFlag(flags, name) + if (value === undefined) { + return undefined + } + if (!Number.isInteger(value) || value < 0) { + throw new RuntimeClientError('invalid_argument', `Invalid non-negative integer for --${name}`) + } + return value +} + +export function getRequiredPositiveNumber( + flags: Map, + name: string +): number { + const raw = getRequiredStringFlag(flags, name) + const value = Number(raw) + if (!Number.isFinite(value) || value <= 0) { + throw new RuntimeClientError('invalid_argument', `--${name} must be a positive number`) + } + return value +} + +export function getRequiredFiniteNumber( + flags: Map, + name: string +): number { + const raw = getRequiredStringFlag(flags, name) + const value = Number(raw) + if (!Number.isFinite(value)) { + throw new RuntimeClientError('invalid_argument', `--${name} must be a valid number`) + } + return value +} + +export function getOptionalNullableNumberFlag( + flags: Map, + name: string +): number | null | undefined { + const value = flags.get(name) + if (value === 'null') { + return null + } + return getOptionalNumberFlag(flags, name) +} diff --git a/src/cli/format.ts b/src/cli/format.ts new file mode 100644 index 000000000..b1d1ac432 --- /dev/null +++ b/src/cli/format.ts @@ -0,0 +1,249 @@ +import type { + BrowserScreenshotResult, + BrowserSnapshotResult, + BrowserTabListResult, + CliStatusResult, + RuntimeRepoList, + RuntimeRepoSearchRefs, + RuntimeTerminalClose, + RuntimeTerminalCreate, + RuntimeTerminalFocus, + RuntimeTerminalListResult, + RuntimeTerminalRead, + RuntimeTerminalRename, + RuntimeTerminalSend, + RuntimeTerminalShow, + RuntimeTerminalSplit, + RuntimeTerminalWait, + RuntimeWorktreeListResult, + RuntimeWorktreePsResult, + RuntimeWorktreeRecord +} from '../shared/runtime-types' +import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client' +import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client' + +export function printResult( + response: RuntimeRpcSuccess, + json: boolean, + formatter: (value: TResult) => string +): void { + if (json) { + console.log(JSON.stringify(response, null, 2)) + return + } + console.log(formatter(response.result)) +} + +export function formatCliError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error) + if (error instanceof RuntimeClientError && error.code === 'runtime_unavailable') { + return `${message}\nOrca is not running. Run 'orca open' first.` + } + if ( + error instanceof RuntimeRpcFailureError && + error.response.error.code === 'runtime_unavailable' + ) { + return `${message}\nOrca is not running. Run 'orca open' first.` + } + return message +} + +export function reportCliError(error: unknown, json: boolean): void { + if (json) { + if (error instanceof RuntimeRpcFailureError) { + console.log(JSON.stringify(error.response, null, 2)) + } else { + const response: RuntimeRpcFailure = { + id: 'local', + ok: false, + error: { + code: error instanceof RuntimeClientError ? error.code : 'runtime_error', + message: formatCliError(error) + }, + _meta: { + runtimeId: null + } + } + console.log(JSON.stringify(response, null, 2)) + } + } else { + console.error(formatCliError(error)) + } +} + +export function formatCliStatus(status: CliStatusResult): string { + return [ + `appRunning: ${status.app.running}`, + `pid: ${status.app.pid ?? 'none'}`, + `runtimeState: ${status.runtime.state}`, + `runtimeReachable: ${status.runtime.reachable}`, + `runtimeId: ${status.runtime.runtimeId ?? 'none'}`, + `graphState: ${status.graph.state}` + ].join('\n') +} + +export function formatStatus(status: CliStatusResult): string { + return formatCliStatus(status) +} + +export function formatTerminalList(result: RuntimeTerminalListResult): string { + if (result.terminals.length === 0) { + return 'No live terminals.' + } + const body = result.terminals + .map( + (terminal) => + `${terminal.handle} ${terminal.title ?? '(untitled)'} ${terminal.connected ? 'connected' : 'disconnected'} ${terminal.worktreePath}\n${terminal.preview ? `preview: ${terminal.preview}` : 'preview: '}` + ) + .join('\n\n') + return result.truncated + ? `${body}\n\ntruncated: showing ${result.terminals.length} of ${result.totalCount}` + : body +} + +export function formatTerminalShow(result: { terminal: RuntimeTerminalShow }): string { + const terminal = result.terminal + return [ + `handle: ${terminal.handle}`, + `title: ${terminal.title ?? '(untitled)'}`, + `worktree: ${terminal.worktreePath}`, + `branch: ${terminal.branch}`, + `leaf: ${terminal.leafId}`, + `ptyId: ${terminal.ptyId ?? 'none'}`, + `connected: ${terminal.connected}`, + `writable: ${terminal.writable}`, + `preview: ${terminal.preview || ''}` + ].join('\n') +} + +export function formatTerminalRead(result: { terminal: RuntimeTerminalRead }): string { + const terminal = result.terminal + const header = [ + `handle: ${terminal.handle}`, + `status: ${terminal.status}`, + ...(terminal.nextCursor !== null ? [`cursor: ${terminal.nextCursor}`] : []) + ] + return [...header, '', ...terminal.tail].join('\n') +} + +export function formatTerminalSend(result: { send: RuntimeTerminalSend }): string { + return `Sent ${result.send.bytesWritten} bytes to ${result.send.handle}.` +} + +export function formatTerminalRename(result: { rename: RuntimeTerminalRename }): string { + return result.rename.title + ? `Renamed terminal ${result.rename.handle} to "${result.rename.title}".` + : `Cleared title for terminal ${result.rename.handle}.` +} + +export function formatTerminalCreate(result: { terminal: RuntimeTerminalCreate }): string { + const titleNote = result.terminal.title ? ` (title: "${result.terminal.title}")` : '' + return `Created terminal ${result.terminal.handle}${titleNote}` +} + +export function formatTerminalSplit(result: { split: RuntimeTerminalSplit }): string { + return `Split pane ${result.split.handle} in tab ${result.split.tabId}` +} + +export function formatTerminalFocus(result: { focus: RuntimeTerminalFocus }): string { + return `Focused terminal ${result.focus.handle} (tab ${result.focus.tabId}).` +} + +export function formatTerminalClose(result: { close: RuntimeTerminalClose }): string { + const ptyNote = result.close.ptyKilled ? ' PTY killed.' : '' + return `Closed terminal ${result.close.handle}.${ptyNote}` +} + +export function formatTerminalWait(result: { wait: RuntimeTerminalWait }): string { + return [ + `handle: ${result.wait.handle}`, + `condition: ${result.wait.condition}`, + `satisfied: ${result.wait.satisfied}`, + `status: ${result.wait.status}`, + `exitCode: ${result.wait.exitCode ?? 'null'}` + ].join('\n') +} + +export function formatWorktreePs(result: RuntimeWorktreePsResult): string { + if (result.worktrees.length === 0) { + return 'No worktrees found.' + } + const body = result.worktrees + .map( + (worktree) => + `${worktree.repo} ${worktree.branch} live:${worktree.liveTerminalCount} pty:${worktree.hasAttachedPty ? 'yes' : 'no'} unread:${worktree.unread ? 'yes' : 'no'}\n${worktree.path}${worktree.preview ? `\npreview: ${worktree.preview}` : ''}` + ) + .join('\n\n') + return result.truncated + ? `${body}\n\ntruncated: showing ${result.worktrees.length} of ${result.totalCount}` + : body +} + +export function formatRepoList(result: RuntimeRepoList): string { + if (result.repos.length === 0) { + return 'No repos found.' + } + return result.repos.map((repo) => `${repo.id} ${repo.displayName} ${repo.path}`).join('\n') +} + +export function formatRepoShow(result: { repo: Record }): string { + return Object.entries(result.repo) + .map( + ([key, value]) => + `${key}: ${typeof value === 'object' ? JSON.stringify(value) : String(value)}` + ) + .join('\n') +} + +export function formatRepoRefs(result: RuntimeRepoSearchRefs): string { + if (result.refs.length === 0) { + return 'No refs found.' + } + return result.truncated ? `${result.refs.join('\n')}\n\ntruncated: yes` : result.refs.join('\n') +} + +export function formatWorktreeList(result: RuntimeWorktreeListResult): string { + if (result.worktrees.length === 0) { + return 'No worktrees found.' + } + const body = result.worktrees + .map( + (worktree) => + `${String(worktree.id)} ${String(worktree.branch)} ${String(worktree.path)}\ndisplayName: ${String(worktree.displayName ?? '')}\nlinkedIssue: ${String(worktree.linkedIssue ?? 'null')}\ncomment: ${String(worktree.comment ?? '')}` + ) + .join('\n\n') + return result.truncated + ? `${body}\n\ntruncated: showing ${result.worktrees.length} of ${result.totalCount}` + : body +} + +export function formatWorktreeShow(result: { worktree: RuntimeWorktreeRecord }): string { + const worktree = result.worktree + return Object.entries(worktree) + .map( + ([key, value]) => + `${key}: ${typeof value === 'object' ? JSON.stringify(value) : String(value)}` + ) + .join('\n') +} + +export function formatSnapshot(result: BrowserSnapshotResult): string { + const header = `page: ${result.browserPageId}\n${result.title} — ${result.url}\n` + return header + result.snapshot +} + +export function formatScreenshot(result: BrowserScreenshotResult): string { + return `Screenshot captured (${result.format}, ${Math.round(result.data.length * 0.75)} bytes)` +} + +export function formatTabList(result: BrowserTabListResult): string { + if (result.tabs.length === 0) { + return 'No browser tabs open.' + } + return result.tabs + .map((t) => { + const marker = t.active ? '* ' : ' ' + return `${marker}[${t.index}] ${t.browserPageId} ${t.title} — ${t.url}` + }) + .join('\n') +} diff --git a/src/cli/handlers/browser-capture.ts b/src/cli/handlers/browser-capture.ts new file mode 100644 index 000000000..5c661696d --- /dev/null +++ b/src/cli/handlers/browser-capture.ts @@ -0,0 +1,94 @@ +import type { + BrowserCaptureStartResult, + BrowserCaptureStopResult, + BrowserConsoleResult, + BrowserInterceptDisableResult, + BrowserInterceptEnableResult, + BrowserInterceptedRequest, + BrowserNetworkLogResult +} from '../../shared/runtime-types' +import type { CommandHandler } from '../dispatch' +import { printResult } from '../format' +import { getOptionalPositiveIntegerFlag, getOptionalStringFlag } from '../flags' +import { getBrowserCommandTarget } from '../selectors' + +export const BROWSER_CAPTURE_HANDLERS: Record = { + 'intercept enable': async ({ flags, client, cwd, json }) => { + const params: Record = {} + const patternsStr = getOptionalStringFlag(flags, 'patterns') + if (patternsStr) { + params.patterns = patternsStr.split(',').map((p) => p.trim()) + } + Object.assign(params, await getBrowserCommandTarget(flags, cwd, client)) + const result = await client.call( + 'browser.intercept.enable', + params + ) + printResult( + result, + json, + (v) => `Interception enabled for: ${(v.patterns ?? []).join(', ') || '*'}` + ) + }, + 'intercept disable': async ({ flags, client, cwd, json }) => { + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call( + 'browser.intercept.disable', + target + ) + printResult(result, json, () => 'Interception disabled') + }, + 'intercept list': async ({ flags, client, cwd, json }) => { + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call<{ requests: BrowserInterceptedRequest[] }>( + 'browser.intercept.list', + target + ) + printResult(result, json, (v) => { + if (v.requests.length === 0) { + return 'No paused requests' + } + return v.requests.map((r) => `[${r.id}] ${r.method} ${r.url} (${r.resourceType})`).join('\n') + }) + }, + 'capture start': async ({ flags, client, cwd, json }) => { + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.capture.start', target) + printResult(result, json, () => 'Capture started (console + network)') + }, + 'capture stop': async ({ flags, client, cwd, json }) => { + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.capture.stop', target) + printResult(result, json, () => 'Capture stopped') + }, + console: async ({ flags, client, cwd, json }) => { + const params: Record = {} + const limit = getOptionalPositiveIntegerFlag(flags, 'limit') + if (limit !== undefined) { + params.limit = limit + } + Object.assign(params, await getBrowserCommandTarget(flags, cwd, client)) + const result = await client.call('browser.console', params) + printResult(result, json, (v) => { + if (v.entries.length === 0) { + return 'No console entries' + } + return v.entries.map((e) => `[${e.level}] ${e.text}`).join('\n') + }) + }, + network: async ({ flags, client, cwd, json }) => { + const params: Record = {} + const limit = getOptionalPositiveIntegerFlag(flags, 'limit') + if (limit !== undefined) { + params.limit = limit + } + Object.assign(params, await getBrowserCommandTarget(flags, cwd, client)) + const result = await client.call('browser.network', params) + printResult(result, json, (v) => { + if (v.entries.length === 0) { + return 'No network entries' + } + return v.entries.map((e) => `${e.status} ${e.url} (${e.mimeType}, ${e.size}B)`).join('\n') + }) + } +} diff --git a/src/cli/handlers/browser-cookie.ts b/src/cli/handlers/browser-cookie.ts new file mode 100644 index 000000000..ba46ae721 --- /dev/null +++ b/src/cli/handlers/browser-cookie.ts @@ -0,0 +1,73 @@ +import type { + BrowserCookieDeleteResult, + BrowserCookieGetResult, + BrowserCookieSetResult +} from '../../shared/runtime-types' +import type { CommandHandler } from '../dispatch' +import { printResult } from '../format' +import { getOptionalStringFlag, getRequiredStringFlag } from '../flags' +import { getBrowserCommandTarget } from '../selectors' + +export const BROWSER_COOKIE_HANDLERS: Record = { + 'cookie get': async ({ flags, client, cwd, json }) => { + const url = getOptionalStringFlag(flags, 'url') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.cookie.get', { + url, + ...target + }) + printResult(result, json, (v) => { + if (v.cookies.length === 0) { + return 'No cookies' + } + return v.cookies.map((c) => `${c.name}=${c.value} (${c.domain})`).join('\n') + }) + }, + 'cookie set': async ({ flags, client, cwd, json }) => { + const name = getRequiredStringFlag(flags, 'name') + const value = getRequiredStringFlag(flags, 'value') + const params: Record = { name, value } + const domain = getOptionalStringFlag(flags, 'domain') + const path = getOptionalStringFlag(flags, 'path') + const sameSite = getOptionalStringFlag(flags, 'sameSite') + const expires = getOptionalStringFlag(flags, 'expires') + if (domain) { + params.domain = domain + } + if (path) { + params.path = path + } + if (flags.has('secure')) { + params.secure = true + } + if (flags.has('httpOnly')) { + params.httpOnly = true + } + if (sameSite) { + params.sameSite = sameSite + } + if (expires) { + params.expires = Number(expires) + } + Object.assign(params, await getBrowserCommandTarget(flags, cwd, client)) + const result = await client.call('browser.cookie.set', params) + printResult(result, json, (v) => + v.success ? `Cookie "${name}" set` : `Failed to set cookie "${name}"` + ) + }, + 'cookie delete': async ({ flags, client, cwd, json }) => { + const name = getRequiredStringFlag(flags, 'name') + const params: Record = { name } + const domain = getOptionalStringFlag(flags, 'domain') + const url = getOptionalStringFlag(flags, 'url') + if (domain) { + params.domain = domain + } + if (url) { + params.url = url + } + Object.assign(params, await getBrowserCommandTarget(flags, cwd, client)) + const result = await client.call('browser.cookie.delete', params) + printResult(result, json, () => `Cookie "${name}" deleted`) + } +} diff --git a/src/cli/handlers/browser-env.ts b/src/cli/handlers/browser-env.ts new file mode 100644 index 000000000..e2b7171cd --- /dev/null +++ b/src/cli/handlers/browser-env.ts @@ -0,0 +1,115 @@ +import type { BrowserGeolocationResult, BrowserViewportResult } from '../../shared/runtime-types' +import type { CommandHandler } from '../dispatch' +import { printResult } from '../format' +import { + getOptionalStringFlag, + getRequiredFiniteNumber, + getRequiredPositiveNumber, + getRequiredStringFlag +} from '../flags' +import { RuntimeClientError } from '../runtime-client' +import { getBrowserCommandTarget } from '../selectors' + +export const BROWSER_ENV_HANDLERS: Record = { + viewport: async ({ flags, client, cwd, json }) => { + const width = getRequiredPositiveNumber(flags, 'width') + const height = getRequiredPositiveNumber(flags, 'height') + const params: Record = { width, height } + const scale = getOptionalStringFlag(flags, 'scale') + if (scale) { + const n = Number(scale) + if (!Number.isFinite(n) || n <= 0) { + throw new RuntimeClientError('invalid_argument', '--scale must be a positive number') + } + params.deviceScaleFactor = n + } + if (flags.has('mobile')) { + params.mobile = true + } + Object.assign(params, await getBrowserCommandTarget(flags, cwd, client)) + const result = await client.call('browser.viewport', params) + printResult( + result, + json, + (v) => `Viewport set to ${v.width}×${v.height}${v.mobile ? ' (mobile)' : ''}` + ) + }, + geolocation: async ({ flags, client, cwd, json }) => { + const latitude = getRequiredFiniteNumber(flags, 'latitude') + const longitude = getRequiredFiniteNumber(flags, 'longitude') + const params: Record = { latitude, longitude } + const accuracy = getOptionalStringFlag(flags, 'accuracy') + if (accuracy) { + const n = Number(accuracy) + if (!Number.isFinite(n) || n <= 0) { + throw new RuntimeClientError('invalid_argument', '--accuracy must be a positive number') + } + params.accuracy = n + } + Object.assign(params, await getBrowserCommandTarget(flags, cwd, client)) + const result = await client.call('browser.geolocation', params) + printResult(result, json, (v) => `Geolocation set to ${v.latitude}, ${v.longitude}`) + }, + 'set device': async ({ flags, client, cwd, json }) => { + const name = getRequiredStringFlag(flags, 'name') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.setDevice', { name, ...target }) + printResult(result, json, () => `Device emulation set to ${name}`) + }, + 'set offline': async ({ flags, client, cwd, json }) => { + const state = getOptionalStringFlag(flags, 'state') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.setOffline', { state, ...target }) + printResult(result, json, () => `Offline mode ${state ?? 'toggled'}`) + }, + 'set headers': async ({ flags, client, cwd, json }) => { + const headers = getRequiredStringFlag(flags, 'headers') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.setHeaders', { headers, ...target }) + printResult(result, json, () => 'Extra HTTP headers set') + }, + 'set credentials': async ({ flags, client, cwd, json }) => { + const user = getRequiredStringFlag(flags, 'user') + const pass = getRequiredStringFlag(flags, 'pass') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.setCredentials', { + user, + pass, + ...target + }) + printResult(result, json, () => `HTTP auth credentials set for ${user}`) + }, + 'set media': async ({ flags, client, cwd, json }) => { + const colorScheme = getOptionalStringFlag(flags, 'color-scheme') + const reducedMotion = getOptionalStringFlag(flags, 'reduced-motion') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.setMedia', { + colorScheme, + reducedMotion, + ...target + }) + printResult(result, json, () => 'Media preferences set') + }, + 'clipboard read': async ({ flags, client, cwd, json }) => { + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.clipboardRead', target) + printResult(result, json, (v) => JSON.stringify(v, null, 2)) + }, + 'clipboard write': async ({ flags, client, cwd, json }) => { + const text = getRequiredStringFlag(flags, 'text') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.clipboardWrite', { text, ...target }) + printResult(result, json, () => 'Clipboard updated') + }, + 'dialog accept': async ({ flags, client, cwd, json }) => { + const text = getOptionalStringFlag(flags, 'text') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.dialogAccept', { text, ...target }) + printResult(result, json, () => 'Dialog accepted') + }, + 'dialog dismiss': async ({ flags, client, cwd, json }) => { + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.dialogDismiss', target) + printResult(result, json, () => 'Dialog dismissed') + } +} diff --git a/src/cli/handlers/browser-interact.ts b/src/cli/handlers/browser-interact.ts new file mode 100644 index 000000000..0274f4028 --- /dev/null +++ b/src/cli/handlers/browser-interact.ts @@ -0,0 +1,224 @@ +import type { + BrowserCheckResult, + BrowserClearResult, + BrowserClickResult, + BrowserDragResult, + BrowserFillResult, + BrowserFocusResult, + BrowserHoverResult, + BrowserKeypressResult, + BrowserSelectAllResult, + BrowserSelectResult, + BrowserTypeResult, + BrowserUploadResult +} from '../../shared/runtime-types' +import type { CommandHandler } from '../dispatch' +import { printResult } from '../format' +import { + getOptionalNumberFlag, + getOptionalStringFlag, + getRequiredFiniteNumber, + getRequiredStringFlag +} from '../flags' +import { getBrowserCommandTarget } from '../selectors' + +const checkHandler = + (checked: boolean): CommandHandler => + async ({ flags, client, cwd, json }) => { + const element = getRequiredStringFlag(flags, 'element') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.check', { + element, + checked, + ...target + }) + printResult(result, json, (v) => (v.checked ? `Checked ${element}` : `Unchecked ${element}`)) + } + +export const BROWSER_INTERACT_HANDLERS: Record = { + click: async ({ flags, client, cwd, json }) => { + const element = getRequiredStringFlag(flags, 'element') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.click', { element, ...target }) + printResult(result, json, (v) => `Clicked ${v.clicked}`) + }, + dblclick: async ({ flags, client, cwd, json }) => { + const element = getRequiredStringFlag(flags, 'element') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.dblclick', { element, ...target }) + printResult(result, json, () => `Double-clicked ${element}`) + }, + fill: async ({ flags, client, cwd, json }) => { + const element = getRequiredStringFlag(flags, 'element') + const value = getRequiredStringFlag(flags, 'value') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.fill', { + element, + value, + ...target + }) + printResult(result, json, (v) => `Filled ${v.filled}`) + }, + type: async ({ flags, client, cwd, json }) => { + const input = getRequiredStringFlag(flags, 'input') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.type', { input, ...target }) + printResult(result, json, () => 'Typed input') + }, + select: async ({ flags, client, cwd, json }) => { + const element = getRequiredStringFlag(flags, 'element') + const value = getRequiredStringFlag(flags, 'value') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.select', { + element, + value, + ...target + }) + printResult(result, json, (v) => `Selected ${v.selected}`) + }, + check: checkHandler(true), + uncheck: checkHandler(false), + focus: async ({ flags, client, cwd, json }) => { + const element = getRequiredStringFlag(flags, 'element') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.focus', { element, ...target }) + printResult(result, json, (v) => `Focused ${v.focused}`) + }, + clear: async ({ flags, client, cwd, json }) => { + const element = getRequiredStringFlag(flags, 'element') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.clear', { element, ...target }) + printResult(result, json, (v) => `Cleared ${v.cleared}`) + }, + 'select-all': async ({ flags, client, cwd, json }) => { + const element = getRequiredStringFlag(flags, 'element') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.selectAll', { + element, + ...target + }) + printResult(result, json, (v) => `Selected all in ${v.selected}`) + }, + keypress: async ({ flags, client, cwd, json }) => { + const key = getRequiredStringFlag(flags, 'key') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.keypress', { + key, + ...target + }) + printResult(result, json, (v) => `Pressed ${v.pressed}`) + }, + hover: async ({ flags, client, cwd, json }) => { + const element = getRequiredStringFlag(flags, 'element') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.hover', { element, ...target }) + printResult(result, json, (v) => `Hovered ${v.hovered}`) + }, + drag: async ({ flags, client, cwd, json }) => { + const from = getRequiredStringFlag(flags, 'from') + const to = getRequiredStringFlag(flags, 'to') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.drag', { from, to, ...target }) + printResult(result, json, (v) => `Dragged ${v.dragged.from} → ${v.dragged.to}`) + }, + upload: async ({ flags, client, cwd, json }) => { + const element = getRequiredStringFlag(flags, 'element') + const filesStr = getRequiredStringFlag(flags, 'files') + const files = filesStr.split(',').map((f) => f.trim()) + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.upload', { + element, + files, + ...target + }) + printResult(result, json, (v) => `Uploaded ${v.uploaded} file(s)`) + }, + scrollintoview: async ({ flags, client, cwd, json }) => { + const element = getRequiredStringFlag(flags, 'element') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.scrollIntoView', { element, ...target }) + printResult(result, json, () => `Scrolled ${element} into view`) + }, + get: async ({ flags, client, cwd, json }) => { + const what = getRequiredStringFlag(flags, 'what') + const element = getOptionalStringFlag(flags, 'element') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.get', { + what, + selector: element, + ...target + }) + printResult(result, json, (v) => (typeof v === 'string' ? v : JSON.stringify(v, null, 2))) + }, + is: async ({ flags, client, cwd, json }) => { + const what = getRequiredStringFlag(flags, 'what') + const element = getRequiredStringFlag(flags, 'element') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.is', { + what, + selector: element, + ...target + }) + printResult(result, json, (v) => String(v)) + }, + inserttext: async ({ flags, client, cwd, json }) => { + const text = getRequiredStringFlag(flags, 'text') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.keyboardInsertText', { text, ...target }) + printResult(result, json, () => 'Text inserted') + }, + 'mouse move': async ({ flags, client, cwd, json }) => { + const x = getRequiredFiniteNumber(flags, 'x') + const y = getRequiredFiniteNumber(flags, 'y') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.mouseMove', { x, y, ...target }) + printResult(result, json, () => `Mouse moved to ${x},${y}`) + }, + 'mouse down': async ({ flags, client, cwd, json }) => { + const button = getOptionalStringFlag(flags, 'button') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.mouseDown', { button, ...target }) + printResult(result, json, () => `Mouse button ${button ?? 'left'} pressed`) + }, + 'mouse up': async ({ flags, client, cwd, json }) => { + const button = getOptionalStringFlag(flags, 'button') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.mouseUp', { button, ...target }) + printResult(result, json, () => `Mouse button ${button ?? 'left'} released`) + }, + 'mouse wheel': async ({ flags, client, cwd, json }) => { + const dy = getRequiredFiniteNumber(flags, 'dy') + const dx = getOptionalNumberFlag(flags, 'dx') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.mouseWheel', { dy, dx, ...target }) + printResult(result, json, () => `Mouse wheel scrolled dy=${dy}${dx != null ? ` dx=${dx}` : ''}`) + }, + find: async ({ flags, client, cwd, json }) => { + const locator = getRequiredStringFlag(flags, 'locator') + const value = getRequiredStringFlag(flags, 'value') + const action = getRequiredStringFlag(flags, 'action') + const text = getOptionalStringFlag(flags, 'text') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.find', { + locator, + value, + action, + text, + ...target + }) + printResult(result, json, (v) => JSON.stringify(v, null, 2)) + }, + download: async ({ flags, client, cwd, json }) => { + const selector = getRequiredStringFlag(flags, 'selector') + const path = getRequiredStringFlag(flags, 'path') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.download', { selector, path, ...target }) + printResult(result, json, () => `Downloaded to ${path}`) + }, + highlight: async ({ flags, client, cwd, json }) => { + const selector = getRequiredStringFlag(flags, 'selector') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.highlight', { selector, ...target }) + printResult(result, json, () => `Highlighted ${selector}`) + } +} diff --git a/src/cli/handlers/browser-nav.ts b/src/cli/handlers/browser-nav.ts new file mode 100644 index 000000000..e3a20d475 --- /dev/null +++ b/src/cli/handlers/browser-nav.ts @@ -0,0 +1,135 @@ +import type { + BrowserBackResult, + BrowserEvalResult, + BrowserGotoResult, + BrowserPdfResult, + BrowserReloadResult, + BrowserScreenshotResult, + BrowserScrollResult, + BrowserSnapshotResult, + BrowserWaitResult +} from '../../shared/runtime-types' +import type { CommandHandler } from '../dispatch' +import { formatScreenshot, formatSnapshot, printResult } from '../format' +import { + getOptionalPositiveIntegerFlag, + getOptionalStringFlag, + getRequiredStringFlag +} from '../flags' +import { RuntimeClientError } from '../runtime-client' +import { getBrowserCommandTarget } from '../selectors' + +// Why: selector/text/url waits can legitimately take longer than a normal RPC +// round-trip, even when Orca is healthy. Give browser.wait an explicit timeout +// budget so slow waits do not get mislabeled as "Orca is not running" by the +// generic client timeout path. +const DEFAULT_BROWSER_WAIT_RPC_TIMEOUT_MS = 60_000 + +export const BROWSER_NAV_HANDLERS: Record = { + snapshot: async ({ flags, client, cwd, json }) => { + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.snapshot', target) + printResult(result, json, formatSnapshot) + }, + screenshot: async ({ flags, client, cwd, json }) => { + const format = getOptionalStringFlag(flags, 'format') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.screenshot', { + format: format === 'jpeg' ? 'jpeg' : undefined, + ...target + }) + printResult(result, json, formatScreenshot) + }, + goto: async ({ flags, client, cwd, json }) => { + const url = getRequiredStringFlag(flags, 'url') + const target = await getBrowserCommandTarget(flags, cwd, client) + // Why: navigation waits for network idle which can exceed the default 15s RPC timeout + const result = await client.call( + 'browser.goto', + { url, ...target }, + { timeoutMs: 60_000 } + ) + printResult(result, json, (v) => `Navigated to ${v.url} — ${v.title}`) + }, + back: async ({ flags, client, cwd, json }) => { + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.back', target) + printResult(result, json, (v) => `Back to ${v.url} — ${v.title}`) + }, + reload: async ({ flags, client, cwd, json }) => { + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.reload', target, { + timeoutMs: 60_000 + }) + printResult(result, json, (v) => `Reloaded ${v.url} — ${v.title}`) + }, + forward: async ({ flags, client, cwd, json }) => { + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.forward', target) + printResult(result, json, (v) => { + const url = (v as { url?: string } | null | undefined)?.url + return url ? `Navigated forward to ${url}` : 'Navigated forward' + }) + }, + eval: async ({ flags, client, cwd, json }) => { + const expression = getRequiredStringFlag(flags, 'expression') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.eval', { expression, ...target }) + printResult(result, json, (v) => v.result) + }, + scroll: async ({ flags, client, cwd, json }) => { + const direction = getRequiredStringFlag(flags, 'direction') + if (direction !== 'up' && direction !== 'down') { + throw new RuntimeClientError('invalid_argument', '--direction must be "up" or "down"') + } + const amount = getOptionalPositiveIntegerFlag(flags, 'amount') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.scroll', { + direction, + amount, + ...target + }) + printResult(result, json, (v) => `Scrolled ${v.scrolled}`) + }, + wait: async ({ flags, client, cwd, json }) => { + const selector = getOptionalStringFlag(flags, 'selector') + const timeout = getOptionalPositiveIntegerFlag(flags, 'timeout') + const text = getOptionalStringFlag(flags, 'text') + const url = getOptionalStringFlag(flags, 'url') + const load = getOptionalStringFlag(flags, 'load') + const fn = getOptionalStringFlag(flags, 'fn') + const state = getOptionalStringFlag(flags, 'state') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call( + 'browser.wait', + { + selector, + timeout, + text, + url, + load, + fn, + state, + ...target + }, + { + timeoutMs: timeout ? timeout + 5000 : DEFAULT_BROWSER_WAIT_RPC_TIMEOUT_MS + } + ) + printResult(result, json, (v) => JSON.stringify(v, null, 2)) + }, + pdf: async ({ flags, client, cwd, json }) => { + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.pdf', target) + printResult(result, json, (v) => `PDF exported (${v.data.length} bytes base64)`) + }, + 'full-screenshot': async ({ flags, client, cwd, json }) => { + const format = getOptionalStringFlag(flags, 'format') === 'jpeg' ? 'jpeg' : 'png' + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.fullScreenshot', { + format, + ...target + }) + printResult(result, json, (v) => `Full-page screenshot captured (${v.format})`) + } +} diff --git a/src/cli/handlers/browser-storage.ts b/src/cli/handlers/browser-storage.ts new file mode 100644 index 000000000..c7acbf40d --- /dev/null +++ b/src/cli/handlers/browser-storage.ts @@ -0,0 +1,51 @@ +import type { CommandHandler } from '../dispatch' +import { printResult } from '../format' +import { getRequiredStringFlag } from '../flags' +import { getBrowserCommandTarget } from '../selectors' + +export const BROWSER_STORAGE_HANDLERS: Record = { + 'storage local get': async ({ flags, client, cwd, json }) => { + const key = getRequiredStringFlag(flags, 'key') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.storage.local.get', { key, ...target }) + printResult(result, json, (v) => JSON.stringify(v, null, 2)) + }, + 'storage local set': async ({ flags, client, cwd, json }) => { + const key = getRequiredStringFlag(flags, 'key') + const value = getRequiredStringFlag(flags, 'value') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.storage.local.set', { + key, + value, + ...target + }) + printResult(result, json, () => `localStorage["${key}"] set`) + }, + 'storage local clear': async ({ flags, client, cwd, json }) => { + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.storage.local.clear', target) + printResult(result, json, () => 'localStorage cleared') + }, + 'storage session get': async ({ flags, client, cwd, json }) => { + const key = getRequiredStringFlag(flags, 'key') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.storage.session.get', { key, ...target }) + printResult(result, json, (v) => JSON.stringify(v, null, 2)) + }, + 'storage session set': async ({ flags, client, cwd, json }) => { + const key = getRequiredStringFlag(flags, 'key') + const value = getRequiredStringFlag(flags, 'value') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.storage.session.set', { + key, + value, + ...target + }) + printResult(result, json, () => `sessionStorage["${key}"] set`) + }, + 'storage session clear': async ({ flags, client, cwd, json }) => { + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.storage.session.clear', target) + printResult(result, json, () => 'sessionStorage cleared') + } +} diff --git a/src/cli/handlers/browser-tab.ts b/src/cli/handlers/browser-tab.ts new file mode 100644 index 000000000..e842cd33c --- /dev/null +++ b/src/cli/handlers/browser-tab.ts @@ -0,0 +1,60 @@ +import type { BrowserTabListResult, BrowserTabSwitchResult } from '../../shared/runtime-types' +import type { CommandHandler } from '../dispatch' +import { formatTabList, printResult } from '../format' +import { + getOptionalNonNegativeIntegerFlag, + getOptionalStringFlag, + getRequiredStringFlag +} from '../flags' +import { RuntimeClientError } from '../runtime-client' +import { getBrowserCommandTarget, getBrowserWorktreeSelector } from '../selectors' + +export const BROWSER_TAB_HANDLERS: Record = { + 'tab list': async ({ flags, client, cwd, json }) => { + const worktree = await getBrowserWorktreeSelector(flags, cwd, client) + const result = await client.call('browser.tabList', { worktree }) + printResult(result, json, formatTabList) + }, + 'tab switch': async ({ flags, client, cwd, json }) => { + const index = getOptionalNonNegativeIntegerFlag(flags, 'index') + const page = getOptionalStringFlag(flags, 'page') + if (index === undefined && !page) { + throw new RuntimeClientError('invalid_argument', 'Missing required --index or --page') + } + // Why: a stable browser page id is globally unique across Orca, so page- + // targeted tab switches should match the rest of the --page command model: + // global by default, with --worktree only acting as explicit validation. + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.tabSwitch', { + index, + page, + ...target + }) + printResult(result, json, (v) => `Switched to tab ${v.switched} (${v.browserPageId})`) + }, + 'tab create': async ({ flags, client, cwd, json }) => { + const url = getOptionalStringFlag(flags, 'url') + const worktree = await getBrowserWorktreeSelector(flags, cwd, client) + const result = await client.call<{ browserPageId: string }>( + 'browser.tabCreate', + { url, worktree }, + { timeoutMs: 60_000 } + ) + printResult(result, json, (v) => `Created tab ${v.browserPageId}`) + }, + 'tab close': async ({ flags, client, cwd, json }) => { + const index = getOptionalNonNegativeIntegerFlag(flags, 'index') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call<{ closed: boolean }>('browser.tabClose', { + index, + ...target + }) + printResult(result, json, () => 'Tab closed') + }, + exec: async ({ flags, client, cwd, json }) => { + const command = getRequiredStringFlag(flags, 'command') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.exec', { command, ...target }) + printResult(result, json, (v) => JSON.stringify(v, null, 2)) + } +} diff --git a/src/cli/handlers/core.ts b/src/cli/handlers/core.ts new file mode 100644 index 000000000..f96a941da --- /dev/null +++ b/src/cli/handlers/core.ts @@ -0,0 +1,16 @@ +import type { CommandHandler } from '../dispatch' +import { formatCliStatus, formatStatus, printResult } from '../format' + +export const CORE_HANDLERS: Record = { + open: async ({ client, json }) => { + const result = await client.openOrca() + printResult(result, json, formatCliStatus) + }, + status: async ({ client, json }) => { + const result = await client.getCliStatus() + if (!json && !result.result.runtime.reachable) { + process.exitCode = 1 + } + printResult(result, json, formatStatus) + } +} diff --git a/src/cli/handlers/repo.ts b/src/cli/handlers/repo.ts new file mode 100644 index 000000000..a1061ee27 --- /dev/null +++ b/src/cli/handlers/repo.ts @@ -0,0 +1,38 @@ +import type { RuntimeRepoList, RuntimeRepoSearchRefs } from '../../shared/runtime-types' +import type { CommandHandler } from '../dispatch' +import { formatRepoList, formatRepoRefs, formatRepoShow, printResult } from '../format' +import { getOptionalPositiveIntegerFlag, getRequiredStringFlag } from '../flags' + +export const REPO_HANDLERS: Record = { + 'repo list': async ({ client, json }) => { + const result = await client.call('repo.list') + printResult(result, json, formatRepoList) + }, + 'repo add': async ({ flags, client, json }) => { + const result = await client.call<{ repo: Record }>('repo.add', { + path: getRequiredStringFlag(flags, 'path') + }) + printResult(result, json, formatRepoShow) + }, + 'repo show': async ({ flags, client, json }) => { + const result = await client.call<{ repo: Record }>('repo.show', { + repo: getRequiredStringFlag(flags, 'repo') + }) + printResult(result, json, formatRepoShow) + }, + 'repo set-base-ref': async ({ flags, client, json }) => { + const result = await client.call<{ repo: Record }>('repo.setBaseRef', { + repo: getRequiredStringFlag(flags, 'repo'), + ref: getRequiredStringFlag(flags, 'ref') + }) + printResult(result, json, formatRepoShow) + }, + 'repo search-refs': async ({ flags, client, json }) => { + const result = await client.call('repo.searchRefs', { + repo: getRequiredStringFlag(flags, 'repo'), + query: getRequiredStringFlag(flags, 'query'), + limit: getOptionalPositiveIntegerFlag(flags, 'limit') + }) + printResult(result, json, formatRepoRefs) + } +} diff --git a/src/cli/handlers/terminal.ts b/src/cli/handlers/terminal.ts new file mode 100644 index 000000000..c397021a8 --- /dev/null +++ b/src/cli/handlers/terminal.ts @@ -0,0 +1,152 @@ +import type { + RuntimeTerminalClose, + RuntimeTerminalCreate, + RuntimeTerminalFocus, + RuntimeTerminalListResult, + RuntimeTerminalRead, + RuntimeTerminalRename, + RuntimeTerminalSend, + RuntimeTerminalShow, + RuntimeTerminalSplit, + RuntimeTerminalWait +} from '../../shared/runtime-types' +import type { CommandHandler } from '../dispatch' +import { + formatTerminalClose, + formatTerminalCreate, + formatTerminalFocus, + formatTerminalList, + formatTerminalRead, + formatTerminalRename, + formatTerminalSend, + formatTerminalShow, + formatTerminalSplit, + formatTerminalWait, + printResult +} from '../format' +import { + getOptionalPositiveIntegerFlag, + getOptionalStringFlag, + getRequiredStringFlag +} from '../flags' +import { RuntimeClientError } from '../runtime-client' +import { + getBrowserWorktreeSelector, + getOptionalWorktreeSelector, + getRequiredWorktreeSelector, + getTerminalHandle +} from '../selectors' + +// Why: terminal wait legitimately needs to outlive the CLI's default RPC +// timeout. Even without an explicit server timeout, the client must allow +// long waits instead of failing at the generic 15s transport cap. +const DEFAULT_TERMINAL_WAIT_RPC_TIMEOUT_MS = 5 * 60 * 1000 + +const terminalFocusHandler: CommandHandler = async ({ flags, client, cwd, json }) => { + const result = await client.call<{ focus: RuntimeTerminalFocus }>('terminal.focus', { + terminal: await getTerminalHandle(flags, cwd, client) + }) + printResult(result, json, formatTerminalFocus) +} + +export const TERMINAL_HANDLERS: Record = { + 'terminal list': async ({ flags, client, cwd, json }) => { + const result = await client.call('terminal.list', { + worktree: await getOptionalWorktreeSelector(flags, 'worktree', cwd, client), + limit: getOptionalPositiveIntegerFlag(flags, 'limit') + }) + printResult(result, json, formatTerminalList) + }, + 'terminal show': async ({ flags, client, cwd, json }) => { + const result = await client.call<{ terminal: RuntimeTerminalShow }>('terminal.show', { + terminal: await getTerminalHandle(flags, cwd, client) + }) + printResult(result, json, formatTerminalShow) + }, + 'terminal read': async ({ flags, client, cwd, json }) => { + const cursorFlag = getOptionalStringFlag(flags, 'cursor') + const cursor = + cursorFlag !== undefined && /^\d+$/.test(cursorFlag) + ? Number.parseInt(cursorFlag, 10) + : undefined + if (cursorFlag !== undefined && cursor === undefined) { + throw new RuntimeClientError('invalid_argument', '--cursor must be a non-negative integer') + } + const result = await client.call<{ terminal: RuntimeTerminalRead }>('terminal.read', { + terminal: await getTerminalHandle(flags, cwd, client), + ...(cursor !== undefined ? { cursor } : {}) + }) + printResult(result, json, formatTerminalRead) + }, + 'terminal send': async ({ flags, client, cwd, json }) => { + const result = await client.call<{ send: RuntimeTerminalSend }>('terminal.send', { + terminal: await getTerminalHandle(flags, cwd, client), + text: getOptionalStringFlag(flags, 'text'), + enter: flags.get('enter') === true, + interrupt: flags.get('interrupt') === true + }) + printResult(result, json, formatTerminalSend) + }, + 'terminal wait': async ({ flags, client, cwd, json }) => { + const timeoutMs = getOptionalPositiveIntegerFlag(flags, 'timeout-ms') + const result = await client.call<{ wait: RuntimeTerminalWait }>( + 'terminal.wait', + { + terminal: await getTerminalHandle(flags, cwd, client), + for: getRequiredStringFlag(flags, 'for'), + timeoutMs + }, + { + timeoutMs: timeoutMs ? timeoutMs + 5000 : DEFAULT_TERMINAL_WAIT_RPC_TIMEOUT_MS + } + ) + printResult(result, json, formatTerminalWait) + }, + 'terminal stop': async ({ flags, client, cwd, json }) => { + const result = await client.call<{ stopped: number }>('terminal.stop', { + worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client) + }) + printResult(result, json, (value) => `Stopped ${value.stopped} terminals.`) + }, + 'terminal rename': async ({ flags, client, cwd, json }) => { + const result = await client.call<{ rename: RuntimeTerminalRename }>('terminal.rename', { + terminal: await getTerminalHandle(flags, cwd, client), + title: getOptionalStringFlag(flags, 'title') ?? null + }) + printResult(result, json, formatTerminalRename) + }, + 'terminal create': async ({ flags, client, cwd, json }) => { + const result = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', { + worktree: await getBrowserWorktreeSelector(flags, cwd, client), + command: getOptionalStringFlag(flags, 'command'), + title: getOptionalStringFlag(flags, 'title') + }) + printResult(result, json, formatTerminalCreate) + }, + // Why: `focus` and `switch` are aliases — register the same handler under + // both keys so the dispatch table stays a plain command-path lookup. + 'terminal focus': terminalFocusHandler, + 'terminal switch': terminalFocusHandler, + 'terminal close': async ({ flags, client, cwd, json }) => { + const result = await client.call<{ close: RuntimeTerminalClose }>('terminal.close', { + terminal: await getTerminalHandle(flags, cwd, client) + }) + printResult(result, json, formatTerminalClose) + }, + 'terminal split': async ({ flags, client, cwd, json }) => { + const directionFlag = getOptionalStringFlag(flags, 'direction') + if ( + directionFlag !== undefined && + directionFlag !== 'horizontal' && + directionFlag !== 'vertical' + ) { + throw new RuntimeClientError('invalid_argument', '--direction must be horizontal or vertical') + } + const result = await client.call<{ split: RuntimeTerminalSplit }>('terminal.split', { + terminal: await getTerminalHandle(flags, cwd, client), + direction: directionFlag, + command: getOptionalStringFlag(flags, 'command') + }) + printResult(result, json, formatTerminalSplit) + } +} diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts new file mode 100644 index 000000000..4775353df --- /dev/null +++ b/src/cli/handlers/worktree.ts @@ -0,0 +1,69 @@ +import type { + RuntimeWorktreeListResult, + RuntimeWorktreePsResult, + RuntimeWorktreeRecord +} from '../../shared/runtime-types' +import type { CommandHandler } from '../dispatch' +import { formatWorktreeList, formatWorktreePs, formatWorktreeShow, printResult } from '../format' +import { + getOptionalNullableNumberFlag, + getOptionalNumberFlag, + getOptionalPositiveIntegerFlag, + getOptionalStringFlag, + getRequiredStringFlag +} from '../flags' +import { getRequiredWorktreeSelector, resolveCurrentWorktreeSelector } from '../selectors' + +export const WORKTREE_HANDLERS: Record = { + 'worktree ps': async ({ flags, client, json }) => { + const result = await client.call('worktree.ps', { + limit: getOptionalPositiveIntegerFlag(flags, 'limit') + }) + printResult(result, json, formatWorktreePs) + }, + 'worktree list': async ({ flags, client, json }) => { + const result = await client.call('worktree.list', { + repo: getOptionalStringFlag(flags, 'repo'), + limit: getOptionalPositiveIntegerFlag(flags, 'limit') + }) + printResult(result, json, formatWorktreeList) + }, + 'worktree show': async ({ flags, client, cwd, json }) => { + const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', { + worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client) + }) + printResult(result, json, formatWorktreeShow) + }, + 'worktree current': async ({ client, cwd, json }) => { + const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', { + worktree: await resolveCurrentWorktreeSelector(cwd, client) + }) + printResult(result, json, formatWorktreeShow) + }, + 'worktree create': async ({ flags, client, json }) => { + const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.create', { + repo: getRequiredStringFlag(flags, 'repo'), + name: getRequiredStringFlag(flags, 'name'), + baseBranch: getOptionalStringFlag(flags, 'base-branch'), + linkedIssue: getOptionalNumberFlag(flags, 'issue'), + comment: getOptionalStringFlag(flags, 'comment') + }) + printResult(result, json, formatWorktreeShow) + }, + 'worktree set': async ({ flags, client, cwd, json }) => { + const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.set', { + worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client), + displayName: getOptionalStringFlag(flags, 'display-name'), + linkedIssue: getOptionalNullableNumberFlag(flags, 'issue'), + comment: getOptionalStringFlag(flags, 'comment') + }) + printResult(result, json, formatWorktreeShow) + }, + 'worktree rm': async ({ flags, client, cwd, json }) => { + const result = await client.call<{ removed: boolean }>('worktree.rm', { + worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client), + force: flags.get('force') === true + }) + printResult(result, json, (value) => `removed: ${value.removed}`) + } +} diff --git a/src/cli/help.ts b/src/cli/help.ts new file mode 100644 index 000000000..2028b9ffd --- /dev/null +++ b/src/cli/help.ts @@ -0,0 +1,296 @@ +import type { CommandSpec } from './args' +import { findCommandSpec, isCommandGroup, supportsBrowserPageFlag } from './args' + +const ROOT_HELP_TEXT = `orca + +Usage: orca [options] + +Startup: + open Launch Orca and wait for the runtime to be reachable + status Show app/runtime/graph readiness + +Repos: + repo list List repos registered in Orca + repo add Add a project to Orca by filesystem path + repo show Show one registered repo + repo set-base-ref Set the repo's default base ref for future worktrees + repo search-refs Search branch/tag refs within a repo + +Worktrees: + worktree list List Orca-managed worktrees + worktree show Show one worktree + worktree current Show the Orca-managed worktree for the current directory + worktree create Create a new Orca-managed worktree + worktree set Update Orca metadata for a worktree + worktree rm Remove a worktree from Orca and git + worktree ps Show a compact orchestration summary across worktrees + +Terminals: + terminal list List live Orca-managed terminals + terminal show Show terminal metadata and preview + terminal read Read bounded terminal output + terminal send Send input to a live terminal + terminal wait Wait for a terminal condition (exit, tui-idle) + terminal stop Stop terminals for a worktree + terminal create Create a new terminal tab in a worktree + terminal rename Set or clear the title of a terminal tab + terminal split Split an existing terminal pane + terminal switch Bring a terminal tab to the foreground + terminal focus Alias for terminal switch + terminal close Close a terminal pane (or tab if last pane) + +Browser Automation: + tab create Create a new browser tab (navigates to --url) + tab list List open browser tabs + tab switch Switch the active browser tab by --index or --page + tab close Close a browser tab by --index/--page or the current tab + snapshot Accessibility snapshot with element refs (e.g. @e1, @e2) + goto Navigate the active tab to --url + click Click element by --element ref + fill Clear and fill input by --element ref with --value + type Type --input text at the current focus (no element needed) + select Select dropdown option by --element ref and --value + hover Hover element by --element ref + keypress Press a key (e.g. --key Enter, --key Tab) + scroll Scroll --direction (up/down) by --amount pixels + back Navigate back in browser history + reload Reload the active browser tab + screenshot Capture viewport screenshot (--format png|jpeg) + eval Evaluate --expression JavaScript in the page context + wait Wait for page idle or --timeout ms + check Check a checkbox by --element ref + uncheck Uncheck a checkbox by --element ref + focus Focus an element by --element ref + clear Clear an input by --element ref + drag Drag --from ref to --to ref + upload Upload --files to a file input by --element ref + dblclick Double-click element by --element ref + forward Navigate forward in browser history + scrollintoview Scroll --element into view + get Get element property (--what: text, html, value, url, title) + is Check element state (--what: visible, enabled, checked) + inserttext Insert text without key events + mouse move Move mouse to --x --y coordinates + mouse down Press mouse button + mouse up Release mouse button + mouse wheel Scroll wheel --dy [--dx] + find Find element by locator (--locator role|text|label --value ) + set device Emulate device (--name "iPhone 12") + set offline Toggle offline mode (--state on|off) + set headers Set HTTP headers (--headers '{"key":"val"}') + set credentials Set HTTP auth (--user --pass

) + set media Set color scheme (--color-scheme dark|light) + clipboard read Read clipboard contents + clipboard write Write --text to clipboard + dialog accept Accept browser dialog (--text for prompt response) + dialog dismiss Dismiss browser dialog + storage local get Get localStorage value by --key + storage local set Set localStorage --key --value + storage local clear Clear localStorage + storage session get Get sessionStorage value by --key + storage session set Set sessionStorage --key --value + storage session clear Clear sessionStorage + download Download file via --selector to --path + highlight Highlight --selector on page + exec Run any agent-browser command (--command "...") + +Common Commands: + orca open [--json] + orca status [--json] + orca worktree list [--repo ] [--limit ] [--json] + orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--json] + orca worktree show --worktree [--json] + orca worktree current [--json] + orca worktree set --worktree [--display-name ] [--issue ] [--comment ] [--json] + orca worktree rm --worktree [--force] [--json] + orca worktree ps [--limit ] [--json] + orca terminal list [--worktree ] [--limit ] [--json] + orca terminal show [--terminal ] [--json] + orca terminal read [--terminal ] [--json] + orca terminal send [--terminal ] [--text ] [--enter] [--interrupt] [--json] + orca terminal wait [--terminal ] --for exit|tui-idle [--timeout-ms ] [--json] + orca terminal stop --worktree [--json] + orca terminal create [--worktree ] [--title ] [--command ] [--json] + orca terminal split [--terminal ] [--direction horizontal|vertical] [--json] + orca terminal switch [--terminal ] [--json] + orca terminal close [--terminal ] [--json] + orca repo list [--json] + orca repo add --path [--json] + orca repo show --repo [--json] + orca repo set-base-ref --repo --ref [--json] + orca repo search-refs --repo --query [--limit ] [--json] + +Selectors: + --repo Registered repo selector such as id:, name:, or path: + --worktree Worktree selector such as id:, branch:, issue:, path:, or active/current + --terminal Runtime-issued terminal handle returned by \`orca terminal list --json\` + +Terminal Send Options: + --text Text to send to the terminal + --enter Append Enter after sending text + --interrupt Send as an interrupt-style input when supported + +Wait Options: + --for exit Wait until the target terminal exits + --timeout-ms Maximum wait time before timing out + +Output Options: + --json Emit machine-readable JSON instead of human text + --help Show this help message + +Behavior: + Most commands require a running Orca runtime. If Orca is not open yet, run \`orca open\` first. + Use selectors for discovery and handles for repeated live terminal operations. + +Browser Workflow: + 1. Create or navigate: orca tab create --url https://example.com + orca goto --url https://example.com + 2. Inspect the page: orca snapshot + (Returns an accessibility tree with element refs like e1, e2, e3) + For concurrent workflows, prefer: orca tab list --json + then reuse tabs[].browserPageId with --page on later commands. + 3. Interact: orca click --element e2 + orca fill --element e5 --value "search query" + orca keypress --key Enter + 4. Re-inspect: orca snapshot + (Element refs change after navigation — always re-snapshot before interacting) + +Browser Options: + --element Element ref from snapshot (e.g. @e3) + --url URL to navigate to + --value Value to fill or select + --input Text to type at current focus (no element needed) + --expression JavaScript expression to evaluate + --key Key to press (Enter, Tab, Escape, Control+a, etc.) + --direction

Scroll direction: up or down + --amount Scroll distance in pixels (default: viewport height) + --index Tab index (from \`tab list\`) + --page Stable browser page id (preferred for concurrent workflows) + --format Screenshot image format + --from Drag source element ref + --to Drag target element ref + --files Comma-separated file paths for upload + --timeout Wait timeout in milliseconds + --worktree Scope commands to a specific worktree's browser tabs + +Examples: + $ orca open + $ orca status --json + $ orca repo list + $ orca worktree create --repo name:orca --name cli-test-1 --issue 273 + $ orca worktree show --worktree branch:Jinwoo-H/cli + $ orca worktree current + $ orca worktree set --worktree active --comment "waiting on review" + $ orca worktree ps --limit 10 + $ orca terminal list --worktree path:/Users/me/orca/workspaces/orca/cli-test-1 --json + $ orca terminal send --terminal term_123 --text "hi" --enter + $ orca terminal wait --terminal term_123 --for exit --timeout-ms 60000 --json + $ orca tab create --url https://example.com + $ orca snapshot + $ orca click --element e3 + $ orca fill --element e5 --value "hello" + $ orca goto --url https://example.com/login + $ orca keypress --key Enter + $ orca eval --expression "document.title" + $ orca tab list --json` + +export function printHelp(specs: CommandSpec[], commandPath: string[] = []): void { + const exactSpec = findCommandSpec(specs, commandPath) + if (exactSpec) { + console.log(formatCommandHelp(exactSpec)) + return + } + + if (isCommandGroup(commandPath)) { + console.log(formatGroupHelp(specs, commandPath[0])) + return + } + + if (commandPath.length > 0) { + console.log(`Unknown command: ${commandPath.join(' ')}\n`) + } + + console.log(ROOT_HELP_TEXT) +} + +export function formatCommandHelp(spec: CommandSpec): string { + const lines = [`orca ${spec.path.join(' ')}`, '', `Usage: ${spec.usage}`, '', spec.summary] + const displayedFlags = supportsBrowserPageFlag(spec.path) + ? [...spec.allowedFlags, 'page'] + : spec.allowedFlags + + if (displayedFlags.length > 0) { + lines.push('', 'Options:') + for (const flag of displayedFlags) { + lines.push(` ${formatFlagHelp(flag)}`) + } + } + + if (spec.notes && spec.notes.length > 0) { + lines.push('', 'Notes:') + for (const note of spec.notes) { + lines.push(` ${note}`) + } + } + + if (spec.examples && spec.examples.length > 0) { + lines.push('', 'Examples:') + for (const example of spec.examples) { + lines.push(` $ ${example}`) + } + } + + return lines.join('\n') +} + +export function formatGroupHelp(specs: CommandSpec[], group: string): string { + const groupSpecs = specs.filter((spec) => spec.path[0] === group) + const lines = [`orca ${group}`, '', `Usage: orca ${group} [options]`, '', 'Commands:'] + for (const spec of groupSpecs) { + lines.push(` ${spec.path.slice(1).join(' ').padEnd(18)} ${spec.summary}`) + } + lines.push('', `Run \`orca ${group} --help\` for command-specific usage.`) + return lines.join('\n') +} + +export function formatFlagHelp(flag: string): string { + const helpByFlag: Record = { + 'base-branch': '--base-branch Base branch/ref to create the worktree from', + command: '--command Command to run in the terminal on startup', + comment: '--comment Comment stored in Orca metadata', + cursor: '--cursor Line cursor from a previous read (returns only new output)', + direction: '--direction Direction: horizontal|vertical (split) or up|down (scroll)', + 'display-name': '--display-name Override the Orca display name', + title: '--title Custom title for the terminal tab (omit to reset)', + enter: '--enter Append Enter after sending text', + force: '--force Force worktree removal when supported', + for: '--for exit|tui-idle Wait condition to satisfy', + help: '--help Show this help message', + interrupt: '--interrupt Send as an interrupt-style input when supported', + issue: '--issue Linked GitHub issue number', + json: '--json Emit machine-readable JSON', + limit: '--limit Maximum number of rows to return', + name: '--name Name for the new worktree', + path: '--path Filesystem path to the repo', + query: '--query Search text for matching refs', + ref: '--ref Base ref to persist for the repo', + repo: '--repo Repo selector such as id:, name:, or path:', + terminal: '--terminal Runtime-issued terminal handle', + text: '--text Text to send to the terminal', + 'timeout-ms': '--timeout-ms Maximum wait time before timing out', + worktree: + '--worktree Worktree selector such as id:, branch:, issue:, path:, or active/current', + // Browser automation flags + element: '--element Element ref from snapshot (e.g. e3)', + url: '--url URL to navigate to', + value: '--value Value to fill or select', + input: '--input Text to type at current focus', + expression: '--expression JavaScript expression to evaluate', + amount: '--amount Scroll distance in pixels', + index: '--index Tab index to switch to', + page: '--page Stable browser page id from `orca tab list --json`', + format: '--format Screenshot image format' + } + + return helpByFlag[flag] ?? `--${flag}` +} diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 557c422b3..579556bac 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1,5 +1,3 @@ -/* oxlint-disable max-lines -- Why: CLI parsing behavior is exercised end-to-end -in one file so command and flag interactions stay visible in a single suite. */ import path from 'path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -43,7 +41,7 @@ import { main, normalizeWorktreeSelector } from './index' -import { RuntimeClientError } from './runtime-client' +import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures' describe('COMMAND_SPECS collision check', () => { it('has no duplicate command paths', () => { @@ -81,57 +79,22 @@ describe('orca cli worktree awareness', () => { }) it('shows the enclosing worktree for `worktree current`', async () => { - callMock - .mockResolvedValueOnce({ - id: 'req_list', - ok: true, - result: { - worktrees: [ - { - id: 'repo::/tmp/repo/feature', - repoId: 'repo', - path: '/tmp/repo/feature', - branch: 'feature/foo', - linkedIssue: null, - git: { - path: '/tmp/repo/feature', - head: 'abc', - branch: 'feature/foo', - isBare: false, - isMainWorktree: false - }, - displayName: '', - comment: '' - } - ], - totalCount: 1, - truncated: false - }, - _meta: { - runtimeId: 'runtime-1' - } - }) - .mockResolvedValueOnce({ - id: 'req_1', - ok: true, - result: { - worktree: { - id: 'repo::/tmp/repo/feature', - branch: 'feature/foo', - path: '/tmp/repo/feature' - } - }, - _meta: { - runtimeId: 'runtime-1' + queueFixtures( + callMock, + worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo')]), + okFixture('req_1', { + worktree: { + id: 'repo::/tmp/repo/feature', + branch: 'feature/foo', + path: '/tmp/repo/feature' } }) + ) const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) await main(['worktree', 'current', '--json'], '/tmp/repo/feature/src') - expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { - limit: 10_000 - }) + expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', { worktree: `path:${path.resolve('/tmp/repo/feature')}` }) @@ -139,67 +102,21 @@ describe('orca cli worktree awareness', () => { }) it('uses cwd when active is passed to worktree.set', async () => { - callMock - .mockResolvedValueOnce({ - id: 'req_list', - ok: true, - result: { - worktrees: [ - { - id: 'repo::/tmp/repo', - repoId: 'repo', - path: '/tmp/repo', - branch: 'main', - linkedIssue: null, - git: { - path: '/tmp/repo', - head: 'aaa', - branch: 'main', - isBare: false, - isMainWorktree: false - }, - displayName: '', - comment: '' - }, - { - id: 'repo::/tmp/repo/feature', - repoId: 'repo', - path: '/tmp/repo/feature', - branch: 'feature/foo', - linkedIssue: null, - git: { - path: '/tmp/repo/feature', - head: 'abc', - branch: 'feature/foo', - isBare: false, - isMainWorktree: false - }, - displayName: '', - comment: '' - } - ], - totalCount: 2, - truncated: false - }, - _meta: { - runtimeId: 'runtime-1' - } - }) - .mockResolvedValueOnce({ - id: 'req_1', - ok: true, - result: { - worktree: { - id: 'repo::/tmp/repo/feature', - branch: 'feature/foo', - path: '/tmp/repo/feature', - comment: 'hello' - } - }, - _meta: { - runtimeId: 'runtime-1' + queueFixtures( + callMock, + worktreeListFixture([ + buildWorktree('/tmp/repo', 'main', 'aaa'), + buildWorktree('/tmp/repo/feature', 'feature/foo') + ]), + okFixture('req_1', { + worktree: { + id: 'repo::/tmp/repo/feature', + branch: 'feature/foo', + path: '/tmp/repo/feature', + comment: 'hello' } }) + ) vi.spyOn(console, 'log').mockImplementation(() => {}) await main( @@ -216,50 +133,17 @@ describe('orca cli worktree awareness', () => { }) it('uses the resolved enclosing worktree for other worktree consumers', async () => { - callMock - .mockResolvedValueOnce({ - id: 'req_list', - ok: true, - result: { - worktrees: [ - { - id: 'repo::/tmp/repo/feature', - repoId: 'repo', - path: '/tmp/repo/feature', - branch: 'feature/foo', - linkedIssue: null, - git: { - path: '/tmp/repo/feature', - head: 'abc', - branch: 'feature/foo', - isBare: false, - isMainWorktree: false - }, - displayName: '', - comment: '' - } - ], - totalCount: 1, - truncated: false - }, - _meta: { - runtimeId: 'runtime-1' - } - }) - .mockResolvedValueOnce({ - id: 'req_show', - ok: true, - result: { - worktree: { - id: 'repo::/tmp/repo/feature', - branch: 'feature/foo', - path: '/tmp/repo/feature' - } - }, - _meta: { - runtimeId: 'runtime-1' + queueFixtures( + callMock, + worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo')]), + okFixture('req_show', { + worktree: { + id: 'repo::/tmp/repo/feature', + branch: 'feature/foo', + path: '/tmp/repo/feature' } }) + ) vi.spyOn(console, 'log').mockImplementation(() => {}) await main(['worktree', 'show', '--worktree', 'current', '--json'], '/tmp/repo/feature/src') @@ -270,48 +154,11 @@ describe('orca cli worktree awareness', () => { }) it('uses the resolved enclosing worktree for terminal consumers', async () => { - callMock - .mockResolvedValueOnce({ - id: 'req_list', - ok: true, - result: { - worktrees: [ - { - id: 'repo::/tmp/repo/feature', - repoId: 'repo', - path: '/tmp/repo/feature', - branch: 'feature/foo', - linkedIssue: null, - git: { - path: '/tmp/repo/feature', - head: 'abc', - branch: 'feature/foo', - isBare: false, - isMainWorktree: false - }, - displayName: '', - comment: '' - } - ], - totalCount: 1, - truncated: false - }, - _meta: { - runtimeId: 'runtime-1' - } - }) - .mockResolvedValueOnce({ - id: 'req_term', - ok: true, - result: { - terminals: [], - totalCount: 0, - truncated: false - }, - _meta: { - runtimeId: 'runtime-1' - } - }) + queueFixtures( + callMock, + worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo')]), + okFixture('req_term', { terminals: [], totalCount: 0, truncated: false }) + ) vi.spyOn(console, 'log').mockImplementation(() => {}) await main(['terminal', 'list', '--worktree', 'active', '--json'], '/tmp/repo/feature/src') @@ -322,312 +169,3 @@ describe('orca cli worktree awareness', () => { }) }) }) - -describe('orca cli browser page targeting', () => { - beforeEach(() => { - callMock.mockReset() - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it('passes explicit page ids to snapshot without resolving the current worktree', async () => { - callMock.mockResolvedValueOnce({ - id: 'req_snapshot', - ok: true, - result: { - browserPageId: 'page-1', - snapshot: 'tree', - refs: [], - url: 'https://example.com', - title: 'Example' - }, - _meta: { - runtimeId: 'runtime-1' - } - }) - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await main(['snapshot', '--page', 'page-1', '--json'], '/tmp/not-an-orca-worktree') - - expect(callMock).toHaveBeenCalledTimes(1) - expect(callMock).toHaveBeenCalledWith('browser.snapshot', { - page: 'page-1' - }) - }) - - it('resolves current worktree only when --page is combined with --worktree current', async () => { - callMock - .mockResolvedValueOnce({ - id: 'req_list', - ok: true, - result: { - worktrees: [ - { - id: 'repo::/tmp/repo/feature', - repoId: 'repo', - path: '/tmp/repo/feature', - branch: 'feature/foo', - linkedIssue: null, - git: { - path: '/tmp/repo/feature', - head: 'abc', - branch: 'feature/foo', - isBare: false, - isMainWorktree: false - }, - displayName: '', - comment: '' - } - ], - totalCount: 1, - truncated: false - }, - _meta: { - runtimeId: 'runtime-1' - } - }) - .mockResolvedValueOnce({ - id: 'req_snapshot', - ok: true, - result: { - browserPageId: 'page-1', - snapshot: 'tree', - refs: [], - url: 'https://example.com', - title: 'Example' - }, - _meta: { - runtimeId: 'runtime-1' - } - }) - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await main( - ['snapshot', '--page', 'page-1', '--worktree', 'current', '--json'], - '/tmp/repo/feature/src' - ) - - expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { - limit: 10_000 - }) - expect(callMock).toHaveBeenNthCalledWith(2, 'browser.snapshot', { - page: 'page-1', - worktree: `path:${path.resolve('/tmp/repo/feature')}` - }) - }) - - it('passes page-targeted tab switches through without auto-scoping to the current worktree', async () => { - callMock.mockResolvedValueOnce({ - id: 'req_switch', - ok: true, - result: { - switched: 2, - browserPageId: 'page-2' - }, - _meta: { - runtimeId: 'runtime-1' - } - }) - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await main(['tab', 'switch', '--page', 'page-2', '--json'], '/tmp/repo/feature/src') - - expect(callMock).toHaveBeenCalledTimes(1) - expect(callMock).toHaveBeenCalledWith('browser.tabSwitch', { - index: undefined, - page: 'page-2' - }) - }) - - it('still resolves the current worktree when tab switch --page is combined with --worktree current', async () => { - callMock - .mockResolvedValueOnce({ - id: 'req_list', - ok: true, - result: { - worktrees: [ - { - id: 'repo::/tmp/repo/feature', - repoId: 'repo', - path: '/tmp/repo/feature', - branch: 'feature/foo', - linkedIssue: null, - git: { - path: '/tmp/repo/feature', - head: 'abc', - branch: 'feature/foo', - isBare: false, - isMainWorktree: false - }, - displayName: '', - comment: '' - } - ], - totalCount: 1, - truncated: false - }, - _meta: { - runtimeId: 'runtime-1' - } - }) - .mockResolvedValueOnce({ - id: 'req_switch', - ok: true, - result: { - switched: 2, - browserPageId: 'page-2' - }, - _meta: { - runtimeId: 'runtime-1' - } - }) - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await main( - ['tab', 'switch', '--page', 'page-2', '--worktree', 'current', '--json'], - '/tmp/repo/feature/src' - ) - - expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { - limit: 10_000 - }) - expect(callMock).toHaveBeenNthCalledWith(2, 'browser.tabSwitch', { - index: undefined, - page: 'page-2', - worktree: `path:${path.resolve('/tmp/repo/feature')}` - }) - }) -}) - -describe('orca cli browser waits and viewport flags', () => { - beforeEach(() => { - callMock.mockReset() - process.exitCode = undefined - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it('gives selector waits an explicit RPC timeout budget', async () => { - callMock.mockResolvedValueOnce({ - id: 'req_wait', - ok: true, - result: { ok: true }, - _meta: { - runtimeId: 'runtime-1' - } - }) - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await main( - ['wait', '--selector', '#ready', '--worktree', 'all', '--json'], - '/tmp/not-an-orca-worktree' - ) - - expect(callMock).toHaveBeenCalledWith( - 'browser.wait', - { - selector: '#ready', - timeout: undefined, - text: undefined, - url: undefined, - load: undefined, - fn: undefined, - state: undefined, - worktree: undefined - }, - { timeoutMs: 60_000 } - ) - }) - - it('extends selector wait RPC timeout when the user passes --timeout', async () => { - callMock.mockResolvedValueOnce({ - id: 'req_wait', - ok: true, - result: { ok: true }, - _meta: { - runtimeId: 'runtime-1' - } - }) - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await main( - ['wait', '--selector', '#ready', '--timeout', '12000', '--worktree', 'all', '--json'], - '/tmp/not-an-orca-worktree' - ) - - expect(callMock).toHaveBeenCalledWith( - 'browser.wait', - { - selector: '#ready', - timeout: 12000, - text: undefined, - url: undefined, - load: undefined, - fn: undefined, - state: undefined, - worktree: undefined - }, - { timeoutMs: 17000 } - ) - }) - - it('does not tell users Orca is down for a generic runtime timeout', async () => { - callMock.mockRejectedValueOnce( - new RuntimeClientError( - 'runtime_timeout', - 'Timed out waiting for the Orca runtime to respond.' - ) - ) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - await main(['wait', '--selector', '#ready', '--worktree', 'all'], '/tmp/not-an-orca-worktree') - - expect(errorSpy).toHaveBeenCalledWith('Timed out waiting for the Orca runtime to respond.') - }) - - it('passes the mobile viewport flag through to browser.viewport', async () => { - callMock.mockResolvedValueOnce({ - id: 'req_viewport', - ok: true, - result: { - width: 375, - height: 812, - deviceScaleFactor: 2, - mobile: true - }, - _meta: { - runtimeId: 'runtime-1' - } - }) - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await main( - [ - 'viewport', - '--width', - '375', - '--height', - '812', - '--scale', - '2', - '--mobile', - '--worktree', - 'all', - '--json' - ], - '/tmp/not-an-orca-worktree' - ) - - expect(callMock).toHaveBeenCalledWith('browser.viewport', { - width: 375, - height: 812, - deviceScaleFactor: 2, - mobile: true, - worktree: undefined - }) - }) -}) diff --git a/src/cli/index.ts b/src/cli/index.ts index 88f97d8fd..ed015d118 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,753 +1,36 @@ #!/usr/bin/env node -/* eslint-disable max-lines -- Why: the public CLI entrypoint keeps command dispatch in one place so the bundled shell command and development fallback stay behaviorally identical. */ - -import { isAbsolute, relative, resolve as resolvePath } from 'path' -import type { - CliStatusResult, - RuntimeRepoList, - RuntimeRepoSearchRefs, - RuntimeWorktreeRecord, - RuntimeWorktreePsResult, - RuntimeWorktreeListResult, - RuntimeTerminalRead, - RuntimeTerminalListResult, - RuntimeTerminalShow, - RuntimeTerminalSend, - RuntimeTerminalWait, - RuntimeTerminalCreate, - RuntimeTerminalSplit, - RuntimeTerminalRename, - RuntimeTerminalFocus, - RuntimeTerminalClose, - BrowserSnapshotResult, - BrowserClickResult, - BrowserGotoResult, - BrowserFillResult, - BrowserTypeResult, - BrowserSelectResult, - BrowserScrollResult, - BrowserBackResult, - BrowserReloadResult, - BrowserScreenshotResult, - BrowserEvalResult, - BrowserTabListResult, - BrowserTabSwitchResult, - BrowserHoverResult, - BrowserDragResult, - BrowserUploadResult, - BrowserWaitResult, - BrowserCheckResult, - BrowserFocusResult, - BrowserClearResult, - BrowserSelectAllResult, - BrowserKeypressResult, - BrowserPdfResult, - BrowserCookieGetResult, - BrowserCookieSetResult, - BrowserCookieDeleteResult, - BrowserViewportResult, - BrowserGeolocationResult, - BrowserInterceptEnableResult, - BrowserInterceptDisableResult, - BrowserInterceptedRequest, - BrowserCaptureStartResult, - BrowserCaptureStopResult, - BrowserConsoleResult, - BrowserNetworkLogResult -} from '../shared/runtime-types' import { - RuntimeClient, - RuntimeClientError, - RuntimeRpcFailureError, - type RuntimeRpcSuccess -} from './runtime-client' -import type { RuntimeRpcFailure } from './runtime-client' + findCommandSpec, + isCommandGroup, + parseArgs, + resolveHelpPath, + validateCommandAndFlags +} from './args' +import { dispatch } from './dispatch' +import { reportCliError } from './format' +import { printHelp } from './help' +import { RuntimeClient } from './runtime-client' +import { COMMAND_SPECS } from './specs' -type ParsedArgs = { - commandPath: string[] - flags: Map -} - -type CommandSpec = { - path: string[] - summary: string - usage: string - allowedFlags: string[] - examples?: string[] - notes?: string[] -} - -type BrowserCliTarget = { - worktree?: string - page?: string -} - -const DEFAULT_TERMINAL_WAIT_RPC_TIMEOUT_MS = 5 * 60 * 1000 -const DEFAULT_BROWSER_WAIT_RPC_TIMEOUT_MS = 60_000 -const GLOBAL_FLAGS = ['help', 'json'] -export const COMMAND_SPECS: CommandSpec[] = [ - { - path: ['open'], - summary: 'Launch Orca and wait for the runtime to be reachable', - usage: 'orca open [--json]', - allowedFlags: [...GLOBAL_FLAGS], - examples: ['orca open', 'orca open --json'] - }, - { - path: ['status'], - summary: 'Show app/runtime/graph readiness', - usage: 'orca status [--json]', - allowedFlags: [...GLOBAL_FLAGS], - examples: ['orca status', 'orca status --json'] - }, - { - path: ['repo', 'list'], - summary: 'List repos registered in Orca', - usage: 'orca repo list [--json]', - allowedFlags: [...GLOBAL_FLAGS] - }, - { - path: ['repo', 'add'], - summary: 'Add a project to Orca by filesystem path', - usage: 'orca repo add --path [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'path'] - }, - { - path: ['repo', 'show'], - summary: 'Show one registered repo', - usage: 'orca repo show --repo [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'repo'] - }, - { - path: ['repo', 'set-base-ref'], - summary: "Set the repo's default base ref for future worktrees", - usage: 'orca repo set-base-ref --repo --ref [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'repo', 'ref'] - }, - { - path: ['repo', 'search-refs'], - summary: 'Search branch/tag refs within a repo', - usage: 'orca repo search-refs --repo --query [--limit ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'repo', 'query', 'limit'] - }, - { - path: ['worktree', 'list'], - summary: 'List Orca-managed worktrees', - usage: 'orca worktree list [--repo ] [--limit ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'repo', 'limit'] - }, - { - path: ['worktree', 'show'], - summary: 'Show one worktree', - usage: 'orca worktree show --worktree [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree'] - }, - { - path: ['worktree', 'current'], - summary: 'Show the Orca-managed worktree for the current directory', - usage: 'orca worktree current [--json]', - allowedFlags: [...GLOBAL_FLAGS], - notes: [ - 'Resolves the current shell directory to a path: selector so agents can target the enclosing Orca worktree without spelling out $PWD.' - ], - examples: ['orca worktree current', 'orca worktree current --json'] - }, - { - path: ['worktree', 'create'], - summary: 'Create a new Orca-managed worktree', - usage: - 'orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'repo', 'name', 'base-branch', 'issue', 'comment'], - notes: ['By default this matches the Orca UI flow and activates the new worktree in the app.'] - }, - { - path: ['worktree', 'set'], - summary: 'Update Orca metadata for a worktree', - usage: - 'orca worktree set --worktree [--display-name ] [--issue ] [--comment ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'display-name', 'issue', 'comment'] - }, - { - path: ['worktree', 'rm'], - summary: 'Remove a worktree from Orca and git', - usage: 'orca worktree rm --worktree [--force] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'force'] - }, - { - path: ['worktree', 'ps'], - summary: 'Show a compact orchestration summary across worktrees', - usage: 'orca worktree ps [--limit ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'limit'] - }, - { - path: ['terminal', 'list'], - summary: 'List live Orca-managed terminals', - usage: 'orca terminal list [--worktree ] [--limit ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'limit'] - }, - { - path: ['terminal', 'show'], - summary: 'Show terminal metadata and preview', - usage: 'orca terminal show [--terminal ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'terminal'] - }, - { - path: ['terminal', 'read'], - summary: 'Read bounded terminal output', - usage: 'orca terminal read [--terminal ] [--cursor ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'cursor'], - notes: [ - 'Omit --terminal to target the active terminal in the current worktree.', - 'Use --cursor with the nextCursor value from a previous read to get only new output since that read.', - 'Useful for capturing the response to a command: read before sending, then read --cursor after waiting.' - ], - examples: [ - 'orca terminal read --json', - 'orca terminal read --terminal term_abc123 --cursor 42 --json' - ] - }, - { - path: ['terminal', 'send'], - summary: 'Send input to a live terminal', - usage: - 'orca terminal send [--terminal ] [--text ] [--enter] [--interrupt] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'text', 'enter', 'interrupt'] - }, - { - path: ['terminal', 'wait'], - summary: 'Wait for a terminal condition', - usage: - 'orca terminal wait [--terminal ] --for exit|tui-idle [--timeout-ms ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'for', 'timeout-ms'] - }, - { - path: ['terminal', 'stop'], - summary: 'Stop terminals for a worktree', - usage: 'orca terminal stop --worktree [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree'] - }, - { - path: ['terminal', 'create'], - summary: 'Create a new terminal tab in the current worktree', - usage: - 'orca terminal create [--worktree ] [--title ] [--command ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'command', 'title'], - examples: [ - 'orca terminal create --json', - 'orca terminal create --worktree path:/projects/myapp --title "RUNNER" --command "opencode"' - ] - }, - { - path: ['terminal', 'switch'], - summary: 'Switch to a terminal tab in the UI', - usage: 'orca terminal switch [--terminal ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'terminal'], - examples: ['orca terminal switch --terminal term_abc123'] - }, - { - path: ['terminal', 'focus'], - summary: 'Switch to a terminal tab in the UI (alias for terminal switch)', - usage: 'orca terminal focus [--terminal ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'terminal'], - examples: ['orca terminal focus --terminal term_abc123'] - }, - { - path: ['terminal', 'close'], - summary: 'Close a terminal tab (kills PTY if running)', - usage: 'orca terminal close [--terminal ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'terminal'], - examples: ['orca terminal close --terminal term_abc123'] - }, - { - path: ['terminal', 'rename'], - summary: 'Set or clear the title of a terminal tab', - usage: 'orca terminal rename [--terminal ] [--title ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'title'], - notes: ['Omit --title or pass an empty string to reset to the auto-generated title.'], - examples: [ - 'orca terminal rename --terminal term_abc123 --title "RUNNER"', - 'orca terminal rename --terminal term_abc123 --json' - ] - }, - { - path: ['terminal', 'split'], - summary: 'Split an existing terminal pane', - usage: - 'orca terminal split [--terminal ] [--direction horizontal|vertical] [--command ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'direction', 'command'], - examples: [ - 'orca terminal split --terminal term_abc123 --direction horizontal --json', - 'orca terminal split --terminal term_abc123 --command "codex"' - ] - }, - // ── Browser automation ── - { - path: ['snapshot'], - summary: 'Capture an accessibility snapshot of the active browser tab', - usage: 'orca snapshot [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree'] - }, - { - path: ['screenshot'], - summary: 'Capture a viewport screenshot of the active browser tab', - usage: 'orca screenshot [--format ] [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'format', 'worktree'] - }, - { - path: ['click'], - summary: 'Click a browser element by ref', - usage: 'orca click --element [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'element', 'worktree'] - }, - { - path: ['fill'], - summary: 'Clear and fill a browser input by ref', - usage: 'orca fill --element --value [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'element', 'value', 'worktree'] - }, - { - path: ['type'], - summary: 'Type text at the current browser focus', - usage: 'orca type --input [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'input', 'worktree'] - }, - { - path: ['select'], - summary: 'Select a dropdown option by ref', - usage: 'orca select --element --value [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'element', 'value', 'worktree'] - }, - { - path: ['scroll'], - summary: 'Scroll the browser viewport', - usage: 'orca scroll --direction [--amount ] [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'direction', 'amount', 'worktree'] - }, - { - path: ['goto'], - summary: 'Navigate the active browser tab to a URL', - usage: 'orca goto --url [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'url', 'worktree'] - }, - { - path: ['back'], - summary: 'Navigate back in browser history', - usage: 'orca back [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree'] - }, - { - path: ['reload'], - summary: 'Reload the active browser tab', - usage: 'orca reload [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree'] - }, - { - path: ['eval'], - summary: 'Evaluate JavaScript in the browser page context', - usage: 'orca eval --expression [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'expression', 'worktree'] - }, - { - path: ['wait'], - summary: 'Wait for element, text, URL, load state, JS condition, or timeout', - usage: - 'orca wait [--selector ] [--timeout ] [--text ] [--url ] [--load ] [--fn ] [--state ] [--worktree ] [--json]', - allowedFlags: [ - ...GLOBAL_FLAGS, - 'selector', - 'timeout', - 'text', - 'url', - 'load', - 'fn', - 'state', - 'worktree' - ] - }, - { - path: ['check'], - summary: 'Check a checkbox/radio by ref', - usage: 'orca check --element [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'element', 'worktree'] - }, - { - path: ['uncheck'], - summary: 'Uncheck a checkbox/radio by ref', - usage: 'orca uncheck --element [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'element', 'worktree'] - }, - { - path: ['focus'], - summary: 'Focus a browser element by ref', - usage: 'orca focus --element [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'element', 'worktree'] - }, - { - path: ['clear'], - summary: 'Clear an input element by ref', - usage: 'orca clear --element [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'element', 'worktree'] - }, - { - path: ['select-all'], - summary: 'Select all text in an input by ref', - usage: 'orca select-all --element [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'element', 'worktree'] - }, - { - path: ['keypress'], - summary: 'Press a key (Enter, Tab, Escape, ArrowDown, etc.)', - usage: 'orca keypress --key [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'key', 'worktree'] - }, - { - path: ['pdf'], - summary: 'Export the active browser tab as PDF', - usage: 'orca pdf [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree'] - }, - { - path: ['full-screenshot'], - summary: 'Capture a full-page screenshot (beyond viewport)', - usage: 'orca full-screenshot [--format ] [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'format', 'worktree'] - }, - { - path: ['hover'], - summary: 'Hover over a browser element by ref', - usage: 'orca hover --element [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'element', 'worktree'] - }, - { - path: ['drag'], - summary: 'Drag from one element to another', - usage: 'orca drag --from --to [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'from', 'to', 'worktree'] - }, - { - path: ['upload'], - summary: 'Upload files to a file input element', - usage: 'orca upload --element --files [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'element', 'files', 'worktree'] - }, - { - path: ['tab', 'list'], - summary: 'List open browser tabs', - usage: 'orca tab list [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree'] - }, - { - path: ['tab', 'switch'], - summary: 'Switch the active browser tab', - usage: 'orca tab switch (--index | --page ) [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'index', 'worktree'] - }, - { - path: ['tab', 'create'], - summary: 'Create a new browser tab in the current worktree', - usage: 'orca tab create [--url ] [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'url', 'worktree'] - }, - { - path: ['tab', 'close'], - summary: 'Close a browser tab', - usage: 'orca tab close [--index ] [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'index', 'worktree'] - }, - { - path: ['exec'], - summary: 'Run any agent-browser command against the active browser tab', - usage: 'orca exec --command "" [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'command', 'worktree'] - }, - // ── Cookie management ── - { - path: ['cookie', 'get'], - summary: 'Get cookies for the active tab (optionally filter by URL)', - usage: 'orca cookie get [--url ] [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'url', 'worktree'] - }, - { - path: ['cookie', 'set'], - summary: 'Set a cookie', - usage: - 'orca cookie set --name --value [--domain ] [--path

] [--secure] [--httpOnly] [--sameSite ] [--expires ] [--worktree ] [--json]', - allowedFlags: [ - ...GLOBAL_FLAGS, - 'name', - 'value', - 'domain', - 'path', - 'secure', - 'httpOnly', - 'sameSite', - 'expires', - 'worktree' - ] - }, - { - path: ['cookie', 'delete'], - summary: 'Delete a cookie by name', - usage: - 'orca cookie delete --name [--domain ] [--url ] [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'name', 'domain', 'url', 'worktree'] - }, - // ── Viewport ── - { - path: ['viewport'], - summary: 'Set browser viewport size', - usage: - 'orca viewport --width --height [--scale ] [--mobile] [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'width', 'height', 'scale', 'mobile', 'worktree'] - }, - // ── Geolocation ── - { - path: ['geolocation'], - summary: 'Override browser geolocation', - usage: - 'orca geolocation --latitude --longitude [--accuracy ] [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'latitude', 'longitude', 'accuracy', 'worktree'] - }, - // ── Request interception ── - { - path: ['intercept', 'enable'], - summary: 'Enable request interception (pause matching requests)', - usage: 'orca intercept enable [--patterns ] [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'patterns', 'worktree'] - }, - { - path: ['intercept', 'disable'], - summary: 'Disable request interception', - usage: 'orca intercept disable [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree'] - }, - { - path: ['intercept', 'list'], - summary: 'List paused (intercepted) requests', - usage: 'orca intercept list [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree'] - }, - // TODO: add intercept continue/block once agent-browser supports per-request - // interception decisions (currently only supports URL-pattern-based route/unroute). - // ── Console/network capture ── - { - path: ['capture', 'start'], - summary: 'Start capturing console and network events', - usage: 'orca capture start [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree'] - }, - { - path: ['capture', 'stop'], - summary: 'Stop capturing console and network events', - usage: 'orca capture stop [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree'] - }, - { - path: ['console'], - summary: 'Show captured console log entries', - usage: 'orca console [--limit ] [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'limit', 'worktree'] - }, - { - path: ['network'], - summary: 'Show captured network requests', - usage: 'orca network [--limit ] [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'limit', 'worktree'] - }, - // ── Additional core commands ── - { - path: ['dblclick'], - summary: 'Double-click element by ref', - usage: 'orca dblclick --element [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'element', 'worktree'] - }, - { - path: ['forward'], - summary: 'Navigate forward in browser history', - usage: 'orca forward [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree'] - }, - { - path: ['scrollintoview'], - summary: 'Scroll element into view', - usage: 'orca scrollintoview --element [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'element', 'worktree'] - }, - { - path: ['get'], - summary: 'Get element property (text, html, value, url, title, count, box)', - usage: 'orca get --what [--element ] [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'what', 'element', 'worktree'] - }, - { - path: ['is'], - summary: 'Check element state (visible, enabled, checked)', - usage: 'orca is --what --element [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'what', 'element', 'worktree'] - }, - // ── Keyboard insert text ── - { - path: ['inserttext'], - summary: 'Insert text without key events', - usage: 'orca inserttext --text [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'text', 'worktree'] - }, - // ── Mouse commands ── - { - path: ['mouse', 'move'], - summary: 'Move mouse to x,y coordinates', - usage: 'orca mouse move --x --y [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'x', 'y', 'worktree'] - }, - { - path: ['mouse', 'down'], - summary: 'Press mouse button', - usage: 'orca mouse down [--button ] [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'button', 'worktree'] - }, - { - path: ['mouse', 'up'], - summary: 'Release mouse button', - usage: 'orca mouse up [--button ] [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'button', 'worktree'] - }, - { - path: ['mouse', 'wheel'], - summary: 'Scroll wheel', - usage: 'orca mouse wheel --dy [--dx ] [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'dy', 'dx', 'worktree'] - }, - // ── Find (semantic locators) ── - { - path: ['find'], - summary: 'Find element by semantic locator and perform action', - usage: - 'orca find --locator --value --action [--text ] [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'locator', 'value', 'action', 'text', 'worktree'] - }, - // ── Set commands ── - { - path: ['set', 'device'], - summary: 'Emulate a device', - usage: 'orca set device --name [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'name', 'worktree'] - }, - { - path: ['set', 'offline'], - summary: 'Toggle offline mode', - usage: 'orca set offline [--state ] [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'state', 'worktree'] - }, - { - path: ['set', 'headers'], - summary: 'Set extra HTTP headers', - usage: 'orca set headers --headers [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'headers', 'worktree'] - }, - { - path: ['set', 'credentials'], - summary: 'Set HTTP auth credentials', - usage: 'orca set credentials --user --pass [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'user', 'pass', 'worktree'] - }, - { - path: ['set', 'media'], - summary: 'Set color scheme and reduced motion preferences', - usage: - 'orca set media [--color-scheme ] [--reduced-motion ] [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'color-scheme', 'reduced-motion', 'worktree'] - }, - // ── Clipboard commands ── - { - path: ['clipboard', 'read'], - summary: 'Read clipboard contents', - usage: 'orca clipboard read [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree'] - }, - { - path: ['clipboard', 'write'], - summary: 'Write text to clipboard', - usage: 'orca clipboard write --text [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'text', 'worktree'] - }, - // ── Dialog commands ── - { - path: ['dialog', 'accept'], - summary: 'Accept a browser dialog', - usage: 'orca dialog accept [--text ] [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'text', 'worktree'] - }, - { - path: ['dialog', 'dismiss'], - summary: 'Dismiss a browser dialog', - usage: 'orca dialog dismiss [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree'] - }, - // ── Storage commands ── - { - path: ['storage', 'local', 'get'], - summary: 'Get a localStorage value by key', - usage: 'orca storage local get --key [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'key', 'worktree'] - }, - { - path: ['storage', 'local', 'set'], - summary: 'Set a localStorage value', - usage: 'orca storage local set --key --value [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'key', 'value', 'worktree'] - }, - { - path: ['storage', 'local', 'clear'], - summary: 'Clear all localStorage', - usage: 'orca storage local clear [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree'] - }, - { - path: ['storage', 'session', 'get'], - summary: 'Get a sessionStorage value by key', - usage: 'orca storage session get --key [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'key', 'worktree'] - }, - { - path: ['storage', 'session', 'set'], - summary: 'Set a sessionStorage value', - usage: 'orca storage session set --key --value [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'key', 'value', 'worktree'] - }, - { - path: ['storage', 'session', 'clear'], - summary: 'Clear all sessionStorage', - usage: 'orca storage session clear [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree'] - }, - // ── Download command ── - { - path: ['download'], - summary: 'Download a file by clicking a selector', - usage: 'orca download --selector --path [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'selector', 'path', 'worktree'] - }, - // ── Highlight command ── - { - path: ['highlight'], - summary: 'Highlight an element by selector', - usage: 'orca highlight --selector [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'selector', 'worktree'] - } -] +export { COMMAND_SPECS } from './specs' +export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors' export async function main(argv = process.argv.slice(2), cwd = process.cwd()): Promise { const parsed = parseArgs(argv) const helpPath = resolveHelpPath(parsed) if (helpPath !== null) { - printHelp(helpPath) - if (helpPath.length > 0 && !findCommandSpec(helpPath) && !isCommandGroup(helpPath)) { + printHelp(COMMAND_SPECS, helpPath) + if ( + helpPath.length > 0 && + !findCommandSpec(COMMAND_SPECS, helpPath) && + !isCommandGroup(helpPath) + ) { process.exitCode = 1 } return } if (parsed.commandPath.length === 0) { - printHelp([]) + printHelp(COMMAND_SPECS, []) return } const json = parsed.flags.has('json') @@ -756,1831 +39,20 @@ export async function main(argv = process.argv.slice(2), cwd = process.cwd()): P // Why: CLI syntax and flag errors should be reported before any runtime // lookup so users do not get misleading "Orca is not running" failures for // simple command typos or unsupported flags. - validateCommandAndFlags(parsed) - + validateCommandAndFlags(COMMAND_SPECS, parsed) const client = new RuntimeClient() - const { commandPath } = parsed - - if (matches(commandPath, ['open'])) { - const result = await client.openOrca() - return printResult(result, json, formatCliStatus) - } - - if (matches(commandPath, ['status'])) { - const result = await client.getCliStatus() - if (!json && !result.result.runtime.reachable) { - process.exitCode = 1 - } - return printResult(result, json, formatStatus) - } - - if (matches(commandPath, ['repo', 'list'])) { - const result = await client.call('repo.list') - return printResult(result, json, formatRepoList) - } - - if (matches(commandPath, ['repo', 'add'])) { - const result = await client.call<{ repo: Record }>('repo.add', { - path: getRequiredStringFlag(parsed.flags, 'path') - }) - return printResult(result, json, formatRepoShow) - } - - if (matches(commandPath, ['repo', 'show'])) { - const result = await client.call<{ repo: Record }>('repo.show', { - repo: getRequiredStringFlag(parsed.flags, 'repo') - }) - return printResult(result, json, formatRepoShow) - } - - if (matches(commandPath, ['repo', 'set-base-ref'])) { - const result = await client.call<{ repo: Record }>('repo.setBaseRef', { - repo: getRequiredStringFlag(parsed.flags, 'repo'), - ref: getRequiredStringFlag(parsed.flags, 'ref') - }) - return printResult(result, json, formatRepoShow) - } - - if (matches(commandPath, ['repo', 'search-refs'])) { - const result = await client.call('repo.searchRefs', { - repo: getRequiredStringFlag(parsed.flags, 'repo'), - query: getRequiredStringFlag(parsed.flags, 'query'), - limit: getOptionalPositiveIntegerFlag(parsed.flags, 'limit') - }) - return printResult(result, json, formatRepoRefs) - } - - if (matches(commandPath, ['terminal', 'list'])) { - const result = await client.call('terminal.list', { - worktree: await getOptionalWorktreeSelector(parsed.flags, 'worktree', cwd, client), - limit: getOptionalPositiveIntegerFlag(parsed.flags, 'limit') - }) - return printResult(result, json, formatTerminalList) - } - - if (matches(commandPath, ['terminal', 'show'])) { - const result = await client.call<{ terminal: RuntimeTerminalShow }>('terminal.show', { - terminal: await getTerminalHandle(parsed.flags, cwd, client) - }) - return printResult(result, json, formatTerminalShow) - } - - if (matches(commandPath, ['terminal', 'read'])) { - const cursorFlag = getOptionalStringFlag(parsed.flags, 'cursor') - const cursor = - cursorFlag !== undefined && /^\d+$/.test(cursorFlag) - ? Number.parseInt(cursorFlag, 10) - : undefined - if (cursorFlag !== undefined && cursor === undefined) { - throw new RuntimeClientError('invalid_argument', '--cursor must be a non-negative integer') - } - const result = await client.call<{ terminal: RuntimeTerminalRead }>('terminal.read', { - terminal: await getTerminalHandle(parsed.flags, cwd, client), - ...(cursor !== undefined ? { cursor } : {}) - }) - return printResult(result, json, formatTerminalRead) - } - - if (matches(commandPath, ['terminal', 'send'])) { - const result = await client.call<{ send: RuntimeTerminalSend }>('terminal.send', { - terminal: await getTerminalHandle(parsed.flags, cwd, client), - text: getOptionalStringFlag(parsed.flags, 'text'), - enter: parsed.flags.get('enter') === true, - interrupt: parsed.flags.get('interrupt') === true - }) - return printResult(result, json, formatTerminalSend) - } - - if (matches(commandPath, ['terminal', 'wait'])) { - const timeoutMs = getOptionalPositiveIntegerFlag(parsed.flags, 'timeout-ms') - const result = await client.call<{ wait: RuntimeTerminalWait }>( - 'terminal.wait', - { - terminal: await getTerminalHandle(parsed.flags, cwd, client), - for: getRequiredStringFlag(parsed.flags, 'for'), - timeoutMs - }, - { - // Why: terminal wait legitimately needs to outlive the CLI's default - // RPC timeout. Even without an explicit server timeout, the client must - // allow long waits instead of failing at the generic 15s transport cap. - timeoutMs: timeoutMs ? timeoutMs + 5000 : DEFAULT_TERMINAL_WAIT_RPC_TIMEOUT_MS - } - ) - return printResult(result, json, formatTerminalWait) - } - - if (matches(commandPath, ['terminal', 'stop'])) { - const result = await client.call<{ stopped: number }>('terminal.stop', { - worktree: await getRequiredWorktreeSelector(parsed.flags, 'worktree', cwd, client) - }) - return printResult(result, json, (value) => `Stopped ${value.stopped} terminals.`) - } - - if (matches(commandPath, ['terminal', 'rename'])) { - const result = await client.call<{ rename: RuntimeTerminalRename }>('terminal.rename', { - terminal: await getTerminalHandle(parsed.flags, cwd, client), - title: getOptionalStringFlag(parsed.flags, 'title') ?? null - }) - return printResult(result, json, formatTerminalRename) - } - - if (matches(commandPath, ['terminal', 'create'])) { - const result = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', { - worktree: await getBrowserWorktreeSelector(parsed.flags, cwd, client), - command: getOptionalStringFlag(parsed.flags, 'command'), - title: getOptionalStringFlag(parsed.flags, 'title') - }) - return printResult(result, json, formatTerminalCreate) - } - - if ( - matches(commandPath, ['terminal', 'focus']) || - matches(commandPath, ['terminal', 'switch']) - ) { - const result = await client.call<{ focus: RuntimeTerminalFocus }>('terminal.focus', { - terminal: await getTerminalHandle(parsed.flags, cwd, client) - }) - return printResult(result, json, formatTerminalFocus) - } - - if (matches(commandPath, ['terminal', 'close'])) { - const result = await client.call<{ close: RuntimeTerminalClose }>('terminal.close', { - terminal: await getTerminalHandle(parsed.flags, cwd, client) - }) - return printResult(result, json, formatTerminalClose) - } - - if (matches(commandPath, ['terminal', 'split'])) { - const directionFlag = getOptionalStringFlag(parsed.flags, 'direction') - if ( - directionFlag !== undefined && - directionFlag !== 'horizontal' && - directionFlag !== 'vertical' - ) { - throw new RuntimeClientError( - 'invalid_argument', - '--direction must be horizontal or vertical' - ) - } - const result = await client.call<{ split: RuntimeTerminalSplit }>('terminal.split', { - terminal: await getTerminalHandle(parsed.flags, cwd, client), - direction: directionFlag, - command: getOptionalStringFlag(parsed.flags, 'command') - }) - return printResult(result, json, formatTerminalSplit) - } - - if (matches(commandPath, ['worktree', 'ps'])) { - const result = await client.call('worktree.ps', { - limit: getOptionalPositiveIntegerFlag(parsed.flags, 'limit') - }) - return printResult(result, json, formatWorktreePs) - } - - if (matches(commandPath, ['worktree', 'list'])) { - const result = await client.call('worktree.list', { - repo: getOptionalStringFlag(parsed.flags, 'repo'), - limit: getOptionalPositiveIntegerFlag(parsed.flags, 'limit') - }) - return printResult(result, json, formatWorktreeList) - } - - if (matches(commandPath, ['worktree', 'show'])) { - const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', { - worktree: await getRequiredWorktreeSelector(parsed.flags, 'worktree', cwd, client) - }) - return printResult(result, json, formatWorktreeShow) - } - - if (matches(commandPath, ['worktree', 'current'])) { - const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', { - worktree: await resolveCurrentWorktreeSelector(cwd, client) - }) - return printResult(result, json, formatWorktreeShow) - } - - if (matches(commandPath, ['worktree', 'create'])) { - const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.create', { - repo: getRequiredStringFlag(parsed.flags, 'repo'), - name: getRequiredStringFlag(parsed.flags, 'name'), - baseBranch: getOptionalStringFlag(parsed.flags, 'base-branch'), - linkedIssue: getOptionalNumberFlag(parsed.flags, 'issue'), - comment: getOptionalStringFlag(parsed.flags, 'comment') - }) - return printResult(result, json, formatWorktreeShow) - } - - if (matches(commandPath, ['worktree', 'set'])) { - const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.set', { - worktree: await getRequiredWorktreeSelector(parsed.flags, 'worktree', cwd, client), - displayName: getOptionalStringFlag(parsed.flags, 'display-name'), - linkedIssue: getOptionalNullableNumberFlag(parsed.flags, 'issue'), - comment: getOptionalStringFlag(parsed.flags, 'comment') - }) - return printResult(result, json, formatWorktreeShow) - } - - if (matches(commandPath, ['worktree', 'rm'])) { - const result = await client.call<{ removed: boolean }>('worktree.rm', { - worktree: await getRequiredWorktreeSelector(parsed.flags, 'worktree', cwd, client), - force: parsed.flags.get('force') === true - }) - return printResult(result, json, (value) => `removed: ${value.removed}`) - } - - // ── Browser automation dispatch ── - - if (matches(commandPath, ['snapshot'])) { - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.snapshot', target) - return printResult(result, json, formatSnapshot) - } - - if (matches(commandPath, ['screenshot'])) { - const format = getOptionalStringFlag(parsed.flags, 'format') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.screenshot', { - format: format === 'jpeg' ? 'jpeg' : undefined, - ...target - }) - return printResult(result, json, formatScreenshot) - } - - if (matches(commandPath, ['click'])) { - const element = getRequiredStringFlag(parsed.flags, 'element') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.click', { element, ...target }) - return printResult(result, json, (v) => `Clicked ${v.clicked}`) - } - - if (matches(commandPath, ['fill'])) { - const element = getRequiredStringFlag(parsed.flags, 'element') - const value = getRequiredStringFlag(parsed.flags, 'value') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.fill', { - element, - value, - ...target - }) - return printResult(result, json, (v) => `Filled ${v.filled}`) - } - - if (matches(commandPath, ['type'])) { - const input = getRequiredStringFlag(parsed.flags, 'input') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.type', { input, ...target }) - return printResult(result, json, () => 'Typed input') - } - - if (matches(commandPath, ['select'])) { - const element = getRequiredStringFlag(parsed.flags, 'element') - const value = getRequiredStringFlag(parsed.flags, 'value') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.select', { - element, - value, - ...target - }) - return printResult(result, json, (v) => `Selected ${v.selected}`) - } - - if (matches(commandPath, ['scroll'])) { - const direction = getRequiredStringFlag(parsed.flags, 'direction') - if (direction !== 'up' && direction !== 'down') { - throw new RuntimeClientError('invalid_argument', '--direction must be "up" or "down"') - } - const amount = getOptionalPositiveIntegerFlag(parsed.flags, 'amount') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.scroll', { - direction, - amount, - ...target - }) - return printResult(result, json, (v) => `Scrolled ${v.scrolled}`) - } - - if (matches(commandPath, ['goto'])) { - const url = getRequiredStringFlag(parsed.flags, 'url') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - // Why: navigation waits for network idle which can exceed the default 15s RPC timeout - const result = await client.call( - 'browser.goto', - { url, ...target }, - { timeoutMs: 60_000 } - ) - return printResult(result, json, (v) => `Navigated to ${v.url} — ${v.title}`) - } - - if (matches(commandPath, ['back'])) { - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.back', target) - return printResult(result, json, (v) => `Back to ${v.url} — ${v.title}`) - } - - if (matches(commandPath, ['reload'])) { - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.reload', target, { - timeoutMs: 60_000 - }) - return printResult(result, json, (v) => `Reloaded ${v.url} — ${v.title}`) - } - - if (matches(commandPath, ['eval'])) { - const expression = getRequiredStringFlag(parsed.flags, 'expression') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.eval', { expression, ...target }) - return printResult(result, json, (v) => v.result) - } - - if (matches(commandPath, ['tab', 'list'])) { - const worktree = await getBrowserWorktreeSelector(parsed.flags, cwd, client) - const result = await client.call('browser.tabList', { worktree }) - return printResult(result, json, formatTabList) - } - - if (matches(commandPath, ['tab', 'switch'])) { - const index = getOptionalNonNegativeIntegerFlag(parsed.flags, 'index') - const page = getOptionalStringFlag(parsed.flags, 'page') - if (index === undefined && !page) { - throw new RuntimeClientError('invalid_argument', 'Missing required --index or --page') - } - // Why: a stable browser page id is globally unique across Orca, so page- - // targeted tab switches should match the rest of the --page command model: - // global by default, with --worktree only acting as explicit validation. - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.tabSwitch', { - index, - page, - ...target - }) - return printResult(result, json, (v) => `Switched to tab ${v.switched} (${v.browserPageId})`) - } - - if (matches(commandPath, ['tab', 'create'])) { - const url = getOptionalStringFlag(parsed.flags, 'url') - const worktree = await getBrowserWorktreeSelector(parsed.flags, cwd, client) - const result = await client.call<{ browserPageId: string }>( - 'browser.tabCreate', - { url, worktree }, - { timeoutMs: 60_000 } - ) - return printResult(result, json, (v) => `Created tab ${v.browserPageId}`) - } - - if (matches(commandPath, ['tab', 'close'])) { - const index = getOptionalNonNegativeIntegerFlag(parsed.flags, 'index') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call<{ closed: boolean }>('browser.tabClose', { - index, - ...target - }) - return printResult(result, json, () => 'Tab closed') - } - - if (matches(commandPath, ['exec'])) { - const command = getRequiredStringFlag(parsed.flags, 'command') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.exec', { command, ...target }) - return printResult(result, json, (v) => JSON.stringify(v, null, 2)) - } - - if (matches(commandPath, ['wait'])) { - const selector = getOptionalStringFlag(parsed.flags, 'selector') - const timeout = getOptionalPositiveIntegerFlag(parsed.flags, 'timeout') - const text = getOptionalStringFlag(parsed.flags, 'text') - const url = getOptionalStringFlag(parsed.flags, 'url') - const load = getOptionalStringFlag(parsed.flags, 'load') - const fn = getOptionalStringFlag(parsed.flags, 'fn') - const state = getOptionalStringFlag(parsed.flags, 'state') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call( - 'browser.wait', - { - selector, - timeout, - text, - url, - load, - fn, - state, - ...target - }, - { - // Why: selector/text/url waits can legitimately take longer than a - // normal RPC round-trip, even when Orca is healthy. Give browser.wait - // an explicit timeout budget so slow waits do not get mislabeled as - // "Orca is not running" by the generic client timeout path. - timeoutMs: timeout ? timeout + 5000 : DEFAULT_BROWSER_WAIT_RPC_TIMEOUT_MS - } - ) - return printResult(result, json, (v) => JSON.stringify(v, null, 2)) - } - - if (matches(commandPath, ['check']) || matches(commandPath, ['uncheck'])) { - const element = getRequiredStringFlag(parsed.flags, 'element') - const checked = matches(commandPath, ['check']) - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.check', { - element, - checked, - ...target - }) - return printResult(result, json, (v) => - v.checked ? `Checked ${element}` : `Unchecked ${element}` - ) - } - - if (matches(commandPath, ['focus'])) { - const element = getRequiredStringFlag(parsed.flags, 'element') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.focus', { element, ...target }) - return printResult(result, json, (v) => `Focused ${v.focused}`) - } - - if (matches(commandPath, ['clear'])) { - const element = getRequiredStringFlag(parsed.flags, 'element') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.clear', { element, ...target }) - return printResult(result, json, (v) => `Cleared ${v.cleared}`) - } - - if (matches(commandPath, ['select-all'])) { - const element = getRequiredStringFlag(parsed.flags, 'element') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.selectAll', { - element, - ...target - }) - return printResult(result, json, (v) => `Selected all in ${v.selected}`) - } - - if (matches(commandPath, ['keypress'])) { - const key = getRequiredStringFlag(parsed.flags, 'key') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.keypress', { - key, - ...target - }) - return printResult(result, json, (v) => `Pressed ${v.pressed}`) - } - - if (matches(commandPath, ['pdf'])) { - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.pdf', target) - return printResult(result, json, (v) => `PDF exported (${v.data.length} bytes base64)`) - } - - if (matches(commandPath, ['full-screenshot'])) { - const format = getOptionalStringFlag(parsed.flags, 'format') === 'jpeg' ? 'jpeg' : 'png' - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.fullScreenshot', { - format, - ...target - }) - return printResult(result, json, (v) => `Full-page screenshot captured (${v.format})`) - } - - if (matches(commandPath, ['hover'])) { - const element = getRequiredStringFlag(parsed.flags, 'element') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.hover', { element, ...target }) - return printResult(result, json, (v) => `Hovered ${v.hovered}`) - } - - if (matches(commandPath, ['drag'])) { - const from = getRequiredStringFlag(parsed.flags, 'from') - const to = getRequiredStringFlag(parsed.flags, 'to') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.drag', { from, to, ...target }) - return printResult(result, json, (v) => `Dragged ${v.dragged.from} → ${v.dragged.to}`) - } - - if (matches(commandPath, ['upload'])) { - const element = getRequiredStringFlag(parsed.flags, 'element') - const filesStr = getRequiredStringFlag(parsed.flags, 'files') - const files = filesStr.split(',').map((f) => f.trim()) - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.upload', { - element, - files, - ...target - }) - return printResult(result, json, (v) => `Uploaded ${v.uploaded} file(s)`) - } - - // ── Cookie management ── - - if (matches(commandPath, ['cookie', 'get'])) { - const url = getOptionalStringFlag(parsed.flags, 'url') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.cookie.get', { - url, - ...target - }) - return printResult(result, json, (v) => { - if (v.cookies.length === 0) { - return 'No cookies' - } - return v.cookies.map((c) => `${c.name}=${c.value} (${c.domain})`).join('\n') - }) - } - - if (matches(commandPath, ['cookie', 'set'])) { - const name = getRequiredStringFlag(parsed.flags, 'name') - const value = getRequiredStringFlag(parsed.flags, 'value') - const params: Record = { name, value } - const domain = getOptionalStringFlag(parsed.flags, 'domain') - const path = getOptionalStringFlag(parsed.flags, 'path') - const sameSite = getOptionalStringFlag(parsed.flags, 'sameSite') - const expires = getOptionalStringFlag(parsed.flags, 'expires') - if (domain) { - params.domain = domain - } - if (path) { - params.path = path - } - if (parsed.flags.has('secure')) { - params.secure = true - } - if (parsed.flags.has('httpOnly')) { - params.httpOnly = true - } - if (sameSite) { - params.sameSite = sameSite - } - if (expires) { - params.expires = Number(expires) - } - Object.assign(params, await getBrowserCommandTarget(parsed.flags, cwd, client)) - const result = await client.call('browser.cookie.set', params) - return printResult(result, json, (v) => - v.success ? `Cookie "${name}" set` : `Failed to set cookie "${name}"` - ) - } - - if (matches(commandPath, ['cookie', 'delete'])) { - const name = getRequiredStringFlag(parsed.flags, 'name') - const params: Record = { name } - const domain = getOptionalStringFlag(parsed.flags, 'domain') - const url = getOptionalStringFlag(parsed.flags, 'url') - if (domain) { - params.domain = domain - } - if (url) { - params.url = url - } - Object.assign(params, await getBrowserCommandTarget(parsed.flags, cwd, client)) - const result = await client.call('browser.cookie.delete', params) - return printResult(result, json, () => `Cookie "${name}" deleted`) - } - - // ── Viewport ── - - if (matches(commandPath, ['viewport'])) { - const width = getRequiredPositiveNumber(parsed.flags, 'width') - const height = getRequiredPositiveNumber(parsed.flags, 'height') - const params: Record = { width, height } - const scale = getOptionalStringFlag(parsed.flags, 'scale') - if (scale) { - const n = Number(scale) - if (!Number.isFinite(n) || n <= 0) { - throw new RuntimeClientError('invalid_argument', '--scale must be a positive number') - } - params.deviceScaleFactor = n - } - if (parsed.flags.has('mobile')) { - params.mobile = true - } - Object.assign(params, await getBrowserCommandTarget(parsed.flags, cwd, client)) - const result = await client.call('browser.viewport', params) - return printResult( - result, - json, - (v) => `Viewport set to ${v.width}×${v.height}${v.mobile ? ' (mobile)' : ''}` - ) - } - - // ── Geolocation ── - - if (matches(commandPath, ['geolocation'])) { - const latitude = getRequiredFiniteNumber(parsed.flags, 'latitude') - const longitude = getRequiredFiniteNumber(parsed.flags, 'longitude') - const params: Record = { latitude, longitude } - const accuracy = getOptionalStringFlag(parsed.flags, 'accuracy') - if (accuracy) { - const n = Number(accuracy) - if (!Number.isFinite(n) || n <= 0) { - throw new RuntimeClientError('invalid_argument', '--accuracy must be a positive number') - } - params.accuracy = n - } - Object.assign(params, await getBrowserCommandTarget(parsed.flags, cwd, client)) - const result = await client.call('browser.geolocation', params) - return printResult(result, json, (v) => `Geolocation set to ${v.latitude}, ${v.longitude}`) - } - - // ── Request interception ── - - if (matches(commandPath, ['intercept', 'enable'])) { - const params: Record = {} - const patternsStr = getOptionalStringFlag(parsed.flags, 'patterns') - if (patternsStr) { - params.patterns = patternsStr.split(',').map((p) => p.trim()) - } - Object.assign(params, await getBrowserCommandTarget(parsed.flags, cwd, client)) - const result = await client.call( - 'browser.intercept.enable', - params - ) - return printResult( - result, - json, - (v) => `Interception enabled for: ${(v.patterns ?? []).join(', ') || '*'}` - ) - } - - if (matches(commandPath, ['intercept', 'disable'])) { - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call( - 'browser.intercept.disable', - target - ) - return printResult(result, json, () => 'Interception disabled') - } - - if (matches(commandPath, ['intercept', 'list'])) { - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call<{ requests: BrowserInterceptedRequest[] }>( - 'browser.intercept.list', - target - ) - return printResult(result, json, (v) => { - if (v.requests.length === 0) { - return 'No paused requests' - } - return v.requests - .map((r) => `[${r.id}] ${r.method} ${r.url} (${r.resourceType})`) - .join('\n') - }) - } - - // ── Console/network capture ── - - if (matches(commandPath, ['capture', 'start'])) { - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.capture.start', target) - return printResult(result, json, () => 'Capture started (console + network)') - } - - if (matches(commandPath, ['capture', 'stop'])) { - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.capture.stop', target) - return printResult(result, json, () => 'Capture stopped') - } - - if (matches(commandPath, ['console'])) { - const params: Record = {} - const limit = getOptionalPositiveIntegerFlag(parsed.flags, 'limit') - if (limit !== undefined) { - params.limit = limit - } - Object.assign(params, await getBrowserCommandTarget(parsed.flags, cwd, client)) - const result = await client.call('browser.console', params) - return printResult(result, json, (v) => { - if (v.entries.length === 0) { - return 'No console entries' - } - return v.entries.map((e) => `[${e.level}] ${e.text}`).join('\n') - }) - } - - if (matches(commandPath, ['network'])) { - const params: Record = {} - const limit = getOptionalPositiveIntegerFlag(parsed.flags, 'limit') - if (limit !== undefined) { - params.limit = limit - } - Object.assign(params, await getBrowserCommandTarget(parsed.flags, cwd, client)) - const result = await client.call('browser.network', params) - return printResult(result, json, (v) => { - if (v.entries.length === 0) { - return 'No network entries' - } - return v.entries.map((e) => `${e.status} ${e.url} (${e.mimeType}, ${e.size}B)`).join('\n') - }) - } - - // ── Additional core commands ── - - if (matches(commandPath, ['dblclick'])) { - const element = getRequiredStringFlag(parsed.flags, 'element') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.dblclick', { element, ...target }) - return printResult(result, json, () => `Double-clicked ${element}`) - } - - if (matches(commandPath, ['forward'])) { - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.forward', target) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return printResult(result, json, (v: any) => - v?.url ? `Navigated forward to ${v.url}` : 'Navigated forward' - ) - } - - if (matches(commandPath, ['scrollintoview'])) { - const element = getRequiredStringFlag(parsed.flags, 'element') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.scrollIntoView', { element, ...target }) - return printResult(result, json, () => `Scrolled ${element} into view`) - } - - if (matches(commandPath, ['get'])) { - const what = getRequiredStringFlag(parsed.flags, 'what') - const element = getOptionalStringFlag(parsed.flags, 'element') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.get', { - what, - selector: element, - ...target - }) - return printResult(result, json, (v) => - typeof v === 'string' ? v : JSON.stringify(v, null, 2) - ) - } - - if (matches(commandPath, ['is'])) { - const what = getRequiredStringFlag(parsed.flags, 'what') - const element = getRequiredStringFlag(parsed.flags, 'element') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.is', { - what, - selector: element, - ...target - }) - return printResult(result, json, (v) => String(v)) - } - - // ── Keyboard insert text ── - - if (matches(commandPath, ['inserttext'])) { - const text = getRequiredStringFlag(parsed.flags, 'text') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.keyboardInsertText', { text, ...target }) - return printResult(result, json, () => 'Text inserted') - } - - // ── Mouse commands ── - - if (matches(commandPath, ['mouse', 'move'])) { - const x = getRequiredFiniteNumber(parsed.flags, 'x') - const y = getRequiredFiniteNumber(parsed.flags, 'y') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.mouseMove', { x, y, ...target }) - return printResult(result, json, () => `Mouse moved to ${x},${y}`) - } - - if (matches(commandPath, ['mouse', 'down'])) { - const button = getOptionalStringFlag(parsed.flags, 'button') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.mouseDown', { button, ...target }) - return printResult(result, json, () => `Mouse button ${button ?? 'left'} pressed`) - } - - if (matches(commandPath, ['mouse', 'up'])) { - const button = getOptionalStringFlag(parsed.flags, 'button') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.mouseUp', { button, ...target }) - return printResult(result, json, () => `Mouse button ${button ?? 'left'} released`) - } - - if (matches(commandPath, ['mouse', 'wheel'])) { - const dy = getRequiredFiniteNumber(parsed.flags, 'dy') - const dx = getOptionalNumberFlag(parsed.flags, 'dx') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.mouseWheel', { dy, dx, ...target }) - return printResult( - result, - json, - () => `Mouse wheel scrolled dy=${dy}${dx != null ? ` dx=${dx}` : ''}` - ) - } - - // ── Find (semantic locators) ── - - if (matches(commandPath, ['find'])) { - const locator = getRequiredStringFlag(parsed.flags, 'locator') - const value = getRequiredStringFlag(parsed.flags, 'value') - const action = getRequiredStringFlag(parsed.flags, 'action') - const text = getOptionalStringFlag(parsed.flags, 'text') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.find', { - locator, - value, - action, - text, - ...target - }) - return printResult(result, json, (v) => JSON.stringify(v, null, 2)) - } - - // ── Set commands ── - - if (matches(commandPath, ['set', 'device'])) { - const name = getRequiredStringFlag(parsed.flags, 'name') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.setDevice', { name, ...target }) - return printResult(result, json, () => `Device emulation set to ${name}`) - } - - if (matches(commandPath, ['set', 'offline'])) { - const state = getOptionalStringFlag(parsed.flags, 'state') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.setOffline', { state, ...target }) - return printResult(result, json, () => `Offline mode ${state ?? 'toggled'}`) - } - - if (matches(commandPath, ['set', 'headers'])) { - const headers = getRequiredStringFlag(parsed.flags, 'headers') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.setHeaders', { headers, ...target }) - return printResult(result, json, () => 'Extra HTTP headers set') - } - - if (matches(commandPath, ['set', 'credentials'])) { - const user = getRequiredStringFlag(parsed.flags, 'user') - const pass = getRequiredStringFlag(parsed.flags, 'pass') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.setCredentials', { - user, - pass, - ...target - }) - return printResult(result, json, () => `HTTP auth credentials set for ${user}`) - } - - if (matches(commandPath, ['set', 'media'])) { - const colorScheme = getOptionalStringFlag(parsed.flags, 'color-scheme') - const reducedMotion = getOptionalStringFlag(parsed.flags, 'reduced-motion') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.setMedia', { - colorScheme, - reducedMotion, - ...target - }) - return printResult(result, json, () => 'Media preferences set') - } - - // ── Clipboard commands ── - - if (matches(commandPath, ['clipboard', 'read'])) { - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.clipboardRead', target) - return printResult(result, json, (v) => JSON.stringify(v, null, 2)) - } - - if (matches(commandPath, ['clipboard', 'write'])) { - const text = getRequiredStringFlag(parsed.flags, 'text') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.clipboardWrite', { text, ...target }) - return printResult(result, json, () => 'Clipboard updated') - } - - // ── Dialog commands ── - - if (matches(commandPath, ['dialog', 'accept'])) { - const text = getOptionalStringFlag(parsed.flags, 'text') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.dialogAccept', { text, ...target }) - return printResult(result, json, () => 'Dialog accepted') - } - - if (matches(commandPath, ['dialog', 'dismiss'])) { - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.dialogDismiss', target) - return printResult(result, json, () => 'Dialog dismissed') - } - - // ── Storage commands ── - - if (matches(commandPath, ['storage', 'local', 'get'])) { - const key = getRequiredStringFlag(parsed.flags, 'key') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.storage.local.get', { key, ...target }) - return printResult(result, json, (v) => JSON.stringify(v, null, 2)) - } - - if (matches(commandPath, ['storage', 'local', 'set'])) { - const key = getRequiredStringFlag(parsed.flags, 'key') - const value = getRequiredStringFlag(parsed.flags, 'value') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.storage.local.set', { - key, - value, - ...target - }) - return printResult(result, json, () => `localStorage["${key}"] set`) - } - - if (matches(commandPath, ['storage', 'local', 'clear'])) { - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.storage.local.clear', target) - return printResult(result, json, () => 'localStorage cleared') - } - - if (matches(commandPath, ['storage', 'session', 'get'])) { - const key = getRequiredStringFlag(parsed.flags, 'key') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.storage.session.get', { key, ...target }) - return printResult(result, json, (v) => JSON.stringify(v, null, 2)) - } - - if (matches(commandPath, ['storage', 'session', 'set'])) { - const key = getRequiredStringFlag(parsed.flags, 'key') - const value = getRequiredStringFlag(parsed.flags, 'value') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.storage.session.set', { - key, - value, - ...target - }) - return printResult(result, json, () => `sessionStorage["${key}"] set`) - } - - if (matches(commandPath, ['storage', 'session', 'clear'])) { - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.storage.session.clear', target) - return printResult(result, json, () => 'sessionStorage cleared') - } - - // ── Download command ── - - if (matches(commandPath, ['download'])) { - const selector = getRequiredStringFlag(parsed.flags, 'selector') - const path = getRequiredStringFlag(parsed.flags, 'path') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.download', { selector, path, ...target }) - return printResult(result, json, () => `Downloaded to ${path}`) - } - - // ── Highlight command ── - - if (matches(commandPath, ['highlight'])) { - const selector = getRequiredStringFlag(parsed.flags, 'selector') - const target = await getBrowserCommandTarget(parsed.flags, cwd, client) - const result = await client.call('browser.highlight', { selector, ...target }) - return printResult(result, json, () => `Highlighted ${selector}`) - } - - throw new RuntimeClientError('invalid_argument', `Unknown command: ${commandPath.join(' ')}`) + await dispatch(parsed.commandPath, { + flags: parsed.flags, + client, + cwd, + json + }) } catch (error) { - if (json) { - if (error instanceof RuntimeRpcFailureError) { - console.log(JSON.stringify(error.response, null, 2)) - } else { - const response: RuntimeRpcFailure = { - id: 'local', - ok: false, - error: { - code: error instanceof RuntimeClientError ? error.code : 'runtime_error', - message: formatCliError(error) - }, - _meta: { - runtimeId: null - } - } - console.log(JSON.stringify(response, null, 2)) - } - } else { - console.error(formatCliError(error)) - } + reportCliError(error, json) process.exitCode = 1 } } -export function parseArgs(argv: string[]): ParsedArgs { - const commandPath: string[] = [] - const flags = new Map() - - for (let i = 0; i < argv.length; i += 1) { - const token = argv[i] - if (!token.startsWith('--')) { - commandPath.push(token) - continue - } - - const flag = token.slice(2) - const next = argv[i + 1] - if (!next || next.startsWith('--')) { - flags.set(flag, true) - continue - } - flags.set(flag, next) - i += 1 - } - - return { commandPath, flags } -} - -export function resolveHelpPath(parsed: ParsedArgs): string[] | null { - if (parsed.commandPath[0] === 'help') { - return parsed.commandPath.slice(1) - } - if (parsed.flags.has('help')) { - return parsed.commandPath - } - return null -} - -export function validateCommandAndFlags(parsed: ParsedArgs): void { - const spec = findCommandSpec(parsed.commandPath) - if (!spec) { - throw new RuntimeClientError( - 'invalid_argument', - `Unknown command: ${parsed.commandPath.join(' ')}` - ) - } - - for (const flag of parsed.flags.keys()) { - if ( - !spec.allowedFlags.includes(flag) && - !(flag === 'page' && supportsBrowserPageFlag(spec.path)) - ) { - throw new RuntimeClientError( - 'invalid_argument', - `Unknown flag --${flag} for command: ${spec.path.join(' ')}` - ) - } - } -} - -export function findCommandSpec(commandPath: string[]): CommandSpec | undefined { - return COMMAND_SPECS.find((spec) => matches(spec.path, commandPath)) -} - -function supportsBrowserPageFlag(commandPath: string[]): boolean { - const joined = commandPath.join(' ') - if (['open', 'status'].includes(commandPath[0])) { - return false - } - if (['repo', 'worktree', 'terminal'].includes(commandPath[0])) { - return false - } - return !['tab list', 'tab create'].includes(joined) -} - -function isCommandGroup(commandPath: string[]): boolean { - return ( - (commandPath.length === 1 && - [ - 'repo', - 'worktree', - 'terminal', - 'tab', - 'cookie', - 'intercept', - 'capture', - 'mouse', - 'set', - 'clipboard', - 'dialog', - 'storage' - ].includes(commandPath[0])) || - (commandPath.length === 2 && - commandPath[0] === 'storage' && - ['local', 'session'].includes(commandPath[1])) - ) -} - -function getRequiredStringFlag(flags: Map, name: string): string { - const value = flags.get(name) - if (typeof value === 'string' && value.length > 0) { - return value - } - throw new RuntimeClientError('invalid_argument', `Missing required --${name}`) -} - -function getOptionalStringFlag( - flags: Map, - name: string -): string | undefined { - const value = flags.get(name) - return typeof value === 'string' && value.length > 0 ? value : undefined -} - -export function buildCurrentWorktreeSelector(cwd: string): string { - return `path:${resolvePath(cwd)}` -} - -export function normalizeWorktreeSelector(selector: string, cwd: string): string { - if (selector === 'active' || selector === 'current') { - return buildCurrentWorktreeSelector(cwd) - } - return selector -} - -function isWithinPath(parentPath: string, childPath: string): boolean { - const relativePath = relative(parentPath, childPath) - return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath)) -} - -async function resolveCurrentWorktreeSelector(cwd: string, client: RuntimeClient): Promise { - const currentPath = resolvePath(cwd) - const worktrees = await client.call('worktree.list', { - limit: 10_000 - }) - const enclosingWorktree = worktrees.result.worktrees - .filter((worktree) => isWithinPath(resolvePath(worktree.path), currentPath)) - .sort((left, right) => right.path.length - left.path.length)[0] - - if (!enclosingWorktree) { - throw new RuntimeClientError( - 'selector_not_found', - `No Orca-managed worktree contains the current directory: ${currentPath}` - ) - } - - // Why: users expect "active/current" to mean the enclosing managed worktree - // even from nested subdirectories. The CLI resolves that shell-local concept - // to the deepest matching worktree root, then hands the runtime a normal - // path selector so selector semantics stay centralized in one layer. - return buildCurrentWorktreeSelector(enclosingWorktree.path) -} - -async function getOptionalWorktreeSelector( - flags: Map, - name: string, - cwd: string, - client: RuntimeClient -): Promise { - const value = getOptionalStringFlag(flags, name) - if (!value) { - return undefined - } - if (value === 'active' || value === 'current') { - return await resolveCurrentWorktreeSelector(cwd, client) - } - return normalizeWorktreeSelector(value, cwd) -} - -async function getRequiredWorktreeSelector( - flags: Map, - name: string, - cwd: string, - client: RuntimeClient -): Promise { - const value = getRequiredStringFlag(flags, name) - if (value === 'active' || value === 'current') { - return await resolveCurrentWorktreeSelector(cwd, client) - } - return normalizeWorktreeSelector(value, cwd) -} - -// Why: browser commands default to the current worktree (auto-resolve from cwd). -// --worktree all bypasses filtering. Omitting --worktree auto-resolves. -async function getBrowserWorktreeSelector( - flags: Map, - cwd: string, - client: RuntimeClient -): Promise { - const value = getOptionalStringFlag(flags, 'worktree') - if (value === 'all') { - return undefined - } - if (value) { - if (value === 'active' || value === 'current') { - return await resolveCurrentWorktreeSelector(cwd, client) - } - return normalizeWorktreeSelector(value, cwd) - } - // Default: auto-resolve from cwd - try { - return await resolveCurrentWorktreeSelector(cwd, client) - } catch { - // Not inside a managed worktree — no filter - return undefined - } -} - -// Why: mirrors browser's implicit active-tab targeting. When --terminal is -// omitted, resolve the active terminal in the current worktree so commands -// like `orca terminal send --text "hello" --enter` Just Work. -async function getTerminalHandle( - flags: Map, - cwd: string, - client: RuntimeClient -): Promise { - const explicit = getOptionalStringFlag(flags, 'terminal') - if (explicit) { - return explicit - } - const worktree = await getBrowserWorktreeSelector(flags, cwd, client) - const response = await client.call<{ handle: string }>('terminal.resolveActive', { worktree }) - return response.result.handle -} - -async function getBrowserCommandTarget( - flags: Map, - cwd: string, - client: RuntimeClient -): Promise { - const page = getOptionalStringFlag(flags, 'page') - if (!page) { - return { - worktree: await getBrowserWorktreeSelector(flags, cwd, client) - } - } - - const explicitWorktree = getOptionalStringFlag(flags, 'worktree') - if (!explicitWorktree || explicitWorktree === 'all') { - return { page } - } - if (explicitWorktree === 'active' || explicitWorktree === 'current') { - return { - page, - worktree: await resolveCurrentWorktreeSelector(cwd, client) - } - } - return { - page, - worktree: normalizeWorktreeSelector(explicitWorktree, cwd) - } -} - -function getOptionalNumberFlag( - flags: Map, - name: string -): number | undefined { - const value = flags.get(name) - if (typeof value !== 'string' || value.length === 0) { - return undefined - } - const parsed = Number(value) - if (!Number.isFinite(parsed)) { - throw new RuntimeClientError('invalid_argument', `Invalid numeric value for --${name}`) - } - return parsed -} - -function getOptionalPositiveIntegerFlag( - flags: Map, - name: string -): number | undefined { - const value = getOptionalNumberFlag(flags, name) - if (value === undefined) { - return undefined - } - if (!Number.isInteger(value) || value <= 0) { - throw new RuntimeClientError('invalid_argument', `Invalid positive integer for --${name}`) - } - return value -} - -function getOptionalNonNegativeIntegerFlag( - flags: Map, - name: string -): number | undefined { - const value = getOptionalNumberFlag(flags, name) - if (value === undefined) { - return undefined - } - if (!Number.isInteger(value) || value < 0) { - throw new RuntimeClientError('invalid_argument', `Invalid non-negative integer for --${name}`) - } - return value -} - -function getRequiredPositiveNumber(flags: Map, name: string): number { - const raw = getRequiredStringFlag(flags, name) - const value = Number(raw) - if (!Number.isFinite(value) || value <= 0) { - throw new RuntimeClientError('invalid_argument', `--${name} must be a positive number`) - } - return value -} - -function getRequiredFiniteNumber(flags: Map, name: string): number { - const raw = getRequiredStringFlag(flags, name) - const value = Number(raw) - if (!Number.isFinite(value)) { - throw new RuntimeClientError('invalid_argument', `--${name} must be a valid number`) - } - return value -} - -function getOptionalNullableNumberFlag( - flags: Map, - name: string -): number | null | undefined { - const value = flags.get(name) - if (value === 'null') { - return null - } - return getOptionalNumberFlag(flags, name) -} - -export function matches(actual: string[], expected: string[]): boolean { - return ( - actual.length === expected.length && actual.every((value, index) => value === expected[index]) - ) -} - -function printResult( - response: RuntimeRpcSuccess, - json: boolean, - formatter: (value: TResult) => string -): void { - if (json) { - console.log(JSON.stringify(response, null, 2)) - return - } - console.log(formatter(response.result)) -} - -function formatStatus(status: CliStatusResult): string { - return formatCliStatus(status) -} - -function formatCliStatus(status: CliStatusResult): string { - return [ - `appRunning: ${status.app.running}`, - `pid: ${status.app.pid ?? 'none'}`, - `runtimeState: ${status.runtime.state}`, - `runtimeReachable: ${status.runtime.reachable}`, - `runtimeId: ${status.runtime.runtimeId ?? 'none'}`, - `graphState: ${status.graph.state}` - ].join('\n') -} - -function formatCliError(error: unknown): string { - const message = error instanceof Error ? error.message : String(error) - if (error instanceof RuntimeClientError && error.code === 'runtime_unavailable') { - return `${message}\nOrca is not running. Run 'orca open' first.` - } - if ( - error instanceof RuntimeRpcFailureError && - error.response.error.code === 'runtime_unavailable' - ) { - return `${message}\nOrca is not running. Run 'orca open' first.` - } - return message -} - -function formatTerminalList(result: RuntimeTerminalListResult): string { - if (result.terminals.length === 0) { - return 'No live terminals.' - } - const body = result.terminals - .map( - (terminal) => - `${terminal.handle} ${terminal.title ?? '(untitled)'} ${terminal.connected ? 'connected' : 'disconnected'} ${terminal.worktreePath}\n${terminal.preview ? `preview: ${terminal.preview}` : 'preview: '}` - ) - .join('\n\n') - return result.truncated - ? `${body}\n\ntruncated: showing ${result.terminals.length} of ${result.totalCount}` - : body -} - -function formatTerminalShow(result: { terminal: RuntimeTerminalShow }): string { - const terminal = result.terminal - return [ - `handle: ${terminal.handle}`, - `title: ${terminal.title ?? '(untitled)'}`, - `worktree: ${terminal.worktreePath}`, - `branch: ${terminal.branch}`, - `leaf: ${terminal.leafId}`, - `ptyId: ${terminal.ptyId ?? 'none'}`, - `connected: ${terminal.connected}`, - `writable: ${terminal.writable}`, - `preview: ${terminal.preview || ''}` - ].join('\n') -} - -function formatTerminalRead(result: { terminal: RuntimeTerminalRead }): string { - const terminal = result.terminal - const header = [ - `handle: ${terminal.handle}`, - `status: ${terminal.status}`, - ...(terminal.nextCursor !== null ? [`cursor: ${terminal.nextCursor}`] : []) - ] - return [...header, '', ...terminal.tail].join('\n') -} - -function formatTerminalSend(result: { send: RuntimeTerminalSend }): string { - return `Sent ${result.send.bytesWritten} bytes to ${result.send.handle}.` -} - -function formatTerminalRename(result: { rename: RuntimeTerminalRename }): string { - return result.rename.title - ? `Renamed terminal ${result.rename.handle} to "${result.rename.title}".` - : `Cleared title for terminal ${result.rename.handle}.` -} - -function formatTerminalCreate(result: { terminal: RuntimeTerminalCreate }): string { - const titleNote = result.terminal.title ? ` (title: "${result.terminal.title}")` : '' - return `Created terminal ${result.terminal.handle}${titleNote}` -} - -function formatTerminalSplit(result: { split: RuntimeTerminalSplit }): string { - return `Split pane ${result.split.handle} in tab ${result.split.tabId}` -} - -function formatTerminalFocus(result: { focus: RuntimeTerminalFocus }): string { - return `Focused terminal ${result.focus.handle} (tab ${result.focus.tabId}).` -} - -function formatTerminalClose(result: { close: RuntimeTerminalClose }): string { - const ptyNote = result.close.ptyKilled ? ' PTY killed.' : '' - return `Closed terminal ${result.close.handle}.${ptyNote}` -} - -function formatTerminalWait(result: { wait: RuntimeTerminalWait }): string { - return [ - `handle: ${result.wait.handle}`, - `condition: ${result.wait.condition}`, - `satisfied: ${result.wait.satisfied}`, - `status: ${result.wait.status}`, - `exitCode: ${result.wait.exitCode ?? 'null'}` - ].join('\n') -} - -function formatWorktreePs(result: RuntimeWorktreePsResult): string { - if (result.worktrees.length === 0) { - return 'No worktrees found.' - } - const body = result.worktrees - .map( - (worktree) => - `${worktree.repo} ${worktree.branch} live:${worktree.liveTerminalCount} pty:${worktree.hasAttachedPty ? 'yes' : 'no'} unread:${worktree.unread ? 'yes' : 'no'}\n${worktree.path}${worktree.preview ? `\npreview: ${worktree.preview}` : ''}` - ) - .join('\n\n') - return result.truncated - ? `${body}\n\ntruncated: showing ${result.worktrees.length} of ${result.totalCount}` - : body -} - -function formatRepoList(result: RuntimeRepoList): string { - if (result.repos.length === 0) { - return 'No repos found.' - } - return result.repos.map((repo) => `${repo.id} ${repo.displayName} ${repo.path}`).join('\n') -} - -function formatRepoShow(result: { repo: Record }): string { - return Object.entries(result.repo) - .map( - ([key, value]) => - `${key}: ${typeof value === 'object' ? JSON.stringify(value) : String(value)}` - ) - .join('\n') -} - -function formatRepoRefs(result: RuntimeRepoSearchRefs): string { - if (result.refs.length === 0) { - return 'No refs found.' - } - return result.truncated ? `${result.refs.join('\n')}\n\ntruncated: yes` : result.refs.join('\n') -} - -function formatWorktreeList(result: RuntimeWorktreeListResult): string { - if (result.worktrees.length === 0) { - return 'No worktrees found.' - } - const body = result.worktrees - .map( - (worktree) => - `${String(worktree.id)} ${String(worktree.branch)} ${String(worktree.path)}\ndisplayName: ${String(worktree.displayName ?? '')}\nlinkedIssue: ${String(worktree.linkedIssue ?? 'null')}\ncomment: ${String(worktree.comment ?? '')}` - ) - .join('\n\n') - return result.truncated - ? `${body}\n\ntruncated: showing ${result.worktrees.length} of ${result.totalCount}` - : body -} - -function formatWorktreeShow(result: { worktree: RuntimeWorktreeRecord }): string { - const worktree = result.worktree - return Object.entries(worktree) - .map( - ([key, value]) => - `${key}: ${typeof value === 'object' ? JSON.stringify(value) : String(value)}` - ) - .join('\n') -} - -function formatSnapshot(result: BrowserSnapshotResult): string { - const header = `page: ${result.browserPageId}\n${result.title} — ${result.url}\n` - return header + result.snapshot -} - -function formatScreenshot(result: BrowserScreenshotResult): string { - return `Screenshot captured (${result.format}, ${Math.round(result.data.length * 0.75)} bytes)` -} - -function formatTabList(result: BrowserTabListResult): string { - if (result.tabs.length === 0) { - return 'No browser tabs open.' - } - return result.tabs - .map((t) => { - const marker = t.active ? '* ' : ' ' - return `${marker}[${t.index}] ${t.browserPageId} ${t.title} — ${t.url}` - }) - .join('\n') -} - -function printHelp(commandPath: string[] = []): void { - const exactSpec = findCommandSpec(commandPath) - if (exactSpec) { - console.log(formatCommandHelp(exactSpec)) - return - } - - if (isCommandGroup(commandPath)) { - console.log(formatGroupHelp(commandPath[0])) - return - } - - if (commandPath.length > 0) { - console.log(`Unknown command: ${commandPath.join(' ')}\n`) - } - - console.log(`orca - -Usage: orca [options] - -Startup: - open Launch Orca and wait for the runtime to be reachable - status Show app/runtime/graph readiness - -Repos: - repo list List repos registered in Orca - repo add Add a project to Orca by filesystem path - repo show Show one registered repo - repo set-base-ref Set the repo's default base ref for future worktrees - repo search-refs Search branch/tag refs within a repo - -Worktrees: - worktree list List Orca-managed worktrees - worktree show Show one worktree - worktree current Show the Orca-managed worktree for the current directory - worktree create Create a new Orca-managed worktree - worktree set Update Orca metadata for a worktree - worktree rm Remove a worktree from Orca and git - worktree ps Show a compact orchestration summary across worktrees - -Terminals: - terminal list List live Orca-managed terminals - terminal show Show terminal metadata and preview - terminal read Read bounded terminal output - terminal send Send input to a live terminal - terminal wait Wait for a terminal condition (exit, tui-idle) - terminal stop Stop terminals for a worktree - terminal create Create a new terminal tab in a worktree - terminal rename Set or clear the title of a terminal tab - terminal split Split an existing terminal pane - terminal switch Bring a terminal tab to the foreground - terminal focus Alias for terminal switch - terminal close Close a terminal pane (or tab if last pane) - -Browser Automation: - tab create Create a new browser tab (navigates to --url) - tab list List open browser tabs - tab switch Switch the active browser tab by --index or --page - tab close Close a browser tab by --index/--page or the current tab - snapshot Accessibility snapshot with element refs (e.g. @e1, @e2) - goto Navigate the active tab to --url - click Click element by --element ref - fill Clear and fill input by --element ref with --value - type Type --input text at the current focus (no element needed) - select Select dropdown option by --element ref and --value - hover Hover element by --element ref - keypress Press a key (e.g. --key Enter, --key Tab) - scroll Scroll --direction (up/down) by --amount pixels - back Navigate back in browser history - reload Reload the active browser tab - screenshot Capture viewport screenshot (--format png|jpeg) - eval Evaluate --expression JavaScript in the page context - wait Wait for page idle or --timeout ms - check Check a checkbox by --element ref - uncheck Uncheck a checkbox by --element ref - focus Focus an element by --element ref - clear Clear an input by --element ref - drag Drag --from ref to --to ref - upload Upload --files to a file input by --element ref - dblclick Double-click element by --element ref - forward Navigate forward in browser history - scrollintoview Scroll --element into view - get Get element property (--what: text, html, value, url, title) - is Check element state (--what: visible, enabled, checked) - inserttext Insert text without key events - mouse move Move mouse to --x --y coordinates - mouse down Press mouse button - mouse up Release mouse button - mouse wheel Scroll wheel --dy [--dx] - find Find element by locator (--locator role|text|label --value ) - set device Emulate device (--name "iPhone 12") - set offline Toggle offline mode (--state on|off) - set headers Set HTTP headers (--headers '{"key":"val"}') - set credentials Set HTTP auth (--user --pass

) - set media Set color scheme (--color-scheme dark|light) - clipboard read Read clipboard contents - clipboard write Write --text to clipboard - dialog accept Accept browser dialog (--text for prompt response) - dialog dismiss Dismiss browser dialog - storage local get Get localStorage value by --key - storage local set Set localStorage --key --value - storage local clear Clear localStorage - storage session get Get sessionStorage value by --key - storage session set Set sessionStorage --key --value - storage session clear Clear sessionStorage - download Download file via --selector to --path - highlight Highlight --selector on page - exec Run any agent-browser command (--command "...") - -Common Commands: - orca open [--json] - orca status [--json] - orca worktree list [--repo ] [--limit ] [--json] - orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--json] - orca worktree show --worktree [--json] - orca worktree current [--json] - orca worktree set --worktree [--display-name ] [--issue ] [--comment ] [--json] - orca worktree rm --worktree [--force] [--json] - orca worktree ps [--limit ] [--json] - orca terminal list [--worktree ] [--limit ] [--json] - orca terminal show [--terminal ] [--json] - orca terminal read [--terminal ] [--json] - orca terminal send [--terminal ] [--text ] [--enter] [--interrupt] [--json] - orca terminal wait [--terminal ] --for exit|tui-idle [--timeout-ms ] [--json] - orca terminal stop --worktree [--json] - orca terminal create [--worktree ] [--title ] [--command ] [--json] - orca terminal split [--terminal ] [--direction horizontal|vertical] [--json] - orca terminal switch [--terminal ] [--json] - orca terminal close [--terminal ] [--json] - orca repo list [--json] - orca repo add --path [--json] - orca repo show --repo [--json] - orca repo set-base-ref --repo --ref [--json] - orca repo search-refs --repo --query [--limit ] [--json] - -Selectors: - --repo Registered repo selector such as id:, name:, or path: - --worktree Worktree selector such as id:, branch:, issue:, path:, or active/current - --terminal Runtime-issued terminal handle returned by \`orca terminal list --json\` - -Terminal Send Options: - --text Text to send to the terminal - --enter Append Enter after sending text - --interrupt Send as an interrupt-style input when supported - -Wait Options: - --for exit Wait until the target terminal exits - --timeout-ms Maximum wait time before timing out - -Output Options: - --json Emit machine-readable JSON instead of human text - --help Show this help message - -Behavior: - Most commands require a running Orca runtime. If Orca is not open yet, run \`orca open\` first. - Use selectors for discovery and handles for repeated live terminal operations. - -Browser Workflow: - 1. Create or navigate: orca tab create --url https://example.com - orca goto --url https://example.com - 2. Inspect the page: orca snapshot - (Returns an accessibility tree with element refs like e1, e2, e3) - For concurrent workflows, prefer: orca tab list --json - then reuse tabs[].browserPageId with --page on later commands. - 3. Interact: orca click --element e2 - orca fill --element e5 --value "search query" - orca keypress --key Enter - 4. Re-inspect: orca snapshot - (Element refs change after navigation — always re-snapshot before interacting) - -Browser Options: - --element Element ref from snapshot (e.g. @e3) - --url URL to navigate to - --value Value to fill or select - --input Text to type at current focus (no element needed) - --expression JavaScript expression to evaluate - --key Key to press (Enter, Tab, Escape, Control+a, etc.) - --direction

Scroll direction: up or down - --amount Scroll distance in pixels (default: viewport height) - --index Tab index (from \`tab list\`) - --page Stable browser page id (preferred for concurrent workflows) - --format Screenshot image format - --from Drag source element ref - --to Drag target element ref - --files Comma-separated file paths for upload - --timeout Wait timeout in milliseconds - --worktree Scope commands to a specific worktree's browser tabs - -Examples: - $ orca open - $ orca status --json - $ orca repo list - $ orca worktree create --repo name:orca --name cli-test-1 --issue 273 - $ orca worktree show --worktree branch:Jinwoo-H/cli - $ orca worktree current - $ orca worktree set --worktree active --comment "waiting on review" - $ orca worktree ps --limit 10 - $ orca terminal list --worktree path:/Users/me/orca/workspaces/orca/cli-test-1 --json - $ orca terminal send --terminal term_123 --text "hi" --enter - $ orca terminal wait --terminal term_123 --for exit --timeout-ms 60000 --json - $ orca tab create --url https://example.com - $ orca snapshot - $ orca click --element e3 - $ orca fill --element e5 --value "hello" - $ orca goto --url https://example.com/login - $ orca keypress --key Enter - $ orca eval --expression "document.title" - $ orca tab list --json`) -} - -function formatCommandHelp(spec: CommandSpec): string { - const lines = [`orca ${spec.path.join(' ')}`, '', `Usage: ${spec.usage}`, '', spec.summary] - const displayedFlags = supportsBrowserPageFlag(spec.path) - ? [...spec.allowedFlags, 'page'] - : spec.allowedFlags - - if (displayedFlags.length > 0) { - lines.push('', 'Options:') - for (const flag of displayedFlags) { - lines.push(` ${formatFlagHelp(flag)}`) - } - } - - if (spec.notes && spec.notes.length > 0) { - lines.push('', 'Notes:') - for (const note of spec.notes) { - lines.push(` ${note}`) - } - } - - if (spec.examples && spec.examples.length > 0) { - lines.push('', 'Examples:') - for (const example of spec.examples) { - lines.push(` $ ${example}`) - } - } - - return lines.join('\n') -} - -function formatGroupHelp(group: string): string { - const specs = COMMAND_SPECS.filter((spec) => spec.path[0] === group) - const lines = [`orca ${group}`, '', `Usage: orca ${group} [options]`, '', 'Commands:'] - for (const spec of specs) { - lines.push(` ${spec.path.slice(1).join(' ').padEnd(18)} ${spec.summary}`) - } - lines.push('', `Run \`orca ${group} --help\` for command-specific usage.`) - return lines.join('\n') -} - -function formatFlagHelp(flag: string): string { - const helpByFlag: Record = { - 'base-branch': '--base-branch Base branch/ref to create the worktree from', - command: '--command Command to run in the terminal on startup', - comment: '--comment Comment stored in Orca metadata', - cursor: '--cursor Line cursor from a previous read (returns only new output)', - direction: '--direction Direction: horizontal|vertical (split) or up|down (scroll)', - 'display-name': '--display-name Override the Orca display name', - title: '--title Custom title for the terminal tab (omit to reset)', - enter: '--enter Append Enter after sending text', - force: '--force Force worktree removal when supported', - for: '--for exit|tui-idle Wait condition to satisfy', - help: '--help Show this help message', - interrupt: '--interrupt Send as an interrupt-style input when supported', - issue: '--issue Linked GitHub issue number', - json: '--json Emit machine-readable JSON', - limit: '--limit Maximum number of rows to return', - name: '--name Name for the new worktree', - path: '--path Filesystem path to the repo', - query: '--query Search text for matching refs', - ref: '--ref Base ref to persist for the repo', - repo: '--repo Repo selector such as id:, name:, or path:', - terminal: '--terminal Runtime-issued terminal handle', - text: '--text Text to send to the terminal', - 'timeout-ms': '--timeout-ms Maximum wait time before timing out', - worktree: - '--worktree Worktree selector such as id:, branch:, issue:, path:, or active/current', - // Browser automation flags - element: '--element Element ref from snapshot (e.g. e3)', - url: '--url URL to navigate to', - value: '--value Value to fill or select', - input: '--input Text to type at current focus', - expression: '--expression JavaScript expression to evaluate', - amount: '--amount Scroll distance in pixels', - index: '--index Tab index to switch to', - page: '--page Stable browser page id from `orca tab list --json`', - format: '--format Screenshot image format' - } - - return helpByFlag[flag] ?? `--${flag}` -} - if (require.main === module) { void main() } diff --git a/src/cli/selectors.ts b/src/cli/selectors.ts new file mode 100644 index 000000000..dfdfa7377 --- /dev/null +++ b/src/cli/selectors.ts @@ -0,0 +1,152 @@ +import { isAbsolute, relative, resolve as resolvePath } from 'path' +import type { RuntimeWorktreeListResult } from '../shared/runtime-types' +import type { RuntimeClient } from './runtime-client' +import { RuntimeClientError } from './runtime-client' +import { getOptionalStringFlag, getRequiredStringFlag } from './flags' + +export type BrowserCliTarget = { + worktree?: string + page?: string +} + +export function buildCurrentWorktreeSelector(cwd: string): string { + return `path:${resolvePath(cwd)}` +} + +export function normalizeWorktreeSelector(selector: string, cwd: string): string { + if (selector === 'active' || selector === 'current') { + return buildCurrentWorktreeSelector(cwd) + } + return selector +} + +function isWithinPath(parentPath: string, childPath: string): boolean { + const relativePath = relative(parentPath, childPath) + return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath)) +} + +export async function resolveCurrentWorktreeSelector( + cwd: string, + client: RuntimeClient +): Promise { + const currentPath = resolvePath(cwd) + const worktrees = await client.call('worktree.list', { + limit: 10_000 + }) + const enclosingWorktree = worktrees.result.worktrees + .filter((worktree) => isWithinPath(resolvePath(worktree.path), currentPath)) + .sort((left, right) => right.path.length - left.path.length)[0] + + if (!enclosingWorktree) { + throw new RuntimeClientError( + 'selector_not_found', + `No Orca-managed worktree contains the current directory: ${currentPath}` + ) + } + + // Why: users expect "active/current" to mean the enclosing managed worktree + // even from nested subdirectories. The CLI resolves that shell-local concept + // to the deepest matching worktree root, then hands the runtime a normal + // path selector so selector semantics stay centralized in one layer. + return buildCurrentWorktreeSelector(enclosingWorktree.path) +} + +export async function getOptionalWorktreeSelector( + flags: Map, + name: string, + cwd: string, + client: RuntimeClient +): Promise { + const value = getOptionalStringFlag(flags, name) + if (!value) { + return undefined + } + if (value === 'active' || value === 'current') { + return await resolveCurrentWorktreeSelector(cwd, client) + } + return normalizeWorktreeSelector(value, cwd) +} + +export async function getRequiredWorktreeSelector( + flags: Map, + name: string, + cwd: string, + client: RuntimeClient +): Promise { + const value = getRequiredStringFlag(flags, name) + if (value === 'active' || value === 'current') { + return await resolveCurrentWorktreeSelector(cwd, client) + } + return normalizeWorktreeSelector(value, cwd) +} + +// Why: browser commands default to the current worktree (auto-resolve from cwd). +// --worktree all bypasses filtering. Omitting --worktree auto-resolves. +export async function getBrowserWorktreeSelector( + flags: Map, + cwd: string, + client: RuntimeClient +): Promise { + const value = getOptionalStringFlag(flags, 'worktree') + if (value === 'all') { + return undefined + } + if (value) { + if (value === 'active' || value === 'current') { + return await resolveCurrentWorktreeSelector(cwd, client) + } + return normalizeWorktreeSelector(value, cwd) + } + // Default: auto-resolve from cwd + try { + return await resolveCurrentWorktreeSelector(cwd, client) + } catch { + // Not inside a managed worktree — no filter + return undefined + } +} + +// Why: mirrors browser's implicit active-tab targeting. When --terminal is +// omitted, resolve the active terminal in the current worktree so commands +// like `orca terminal send --text "hello" --enter` Just Work. +export async function getTerminalHandle( + flags: Map, + cwd: string, + client: RuntimeClient +): Promise { + const explicit = getOptionalStringFlag(flags, 'terminal') + if (explicit) { + return explicit + } + const worktree = await getBrowserWorktreeSelector(flags, cwd, client) + const response = await client.call<{ handle: string }>('terminal.resolveActive', { worktree }) + return response.result.handle +} + +export async function getBrowserCommandTarget( + flags: Map, + cwd: string, + client: RuntimeClient +): Promise { + const page = getOptionalStringFlag(flags, 'page') + if (!page) { + return { + worktree: await getBrowserWorktreeSelector(flags, cwd, client) + } + } + + const explicitWorktree = getOptionalStringFlag(flags, 'worktree') + if (!explicitWorktree || explicitWorktree === 'all') { + return { page } + } + if (explicitWorktree === 'active' || explicitWorktree === 'current') { + return { + page, + worktree: await resolveCurrentWorktreeSelector(cwd, client) + } + } + return { + page, + worktree: normalizeWorktreeSelector(explicitWorktree, cwd) + } +} diff --git a/src/cli/specs/browser-advanced.ts b/src/cli/specs/browser-advanced.ts new file mode 100644 index 000000000..c7acc92c9 --- /dev/null +++ b/src/cli/specs/browser-advanced.ts @@ -0,0 +1,279 @@ +import type { CommandSpec } from '../args' +import { GLOBAL_FLAGS } from '../args' + +export const BROWSER_ADVANCED_COMMAND_SPECS: CommandSpec[] = [ + // ── Cookie management ── + { + path: ['cookie', 'get'], + summary: 'Get cookies for the active tab (optionally filter by URL)', + usage: 'orca cookie get [--url ] [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'url', 'worktree'] + }, + { + path: ['cookie', 'set'], + summary: 'Set a cookie', + usage: + 'orca cookie set --name --value [--domain ] [--path

] [--secure] [--httpOnly] [--sameSite ] [--expires ] [--worktree ] [--json]', + allowedFlags: [ + ...GLOBAL_FLAGS, + 'name', + 'value', + 'domain', + 'path', + 'secure', + 'httpOnly', + 'sameSite', + 'expires', + 'worktree' + ] + }, + { + path: ['cookie', 'delete'], + summary: 'Delete a cookie by name', + usage: + 'orca cookie delete --name [--domain ] [--url ] [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'name', 'domain', 'url', 'worktree'] + }, + // ── Viewport ── + { + path: ['viewport'], + summary: 'Set browser viewport size', + usage: + 'orca viewport --width --height [--scale ] [--mobile] [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'width', 'height', 'scale', 'mobile', 'worktree'] + }, + // ── Geolocation ── + { + path: ['geolocation'], + summary: 'Override browser geolocation', + usage: + 'orca geolocation --latitude --longitude [--accuracy ] [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'latitude', 'longitude', 'accuracy', 'worktree'] + }, + // ── Request interception ── + { + path: ['intercept', 'enable'], + summary: 'Enable request interception (pause matching requests)', + usage: 'orca intercept enable [--patterns ] [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'patterns', 'worktree'] + }, + { + path: ['intercept', 'disable'], + summary: 'Disable request interception', + usage: 'orca intercept disable [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + }, + { + path: ['intercept', 'list'], + summary: 'List paused (intercepted) requests', + usage: 'orca intercept list [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + }, + // TODO: add intercept continue/block once agent-browser supports per-request + // interception decisions (currently only supports URL-pattern-based route/unroute). + // ── Console/network capture ── + { + path: ['capture', 'start'], + summary: 'Start capturing console and network events', + usage: 'orca capture start [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + }, + { + path: ['capture', 'stop'], + summary: 'Stop capturing console and network events', + usage: 'orca capture stop [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + }, + { + path: ['console'], + summary: 'Show captured console log entries', + usage: 'orca console [--limit ] [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'limit', 'worktree'] + }, + { + path: ['network'], + summary: 'Show captured network requests', + usage: 'orca network [--limit ] [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'limit', 'worktree'] + }, + // ── Additional core commands ── + { + path: ['dblclick'], + summary: 'Double-click element by ref', + usage: 'orca dblclick --element [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'element', 'worktree'] + }, + { + path: ['forward'], + summary: 'Navigate forward in browser history', + usage: 'orca forward [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + }, + { + path: ['scrollintoview'], + summary: 'Scroll element into view', + usage: 'orca scrollintoview --element [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'element', 'worktree'] + }, + { + path: ['get'], + summary: 'Get element property (text, html, value, url, title, count, box)', + usage: 'orca get --what [--element ] [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'what', 'element', 'worktree'] + }, + { + path: ['is'], + summary: 'Check element state (visible, enabled, checked)', + usage: 'orca is --what --element [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'what', 'element', 'worktree'] + }, + // ── Keyboard insert text ── + { + path: ['inserttext'], + summary: 'Insert text without key events', + usage: 'orca inserttext --text [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'text', 'worktree'] + }, + // ── Mouse commands ── + { + path: ['mouse', 'move'], + summary: 'Move mouse to x,y coordinates', + usage: 'orca mouse move --x --y [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'x', 'y', 'worktree'] + }, + { + path: ['mouse', 'down'], + summary: 'Press mouse button', + usage: 'orca mouse down [--button ] [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'button', 'worktree'] + }, + { + path: ['mouse', 'up'], + summary: 'Release mouse button', + usage: 'orca mouse up [--button ] [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'button', 'worktree'] + }, + { + path: ['mouse', 'wheel'], + summary: 'Scroll wheel', + usage: 'orca mouse wheel --dy [--dx ] [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'dy', 'dx', 'worktree'] + }, + // ── Find (semantic locators) ── + { + path: ['find'], + summary: 'Find element by semantic locator and perform action', + usage: + 'orca find --locator --value --action [--text ] [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'locator', 'value', 'action', 'text', 'worktree'] + }, + // ── Set commands ── + { + path: ['set', 'device'], + summary: 'Emulate a device', + usage: 'orca set device --name [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'name', 'worktree'] + }, + { + path: ['set', 'offline'], + summary: 'Toggle offline mode', + usage: 'orca set offline [--state ] [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'state', 'worktree'] + }, + { + path: ['set', 'headers'], + summary: 'Set extra HTTP headers', + usage: 'orca set headers --headers [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'headers', 'worktree'] + }, + { + path: ['set', 'credentials'], + summary: 'Set HTTP auth credentials', + usage: 'orca set credentials --user --pass [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'user', 'pass', 'worktree'] + }, + { + path: ['set', 'media'], + summary: 'Set color scheme and reduced motion preferences', + usage: + 'orca set media [--color-scheme ] [--reduced-motion ] [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'color-scheme', 'reduced-motion', 'worktree'] + }, + // ── Clipboard commands ── + { + path: ['clipboard', 'read'], + summary: 'Read clipboard contents', + usage: 'orca clipboard read [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + }, + { + path: ['clipboard', 'write'], + summary: 'Write text to clipboard', + usage: 'orca clipboard write --text [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'text', 'worktree'] + }, + // ── Dialog commands ── + { + path: ['dialog', 'accept'], + summary: 'Accept a browser dialog', + usage: 'orca dialog accept [--text ] [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'text', 'worktree'] + }, + { + path: ['dialog', 'dismiss'], + summary: 'Dismiss a browser dialog', + usage: 'orca dialog dismiss [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + }, + // ── Storage commands ── + { + path: ['storage', 'local', 'get'], + summary: 'Get a localStorage value by key', + usage: 'orca storage local get --key [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'key', 'worktree'] + }, + { + path: ['storage', 'local', 'set'], + summary: 'Set a localStorage value', + usage: 'orca storage local set --key --value [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'key', 'value', 'worktree'] + }, + { + path: ['storage', 'local', 'clear'], + summary: 'Clear all localStorage', + usage: 'orca storage local clear [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + }, + { + path: ['storage', 'session', 'get'], + summary: 'Get a sessionStorage value by key', + usage: 'orca storage session get --key [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'key', 'worktree'] + }, + { + path: ['storage', 'session', 'set'], + summary: 'Set a sessionStorage value', + usage: 'orca storage session set --key --value [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'key', 'value', 'worktree'] + }, + { + path: ['storage', 'session', 'clear'], + summary: 'Clear all sessionStorage', + usage: 'orca storage session clear [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + }, + // ── Download command ── + { + path: ['download'], + summary: 'Download a file by clicking a selector', + usage: 'orca download --selector --path [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'selector', 'path', 'worktree'] + }, + // ── Highlight command ── + { + path: ['highlight'], + summary: 'Highlight an element by selector', + usage: 'orca highlight --selector [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'selector', 'worktree'] + } +] diff --git a/src/cli/specs/browser-basic.ts b/src/cli/specs/browser-basic.ts new file mode 100644 index 000000000..8a1e02893 --- /dev/null +++ b/src/cli/specs/browser-basic.ts @@ -0,0 +1,184 @@ +import type { CommandSpec } from '../args' +import { GLOBAL_FLAGS } from '../args' + +export const BROWSER_BASIC_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['snapshot'], + summary: 'Capture an accessibility snapshot of the active browser tab', + usage: 'orca snapshot [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + }, + { + path: ['screenshot'], + summary: 'Capture a viewport screenshot of the active browser tab', + usage: 'orca screenshot [--format ] [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'format', 'worktree'] + }, + { + path: ['click'], + summary: 'Click a browser element by ref', + usage: 'orca click --element [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'element', 'worktree'] + }, + { + path: ['fill'], + summary: 'Clear and fill a browser input by ref', + usage: 'orca fill --element --value [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'element', 'value', 'worktree'] + }, + { + path: ['type'], + summary: 'Type text at the current browser focus', + usage: 'orca type --input [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'input', 'worktree'] + }, + { + path: ['select'], + summary: 'Select a dropdown option by ref', + usage: 'orca select --element --value [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'element', 'value', 'worktree'] + }, + { + path: ['scroll'], + summary: 'Scroll the browser viewport', + usage: 'orca scroll --direction [--amount ] [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'direction', 'amount', 'worktree'] + }, + { + path: ['goto'], + summary: 'Navigate the active browser tab to a URL', + usage: 'orca goto --url [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'url', 'worktree'] + }, + { + path: ['back'], + summary: 'Navigate back in browser history', + usage: 'orca back [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + }, + { + path: ['reload'], + summary: 'Reload the active browser tab', + usage: 'orca reload [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + }, + { + path: ['eval'], + summary: 'Evaluate JavaScript in the browser page context', + usage: 'orca eval --expression [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'expression', 'worktree'] + }, + { + path: ['wait'], + summary: 'Wait for element, text, URL, load state, JS condition, or timeout', + usage: + 'orca wait [--selector ] [--timeout ] [--text ] [--url ] [--load ] [--fn ] [--state ] [--worktree ] [--json]', + allowedFlags: [ + ...GLOBAL_FLAGS, + 'selector', + 'timeout', + 'text', + 'url', + 'load', + 'fn', + 'state', + 'worktree' + ] + }, + { + path: ['check'], + summary: 'Check a checkbox/radio by ref', + usage: 'orca check --element [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'element', 'worktree'] + }, + { + path: ['uncheck'], + summary: 'Uncheck a checkbox/radio by ref', + usage: 'orca uncheck --element [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'element', 'worktree'] + }, + { + path: ['focus'], + summary: 'Focus a browser element by ref', + usage: 'orca focus --element [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'element', 'worktree'] + }, + { + path: ['clear'], + summary: 'Clear an input element by ref', + usage: 'orca clear --element [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'element', 'worktree'] + }, + { + path: ['select-all'], + summary: 'Select all text in an input by ref', + usage: 'orca select-all --element [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'element', 'worktree'] + }, + { + path: ['keypress'], + summary: 'Press a key (Enter, Tab, Escape, ArrowDown, etc.)', + usage: 'orca keypress --key [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'key', 'worktree'] + }, + { + path: ['pdf'], + summary: 'Export the active browser tab as PDF', + usage: 'orca pdf [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + }, + { + path: ['full-screenshot'], + summary: 'Capture a full-page screenshot (beyond viewport)', + usage: 'orca full-screenshot [--format ] [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'format', 'worktree'] + }, + { + path: ['hover'], + summary: 'Hover over a browser element by ref', + usage: 'orca hover --element [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'element', 'worktree'] + }, + { + path: ['drag'], + summary: 'Drag from one element to another', + usage: 'orca drag --from --to [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'from', 'to', 'worktree'] + }, + { + path: ['upload'], + summary: 'Upload files to a file input element', + usage: 'orca upload --element --files [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'element', 'files', 'worktree'] + }, + { + path: ['tab', 'list'], + summary: 'List open browser tabs', + usage: 'orca tab list [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + }, + { + path: ['tab', 'switch'], + summary: 'Switch the active browser tab', + usage: 'orca tab switch (--index | --page ) [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'index', 'worktree'] + }, + { + path: ['tab', 'create'], + summary: 'Create a new browser tab in the current worktree', + usage: 'orca tab create [--url ] [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'url', 'worktree'] + }, + { + path: ['tab', 'close'], + summary: 'Close a browser tab', + usage: 'orca tab close [--index ] [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'index', 'worktree'] + }, + { + path: ['exec'], + summary: 'Run any agent-browser command against the active browser tab', + usage: 'orca exec --command "" [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'command', 'worktree'] + } +] diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts new file mode 100644 index 000000000..95f8f66f0 --- /dev/null +++ b/src/cli/specs/core.ts @@ -0,0 +1,199 @@ +import type { CommandSpec } from '../args' +import { GLOBAL_FLAGS } from '../args' + +export const CORE_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['open'], + summary: 'Launch Orca and wait for the runtime to be reachable', + usage: 'orca open [--json]', + allowedFlags: [...GLOBAL_FLAGS], + examples: ['orca open', 'orca open --json'] + }, + { + path: ['status'], + summary: 'Show app/runtime/graph readiness', + usage: 'orca status [--json]', + allowedFlags: [...GLOBAL_FLAGS], + examples: ['orca status', 'orca status --json'] + }, + { + path: ['repo', 'list'], + summary: 'List repos registered in Orca', + usage: 'orca repo list [--json]', + allowedFlags: [...GLOBAL_FLAGS] + }, + { + path: ['repo', 'add'], + summary: 'Add a project to Orca by filesystem path', + usage: 'orca repo add --path [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'path'] + }, + { + path: ['repo', 'show'], + summary: 'Show one registered repo', + usage: 'orca repo show --repo [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'repo'] + }, + { + path: ['repo', 'set-base-ref'], + summary: "Set the repo's default base ref for future worktrees", + usage: 'orca repo set-base-ref --repo --ref [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'repo', 'ref'] + }, + { + path: ['repo', 'search-refs'], + summary: 'Search branch/tag refs within a repo', + usage: 'orca repo search-refs --repo --query [--limit ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'repo', 'query', 'limit'] + }, + { + path: ['worktree', 'list'], + summary: 'List Orca-managed worktrees', + usage: 'orca worktree list [--repo ] [--limit ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'repo', 'limit'] + }, + { + path: ['worktree', 'show'], + summary: 'Show one worktree', + usage: 'orca worktree show --worktree [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + }, + { + path: ['worktree', 'current'], + summary: 'Show the Orca-managed worktree for the current directory', + usage: 'orca worktree current [--json]', + allowedFlags: [...GLOBAL_FLAGS], + notes: [ + 'Resolves the current shell directory to a path: selector so agents can target the enclosing Orca worktree without spelling out $PWD.' + ], + examples: ['orca worktree current', 'orca worktree current --json'] + }, + { + path: ['worktree', 'create'], + summary: 'Create a new Orca-managed worktree', + usage: + 'orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'repo', 'name', 'base-branch', 'issue', 'comment'], + notes: ['By default this matches the Orca UI flow and activates the new worktree in the app.'] + }, + { + path: ['worktree', 'set'], + summary: 'Update Orca metadata for a worktree', + usage: + 'orca worktree set --worktree [--display-name ] [--issue ] [--comment ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'display-name', 'issue', 'comment'] + }, + { + path: ['worktree', 'rm'], + summary: 'Remove a worktree from Orca and git', + usage: 'orca worktree rm --worktree [--force] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'force'] + }, + { + path: ['worktree', 'ps'], + summary: 'Show a compact orchestration summary across worktrees', + usage: 'orca worktree ps [--limit ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'limit'] + }, + { + path: ['terminal', 'list'], + summary: 'List live Orca-managed terminals', + usage: 'orca terminal list [--worktree ] [--limit ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'limit'] + }, + { + path: ['terminal', 'show'], + summary: 'Show terminal metadata and preview', + usage: 'orca terminal show [--terminal ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'terminal'] + }, + { + path: ['terminal', 'read'], + summary: 'Read bounded terminal output', + usage: 'orca terminal read [--terminal ] [--cursor ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'cursor'], + notes: [ + 'Omit --terminal to target the active terminal in the current worktree.', + 'Use --cursor with the nextCursor value from a previous read to get only new output since that read.', + 'Useful for capturing the response to a command: read before sending, then read --cursor after waiting.' + ], + examples: [ + 'orca terminal read --json', + 'orca terminal read --terminal term_abc123 --cursor 42 --json' + ] + }, + { + path: ['terminal', 'send'], + summary: 'Send input to a live terminal', + usage: + 'orca terminal send [--terminal ] [--text ] [--enter] [--interrupt] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'text', 'enter', 'interrupt'] + }, + { + path: ['terminal', 'wait'], + summary: 'Wait for a terminal condition', + usage: + 'orca terminal wait [--terminal ] --for exit|tui-idle [--timeout-ms ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'for', 'timeout-ms'] + }, + { + path: ['terminal', 'stop'], + summary: 'Stop terminals for a worktree', + usage: 'orca terminal stop --worktree [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + }, + { + path: ['terminal', 'create'], + summary: 'Create a new terminal tab in the current worktree', + usage: + 'orca terminal create [--worktree ] [--title ] [--command ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'command', 'title'], + examples: [ + 'orca terminal create --json', + 'orca terminal create --worktree path:/projects/myapp --title "RUNNER" --command "opencode"' + ] + }, + { + path: ['terminal', 'switch'], + summary: 'Switch to a terminal tab in the UI', + usage: 'orca terminal switch [--terminal ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'terminal'], + examples: ['orca terminal switch --terminal term_abc123'] + }, + { + path: ['terminal', 'focus'], + summary: 'Switch to a terminal tab in the UI (alias for terminal switch)', + usage: 'orca terminal focus [--terminal ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'terminal'], + examples: ['orca terminal focus --terminal term_abc123'] + }, + { + path: ['terminal', 'close'], + summary: 'Close a terminal tab (kills PTY if running)', + usage: 'orca terminal close [--terminal ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'terminal'], + examples: ['orca terminal close --terminal term_abc123'] + }, + { + path: ['terminal', 'rename'], + summary: 'Set or clear the title of a terminal tab', + usage: 'orca terminal rename [--terminal ] [--title ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'title'], + notes: ['Omit --title or pass an empty string to reset to the auto-generated title.'], + examples: [ + 'orca terminal rename --terminal term_abc123 --title "RUNNER"', + 'orca terminal rename --terminal term_abc123 --json' + ] + }, + { + path: ['terminal', 'split'], + summary: 'Split an existing terminal pane', + usage: + 'orca terminal split [--terminal ] [--direction horizontal|vertical] [--command ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'direction', 'command'], + examples: [ + 'orca terminal split --terminal term_abc123 --direction horizontal --json', + 'orca terminal split --terminal term_abc123 --command "codex"' + ] + } +] diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts new file mode 100644 index 000000000..dfa343aed --- /dev/null +++ b/src/cli/specs/index.ts @@ -0,0 +1,10 @@ +import type { CommandSpec } from '../args' +import { BROWSER_ADVANCED_COMMAND_SPECS } from './browser-advanced' +import { BROWSER_BASIC_COMMAND_SPECS } from './browser-basic' +import { CORE_COMMAND_SPECS } from './core' + +export const COMMAND_SPECS: CommandSpec[] = [ + ...CORE_COMMAND_SPECS, + ...BROWSER_BASIC_COMMAND_SPECS, + ...BROWSER_ADVANCED_COMMAND_SPECS +] diff --git a/src/cli/test-fixtures.ts b/src/cli/test-fixtures.ts new file mode 100644 index 000000000..637041bbb --- /dev/null +++ b/src/cli/test-fixtures.ts @@ -0,0 +1,62 @@ +import type { Mock } from 'vitest' + +type OkFixture = { + id: string + ok: true + result: unknown + _meta: { runtimeId: string } +} + +type WorktreeFixture = { + id: string + repoId: string + path: string + branch: string + linkedIssue: null + git: { + path: string + head: string + branch: string + isBare: false + isMainWorktree: false + } + displayName: string + comment: string +} + +export function buildWorktree( + path: string, + branch: string, + head = 'abc', + repoId = 'repo' +): WorktreeFixture { + return { + id: `${repoId}::${path}`, + repoId, + path, + branch, + linkedIssue: null, + git: { path, head, branch, isBare: false, isMainWorktree: false }, + displayName: '', + comment: '' + } +} + +export function worktreeListFixture(worktrees: WorktreeFixture[]): OkFixture { + return { + id: 'req_list', + ok: true, + result: { worktrees, totalCount: worktrees.length, truncated: false }, + _meta: { runtimeId: 'runtime-1' } + } +} + +export function okFixture(id: string, result: unknown): OkFixture { + return { id, ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +export function queueFixtures(mock: Mock, ...fixtures: OkFixture[]): void { + for (const fixture of fixtures) { + mock.mockResolvedValueOnce(fixture) + } +}