refactor(cli): split index.ts into per-verb handler modules (#1089)
This commit is contained in:
parent
5f1161b1ab
commit
36c6a9241f
|
|
@ -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(' ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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`)
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
|
|
@ -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})`)
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
2584
src/cli/index.ts
2584
src/cli/index.ts
File diff suppressed because it is too large
Load Diff
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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']
|
||||
}
|
||||
]
|
||||
|
|
@ -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']
|
||||
}
|
||||
]
|
||||
|
|
@ -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"'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
||||
]
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue