refactor(cli): split index.ts into per-verb handler modules (#1089)

This commit is contained in:
Neil 2026-04-25 13:32:14 -07:00 committed by GitHub
parent 5f1161b1ab
commit 36c6a9241f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 3039 additions and 3056 deletions

120
src/cli/args.ts Normal file
View File

@ -0,0 +1,120 @@
import { RuntimeClientError } from './runtime-client'
export type ParsedArgs = {
commandPath: string[]
flags: Map<string, string | boolean>
}
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<string, string | boolean>()
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(' ')}`
)
}
}
}

242
src/cli/browser.test.ts Normal file
View File

@ -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
})
})
})

58
src/cli/dispatch.ts Normal file
View File

@ -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<string, string | boolean>
client: RuntimeClient
cwd: string
json: boolean
}
export type CommandHandler = (ctx: HandlerContext) => Promise<void>
function buildHandlers(): Map<string, CommandHandler> {
const table = new Map<string, CommandHandler>()
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<void> {
const handler = HANDLERS.get(commandPath.join(' '))
if (!handler) {
throw new RuntimeClientError('invalid_argument', `Unknown command: ${commandPath.join(' ')}`)
}
await handler(ctx)
}

95
src/cli/flags.ts Normal file
View File

@ -0,0 +1,95 @@
import { RuntimeClientError } from './runtime-client'
export function getRequiredStringFlag(flags: Map<string, string | boolean>, 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<string, string | boolean>,
name: string
): string | undefined {
const value = flags.get(name)
return typeof value === 'string' && value.length > 0 ? value : undefined
}
export function getOptionalNumberFlag(
flags: Map<string, string | boolean>,
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<string, string | boolean>,
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<string, string | boolean>,
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<string, string | boolean>,
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<string, string | boolean>,
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<string, string | boolean>,
name: string
): number | null | undefined {
const value = flags.get(name)
if (value === 'null') {
return null
}
return getOptionalNumberFlag(flags, name)
}

249
src/cli/format.ts Normal file
View File

@ -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<TResult>(
response: RuntimeRpcSuccess<TResult>,
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: <empty>'}`
)
.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 || '<empty>'}`
].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, unknown> }): 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')
}

View File

@ -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<string, CommandHandler> = {
'intercept enable': async ({ flags, client, cwd, json }) => {
const params: Record<string, unknown> = {}
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<BrowserInterceptEnableResult>(
'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<BrowserInterceptDisableResult>(
'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<BrowserCaptureStartResult>('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<BrowserCaptureStopResult>('browser.capture.stop', target)
printResult(result, json, () => 'Capture stopped')
},
console: async ({ flags, client, cwd, json }) => {
const params: Record<string, unknown> = {}
const limit = getOptionalPositiveIntegerFlag(flags, 'limit')
if (limit !== undefined) {
params.limit = limit
}
Object.assign(params, await getBrowserCommandTarget(flags, cwd, client))
const result = await client.call<BrowserConsoleResult>('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<string, unknown> = {}
const limit = getOptionalPositiveIntegerFlag(flags, 'limit')
if (limit !== undefined) {
params.limit = limit
}
Object.assign(params, await getBrowserCommandTarget(flags, cwd, client))
const result = await client.call<BrowserNetworkLogResult>('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')
})
}
}

View File

@ -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<string, CommandHandler> = {
'cookie get': async ({ flags, client, cwd, json }) => {
const url = getOptionalStringFlag(flags, 'url')
const target = await getBrowserCommandTarget(flags, cwd, client)
const result = await client.call<BrowserCookieGetResult>('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<string, unknown> = { 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<BrowserCookieSetResult>('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<string, unknown> = { 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<BrowserCookieDeleteResult>('browser.cookie.delete', params)
printResult(result, json, () => `Cookie "${name}" deleted`)
}
}

View File

@ -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<string, CommandHandler> = {
viewport: async ({ flags, client, cwd, json }) => {
const width = getRequiredPositiveNumber(flags, 'width')
const height = getRequiredPositiveNumber(flags, 'height')
const params: Record<string, unknown> = { 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<BrowserViewportResult>('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<string, unknown> = { 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<BrowserGeolocationResult>('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<unknown>('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<unknown>('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<unknown>('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<unknown>('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<unknown>('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<unknown>('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<unknown>('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<unknown>('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<unknown>('browser.dialogDismiss', target)
printResult(result, json, () => 'Dialog dismissed')
}
}

View File

@ -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<BrowserCheckResult>('browser.check', {
element,
checked,
...target
})
printResult(result, json, (v) => (v.checked ? `Checked ${element}` : `Unchecked ${element}`))
}
export const BROWSER_INTERACT_HANDLERS: Record<string, CommandHandler> = {
click: async ({ flags, client, cwd, json }) => {
const element = getRequiredStringFlag(flags, 'element')
const target = await getBrowserCommandTarget(flags, cwd, client)
const result = await client.call<BrowserClickResult>('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<unknown>('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<BrowserFillResult>('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<BrowserTypeResult>('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<BrowserSelectResult>('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<BrowserFocusResult>('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<BrowserClearResult>('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<BrowserSelectAllResult>('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<BrowserKeypressResult>('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<BrowserHoverResult>('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<BrowserDragResult>('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<BrowserUploadResult>('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<unknown>('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<unknown>('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<unknown>('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<unknown>('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<unknown>('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<unknown>('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<unknown>('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<unknown>('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<unknown>('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<unknown>('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<unknown>('browser.highlight', { selector, ...target })
printResult(result, json, () => `Highlighted ${selector}`)
}
}

View File

@ -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<string, CommandHandler> = {
snapshot: async ({ flags, client, cwd, json }) => {
const target = await getBrowserCommandTarget(flags, cwd, client)
const result = await client.call<BrowserSnapshotResult>('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<BrowserScreenshotResult>('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<BrowserGotoResult>(
'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<BrowserBackResult>('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<BrowserReloadResult>('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<unknown>('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<BrowserEvalResult>('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<BrowserScrollResult>('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<BrowserWaitResult>(
'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<BrowserPdfResult>('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<BrowserScreenshotResult>('browser.fullScreenshot', {
format,
...target
})
printResult(result, json, (v) => `Full-page screenshot captured (${v.format})`)
}
}

View File

@ -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<string, CommandHandler> = {
'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<unknown>('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<unknown>('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<unknown>('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<unknown>('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<unknown>('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<unknown>('browser.storage.session.clear', target)
printResult(result, json, () => 'sessionStorage cleared')
}
}

View File

@ -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<string, CommandHandler> = {
'tab list': async ({ flags, client, cwd, json }) => {
const worktree = await getBrowserWorktreeSelector(flags, cwd, client)
const result = await client.call<BrowserTabListResult>('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<BrowserTabSwitchResult>('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<unknown>('browser.exec', { command, ...target })
printResult(result, json, (v) => JSON.stringify(v, null, 2))
}
}

16
src/cli/handlers/core.ts Normal file
View File

@ -0,0 +1,16 @@
import type { CommandHandler } from '../dispatch'
import { formatCliStatus, formatStatus, printResult } from '../format'
export const CORE_HANDLERS: Record<string, CommandHandler> = {
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)
}
}

38
src/cli/handlers/repo.ts Normal file
View File

@ -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<string, CommandHandler> = {
'repo list': async ({ client, json }) => {
const result = await client.call<RuntimeRepoList>('repo.list')
printResult(result, json, formatRepoList)
},
'repo add': async ({ flags, client, json }) => {
const result = await client.call<{ repo: Record<string, unknown> }>('repo.add', {
path: getRequiredStringFlag(flags, 'path')
})
printResult(result, json, formatRepoShow)
},
'repo show': async ({ flags, client, json }) => {
const result = await client.call<{ repo: Record<string, unknown> }>('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<string, unknown> }>('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<RuntimeRepoSearchRefs>('repo.searchRefs', {
repo: getRequiredStringFlag(flags, 'repo'),
query: getRequiredStringFlag(flags, 'query'),
limit: getOptionalPositiveIntegerFlag(flags, 'limit')
})
printResult(result, json, formatRepoRefs)
}
}

View File

@ -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<string, CommandHandler> = {
'terminal list': async ({ flags, client, cwd, json }) => {
const result = await client.call<RuntimeTerminalListResult>('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)
}
}

View File

@ -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<string, CommandHandler> = {
'worktree ps': async ({ flags, client, json }) => {
const result = await client.call<RuntimeWorktreePsResult>('worktree.ps', {
limit: getOptionalPositiveIntegerFlag(flags, 'limit')
})
printResult(result, json, formatWorktreePs)
},
'worktree list': async ({ flags, client, json }) => {
const result = await client.call<RuntimeWorktreeListResult>('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}`)
}
}

296
src/cli/help.ts Normal file
View File

@ -0,0 +1,296 @@
import type { CommandSpec } from './args'
import { findCommandSpec, isCommandGroup, supportsBrowserPageFlag } from './args'
const ROOT_HELP_TEXT = `orca
Usage: orca <command> [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 <v>)
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 <u> --pass <p>)
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 <selector>] [--limit <n>] [--json]
orca worktree create --repo <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--json]
orca worktree show --worktree <selector> [--json]
orca worktree current [--json]
orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--json]
orca worktree rm --worktree <selector> [--force] [--json]
orca worktree ps [--limit <n>] [--json]
orca terminal list [--worktree <selector>] [--limit <n>] [--json]
orca terminal show [--terminal <handle>] [--json]
orca terminal read [--terminal <handle>] [--json]
orca terminal send [--terminal <handle>] [--text <text>] [--enter] [--interrupt] [--json]
orca terminal wait [--terminal <handle>] --for exit|tui-idle [--timeout-ms <ms>] [--json]
orca terminal stop --worktree <selector> [--json]
orca terminal create [--worktree <selector>] [--title <name>] [--command <text>] [--json]
orca terminal split [--terminal <handle>] [--direction horizontal|vertical] [--json]
orca terminal switch [--terminal <handle>] [--json]
orca terminal close [--terminal <handle>] [--json]
orca repo list [--json]
orca repo add --path <path> [--json]
orca repo show --repo <selector> [--json]
orca repo set-base-ref --repo <selector> --ref <ref> [--json]
orca repo search-refs --repo <selector> --query <text> [--limit <n>] [--json]
Selectors:
--repo <selector> Registered repo selector such as id:<id>, name:<name>, or path:<path>
--worktree <selector> Worktree selector such as id:<id>, branch:<branch>, issue:<number>, path:<path>, or active/current
--terminal <handle> Runtime-issued terminal handle returned by \`orca terminal list --json\`
Terminal Send Options:
--text <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 <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 <id> 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 <ref> Element ref from snapshot (e.g. @e3)
--url <url> URL to navigate to
--value <text> Value to fill or select
--input <text> Text to type at current focus (no element needed)
--expression <js> JavaScript expression to evaluate
--key <key> Key to press (Enter, Tab, Escape, Control+a, etc.)
--direction <dir> Scroll direction: up or down
--amount <pixels> Scroll distance in pixels (default: viewport height)
--index <n> Tab index (from \`tab list\`)
--page <id> Stable browser page id (preferred for concurrent workflows)
--format <png|jpeg> Screenshot image format
--from <ref> Drag source element ref
--to <ref> Drag target element ref
--files <path,...> Comma-separated file paths for upload
--timeout <ms> Wait timeout in milliseconds
--worktree <selector> 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} <command> [options]`, '', 'Commands:']
for (const spec of groupSpecs) {
lines.push(` ${spec.path.slice(1).join(' ').padEnd(18)} ${spec.summary}`)
}
lines.push('', `Run \`orca ${group} <command> --help\` for command-specific usage.`)
return lines.join('\n')
}
export function formatFlagHelp(flag: string): string {
const helpByFlag: Record<string, string> = {
'base-branch': '--base-branch <ref> Base branch/ref to create the worktree from',
command: '--command <text> Command to run in the terminal on startup',
comment: '--comment <text> Comment stored in Orca metadata',
cursor: '--cursor <n> Line cursor from a previous read (returns only new output)',
direction: '--direction <dir> Direction: horizontal|vertical (split) or up|down (scroll)',
'display-name': '--display-name <name> Override the Orca display name',
title: '--title <text> 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 <number|null> Linked GitHub issue number',
json: '--json Emit machine-readable JSON',
limit: '--limit <n> Maximum number of rows to return',
name: '--name <name> Name for the new worktree',
path: '--path <path> Filesystem path to the repo',
query: '--query <text> Search text for matching refs',
ref: '--ref <ref> Base ref to persist for the repo',
repo: '--repo <selector> Repo selector such as id:<id>, name:<name>, or path:<path>',
terminal: '--terminal <handle> Runtime-issued terminal handle',
text: '--text <text> Text to send to the terminal',
'timeout-ms': '--timeout-ms <ms> Maximum wait time before timing out',
worktree:
'--worktree <selector> Worktree selector such as id:<id>, branch:<branch>, issue:<number>, path:<path>, or active/current',
// Browser automation flags
element: '--element <ref> Element ref from snapshot (e.g. e3)',
url: '--url <url> URL to navigate to',
value: '--value <text> Value to fill or select',
input: '--input <text> Text to type at current focus',
expression: '--expression <js> JavaScript expression to evaluate',
amount: '--amount <pixels> Scroll distance in pixels',
index: '--index <n> Tab index to switch to',
page: '--page <id> Stable browser page id from `orca tab list --json`',
format: '--format <png|jpeg> Screenshot image format'
}
return helpByFlag[flag] ?? `--${flag}`
}

View File

@ -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
})
})
})

File diff suppressed because it is too large Load Diff

152
src/cli/selectors.ts Normal file
View File

@ -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<string> {
const currentPath = resolvePath(cwd)
const worktrees = await client.call<RuntimeWorktreeListResult>('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<string, string | boolean>,
name: string,
cwd: string,
client: RuntimeClient
): Promise<string | undefined> {
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<string, string | boolean>,
name: string,
cwd: string,
client: RuntimeClient
): Promise<string> {
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<string, string | boolean>,
cwd: string,
client: RuntimeClient
): Promise<string | undefined> {
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<string, string | boolean>,
cwd: string,
client: RuntimeClient
): Promise<string> {
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<string, string | boolean>,
cwd: string,
client: RuntimeClient
): Promise<BrowserCliTarget> {
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)
}
}

View File

@ -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 <url>] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'url', 'worktree']
},
{
path: ['cookie', 'set'],
summary: 'Set a cookie',
usage:
'orca cookie set --name <n> --value <v> [--domain <d>] [--path <p>] [--secure] [--httpOnly] [--sameSite <s>] [--expires <epoch>] [--worktree <selector>] [--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 <n> [--domain <d>] [--url <u>] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'name', 'domain', 'url', 'worktree']
},
// ── Viewport ──
{
path: ['viewport'],
summary: 'Set browser viewport size',
usage:
'orca viewport --width <w> --height <h> [--scale <n>] [--mobile] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'width', 'height', 'scale', 'mobile', 'worktree']
},
// ── Geolocation ──
{
path: ['geolocation'],
summary: 'Override browser geolocation',
usage:
'orca geolocation --latitude <lat> --longitude <lon> [--accuracy <n>] [--worktree <selector>] [--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 <glob,...>] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'patterns', 'worktree']
},
{
path: ['intercept', 'disable'],
summary: 'Disable request interception',
usage: 'orca intercept disable [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree']
},
{
path: ['intercept', 'list'],
summary: 'List paused (intercepted) requests',
usage: 'orca intercept list [--worktree <selector>] [--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 <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree']
},
{
path: ['capture', 'stop'],
summary: 'Stop capturing console and network events',
usage: 'orca capture stop [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree']
},
{
path: ['console'],
summary: 'Show captured console log entries',
usage: 'orca console [--limit <n>] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'limit', 'worktree']
},
{
path: ['network'],
summary: 'Show captured network requests',
usage: 'orca network [--limit <n>] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'limit', 'worktree']
},
// ── Additional core commands ──
{
path: ['dblclick'],
summary: 'Double-click element by ref',
usage: 'orca dblclick --element <ref> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'element', 'worktree']
},
{
path: ['forward'],
summary: 'Navigate forward in browser history',
usage: 'orca forward [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree']
},
{
path: ['scrollintoview'],
summary: 'Scroll element into view',
usage: 'orca scrollintoview --element <ref> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'element', 'worktree']
},
{
path: ['get'],
summary: 'Get element property (text, html, value, url, title, count, box)',
usage: 'orca get --what <property> [--element <ref>] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'what', 'element', 'worktree']
},
{
path: ['is'],
summary: 'Check element state (visible, enabled, checked)',
usage: 'orca is --what <state> --element <ref> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'what', 'element', 'worktree']
},
// ── Keyboard insert text ──
{
path: ['inserttext'],
summary: 'Insert text without key events',
usage: 'orca inserttext --text <text> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'text', 'worktree']
},
// ── Mouse commands ──
{
path: ['mouse', 'move'],
summary: 'Move mouse to x,y coordinates',
usage: 'orca mouse move --x <n> --y <n> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'x', 'y', 'worktree']
},
{
path: ['mouse', 'down'],
summary: 'Press mouse button',
usage: 'orca mouse down [--button <left|right|middle>] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'button', 'worktree']
},
{
path: ['mouse', 'up'],
summary: 'Release mouse button',
usage: 'orca mouse up [--button <left|right|middle>] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'button', 'worktree']
},
{
path: ['mouse', 'wheel'],
summary: 'Scroll wheel',
usage: 'orca mouse wheel --dy <n> [--dx <n>] [--worktree <selector>] [--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 <type> --value <text> --action <action> [--text <text>] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'locator', 'value', 'action', 'text', 'worktree']
},
// ── Set commands ──
{
path: ['set', 'device'],
summary: 'Emulate a device',
usage: 'orca set device --name <device> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'name', 'worktree']
},
{
path: ['set', 'offline'],
summary: 'Toggle offline mode',
usage: 'orca set offline [--state <on|off>] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'state', 'worktree']
},
{
path: ['set', 'headers'],
summary: 'Set extra HTTP headers',
usage: 'orca set headers --headers <json> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'headers', 'worktree']
},
{
path: ['set', 'credentials'],
summary: 'Set HTTP auth credentials',
usage: 'orca set credentials --user <user> --pass <pass> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'user', 'pass', 'worktree']
},
{
path: ['set', 'media'],
summary: 'Set color scheme and reduced motion preferences',
usage:
'orca set media [--color-scheme <dark|light>] [--reduced-motion <reduce|no-preference>] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'color-scheme', 'reduced-motion', 'worktree']
},
// ── Clipboard commands ──
{
path: ['clipboard', 'read'],
summary: 'Read clipboard contents',
usage: 'orca clipboard read [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree']
},
{
path: ['clipboard', 'write'],
summary: 'Write text to clipboard',
usage: 'orca clipboard write --text <text> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'text', 'worktree']
},
// ── Dialog commands ──
{
path: ['dialog', 'accept'],
summary: 'Accept a browser dialog',
usage: 'orca dialog accept [--text <text>] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'text', 'worktree']
},
{
path: ['dialog', 'dismiss'],
summary: 'Dismiss a browser dialog',
usage: 'orca dialog dismiss [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree']
},
// ── Storage commands ──
{
path: ['storage', 'local', 'get'],
summary: 'Get a localStorage value by key',
usage: 'orca storage local get --key <key> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'key', 'worktree']
},
{
path: ['storage', 'local', 'set'],
summary: 'Set a localStorage value',
usage: 'orca storage local set --key <key> --value <value> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'key', 'value', 'worktree']
},
{
path: ['storage', 'local', 'clear'],
summary: 'Clear all localStorage',
usage: 'orca storage local clear [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree']
},
{
path: ['storage', 'session', 'get'],
summary: 'Get a sessionStorage value by key',
usage: 'orca storage session get --key <key> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'key', 'worktree']
},
{
path: ['storage', 'session', 'set'],
summary: 'Set a sessionStorage value',
usage: 'orca storage session set --key <key> --value <value> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'key', 'value', 'worktree']
},
{
path: ['storage', 'session', 'clear'],
summary: 'Clear all sessionStorage',
usage: 'orca storage session clear [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree']
},
// ── Download command ──
{
path: ['download'],
summary: 'Download a file by clicking a selector',
usage: 'orca download --selector <ref> --path <path> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'selector', 'path', 'worktree']
},
// ── Highlight command ──
{
path: ['highlight'],
summary: 'Highlight an element by selector',
usage: 'orca highlight --selector <ref> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'selector', 'worktree']
}
]

View File

@ -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 <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree']
},
{
path: ['screenshot'],
summary: 'Capture a viewport screenshot of the active browser tab',
usage: 'orca screenshot [--format <png|jpeg>] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'format', 'worktree']
},
{
path: ['click'],
summary: 'Click a browser element by ref',
usage: 'orca click --element <ref> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'element', 'worktree']
},
{
path: ['fill'],
summary: 'Clear and fill a browser input by ref',
usage: 'orca fill --element <ref> --value <text> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'element', 'value', 'worktree']
},
{
path: ['type'],
summary: 'Type text at the current browser focus',
usage: 'orca type --input <text> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'input', 'worktree']
},
{
path: ['select'],
summary: 'Select a dropdown option by ref',
usage: 'orca select --element <ref> --value <value> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'element', 'value', 'worktree']
},
{
path: ['scroll'],
summary: 'Scroll the browser viewport',
usage: 'orca scroll --direction <up|down> [--amount <pixels>] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'direction', 'amount', 'worktree']
},
{
path: ['goto'],
summary: 'Navigate the active browser tab to a URL',
usage: 'orca goto --url <url> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'url', 'worktree']
},
{
path: ['back'],
summary: 'Navigate back in browser history',
usage: 'orca back [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree']
},
{
path: ['reload'],
summary: 'Reload the active browser tab',
usage: 'orca reload [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree']
},
{
path: ['eval'],
summary: 'Evaluate JavaScript in the browser page context',
usage: 'orca eval --expression <js> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'expression', 'worktree']
},
{
path: ['wait'],
summary: 'Wait for element, text, URL, load state, JS condition, or timeout',
usage:
'orca wait [--selector <sel>] [--timeout <ms>] [--text <text>] [--url <pattern>] [--load <state>] [--fn <js>] [--state <hidden|visible>] [--worktree <selector>] [--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 <ref> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'element', 'worktree']
},
{
path: ['uncheck'],
summary: 'Uncheck a checkbox/radio by ref',
usage: 'orca uncheck --element <ref> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'element', 'worktree']
},
{
path: ['focus'],
summary: 'Focus a browser element by ref',
usage: 'orca focus --element <ref> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'element', 'worktree']
},
{
path: ['clear'],
summary: 'Clear an input element by ref',
usage: 'orca clear --element <ref> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'element', 'worktree']
},
{
path: ['select-all'],
summary: 'Select all text in an input by ref',
usage: 'orca select-all --element <ref> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'element', 'worktree']
},
{
path: ['keypress'],
summary: 'Press a key (Enter, Tab, Escape, ArrowDown, etc.)',
usage: 'orca keypress --key <name> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'key', 'worktree']
},
{
path: ['pdf'],
summary: 'Export the active browser tab as PDF',
usage: 'orca pdf [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree']
},
{
path: ['full-screenshot'],
summary: 'Capture a full-page screenshot (beyond viewport)',
usage: 'orca full-screenshot [--format <png|jpeg>] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'format', 'worktree']
},
{
path: ['hover'],
summary: 'Hover over a browser element by ref',
usage: 'orca hover --element <ref> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'element', 'worktree']
},
{
path: ['drag'],
summary: 'Drag from one element to another',
usage: 'orca drag --from <ref> --to <ref> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'from', 'to', 'worktree']
},
{
path: ['upload'],
summary: 'Upload files to a file input element',
usage: 'orca upload --element <ref> --files <path,...> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'element', 'files', 'worktree']
},
{
path: ['tab', 'list'],
summary: 'List open browser tabs',
usage: 'orca tab list [--worktree <selector|all>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree']
},
{
path: ['tab', 'switch'],
summary: 'Switch the active browser tab',
usage: 'orca tab switch (--index <n> | --page <id>) [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'index', 'worktree']
},
{
path: ['tab', 'create'],
summary: 'Create a new browser tab in the current worktree',
usage: 'orca tab create [--url <url>] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'url', 'worktree']
},
{
path: ['tab', 'close'],
summary: 'Close a browser tab',
usage: 'orca tab close [--index <n>] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'index', 'worktree']
},
{
path: ['exec'],
summary: 'Run any agent-browser command against the active browser tab',
usage: 'orca exec --command "<agent-browser command>" [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'command', 'worktree']
}
]

199
src/cli/specs/core.ts Normal file
View File

@ -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 <path> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'path']
},
{
path: ['repo', 'show'],
summary: 'Show one registered repo',
usage: 'orca repo show --repo <selector> [--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 <selector> --ref <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 <selector> --query <text> [--limit <n>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'repo', 'query', 'limit']
},
{
path: ['worktree', 'list'],
summary: 'List Orca-managed worktrees',
usage: 'orca worktree list [--repo <selector>] [--limit <n>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'repo', 'limit']
},
{
path: ['worktree', 'show'],
summary: 'Show one worktree',
usage: 'orca worktree show --worktree <selector> [--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 <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--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 <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--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 <selector> [--force] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'force']
},
{
path: ['worktree', 'ps'],
summary: 'Show a compact orchestration summary across worktrees',
usage: 'orca worktree ps [--limit <n>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'limit']
},
{
path: ['terminal', 'list'],
summary: 'List live Orca-managed terminals',
usage: 'orca terminal list [--worktree <selector>] [--limit <n>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'limit']
},
{
path: ['terminal', 'show'],
summary: 'Show terminal metadata and preview',
usage: 'orca terminal show [--terminal <handle>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'terminal']
},
{
path: ['terminal', 'read'],
summary: 'Read bounded terminal output',
usage: 'orca terminal read [--terminal <handle>] [--cursor <n>] [--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 <prev> 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 <handle>] [--text <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 <handle>] --for exit|tui-idle [--timeout-ms <ms>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'for', 'timeout-ms']
},
{
path: ['terminal', 'stop'],
summary: 'Stop terminals for a worktree',
usage: 'orca terminal stop --worktree <selector> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree']
},
{
path: ['terminal', 'create'],
summary: 'Create a new terminal tab in the current worktree',
usage:
'orca terminal create [--worktree <selector>] [--title <name>] [--command <text>] [--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 <handle>] [--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 <handle>] [--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 <handle>] [--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 <handle>] [--title <text>] [--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 <handle>] [--direction horizontal|vertical] [--command <text>] [--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"'
]
}
]

10
src/cli/specs/index.ts Normal file
View File

@ -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
]

62
src/cli/test-fixtures.ts Normal file
View File

@ -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)
}
}