orca/src/cli/format.ts

605 lines
22 KiB
TypeScript

/* eslint-disable max-lines -- Why: CLI result formatters are centralized so handlers can stay thin RPC glue. */
import { chmodSync, lstatSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import type {
BrowserProfileListResult,
BrowserTabCurrentResult,
BrowserScreenshotResult,
BrowserSnapshotResult,
BrowserTabListResult,
BrowserTabProfileCloneResult,
BrowserTabProfileShowResult,
BrowserTabShowResult,
ComputerActionResult,
ComputerActionVerification,
ComputerListAppsResult,
ComputerListWindowsResult,
ComputerSnapshotResult,
CliStatusResult,
RuntimeRepoList,
RuntimeRepoSearchRefs,
RuntimeTerminalClose,
RuntimeTerminalCreate,
RuntimeTerminalFocus,
RuntimeTerminalListResult,
RuntimeTerminalRead,
RuntimeTerminalRename,
RuntimeTerminalSend,
RuntimeTerminalShow,
RuntimeTerminalSplit,
RuntimeTerminalWait,
RuntimeWorktreeListResult,
RuntimeWorktreePsResult,
RuntimeWorktreeRecord
} from '../shared/runtime-types'
import type { Automation, AutomationRun } from '../shared/automations-types'
import { formatAutomationSchedule } from '../shared/automation-schedules'
import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments'
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(prepareCliJsonResult(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.`
}
if (error instanceof RuntimeRpcFailureError) {
const data = error.response.error.data
const nextSteps =
data && typeof data === 'object' && Array.isArray((data as { nextSteps?: unknown }).nextSteps)
? (data as { nextSteps: unknown[] }).nextSteps.filter(
(step): step is string => typeof step === 'string'
)
: []
if (nextSteps.length > 0) {
return `${message}\n${nextSteps.map((step) => `Next step: ${step}`).join('\n')}`
}
}
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 formatEnvironmentList(result: {
environments: PublicKnownRuntimeEnvironment[]
}): string {
if (result.environments.length === 0) {
return 'No saved environments.'
}
return result.environments
.map(
(environment) =>
`${environment.id} ${environment.name} ${environment.endpoints[0]?.endpoint ?? 'no-endpoint'}`
)
.join('\n')
}
export function formatEnvironment(environment: PublicKnownRuntimeEnvironment): string {
return [
`id: ${environment.id}`,
`name: ${environment.name}`,
`runtimeId: ${environment.runtimeId ?? 'unknown'}`,
`lastUsedAt: ${environment.lastUsedAt ?? 'never'}`,
`preferredEndpointId: ${environment.preferredEndpointId}`,
...environment.endpoints.map(
(endpoint) => `endpoint: ${endpoint.id} ${endpoint.kind} ${endpoint.endpoint}`
)
].join('\n')
}
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}")` : ''
const surfaceNote = result.terminal.surface ? ` [${result.terminal.surface}]` : ''
return `Created terminal ${result.terminal.handle}${titleNote}${surfaceNote}`
}
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) => {
const childCount = worktree.childWorktreeIds?.length ?? 0
return `${String(worktree.id)} ${String(worktree.branch)} ${String(worktree.path)}\ndisplayName: ${String(worktree.displayName ?? '')}\nparentWorktreeId: ${String(worktree.parentWorktreeId ?? 'null')}\nchildWorktreeIds: ${childCount > 0 ? worktree.childWorktreeIds.join(',') : '[]'}\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 formatAutomationList(result: { automations: Automation[] }): string {
if (result.automations.length === 0) {
return 'No automations found.'
}
return result.automations
.map((automation) => {
const status = automation.enabled ? 'enabled' : 'disabled'
return `${automation.id} ${automation.name} ${automation.agentId} ${status}\n${formatAutomationSchedule(automation.rrule)} next: ${new Date(automation.nextRunAt).toISOString()}`
})
.join('\n\n')
}
export function formatAutomationShow(result: { automation: Automation }): string {
const automation = result.automation
return [
`id: ${automation.id}`,
`name: ${automation.name}`,
`provider: ${automation.agentId}`,
`enabled: ${automation.enabled}`,
`schedule: ${formatAutomationSchedule(automation.rrule)}`,
`rrule: ${automation.rrule}`,
`nextRunAt: ${new Date(automation.nextRunAt).toISOString()}`,
`projectId: ${automation.projectId}`,
`workspaceMode: ${automation.workspaceMode}`,
`workspaceId: ${automation.workspaceId ?? 'null'}`,
`baseBranch: ${automation.baseBranch ?? 'null'}`,
`target: ${automation.executionTargetType}:${automation.executionTargetId}`,
`prompt: ${automation.prompt}`
].join('\n')
}
export function formatAutomationRemoved(result: { removed: boolean; id: string }): string {
return result.removed
? `Removed automation ${result.id}.`
: `Automation ${result.id} not removed.`
}
export function formatAutomationRun(result: { run: AutomationRun }): string {
return [
`id: ${result.run.id}`,
`automationId: ${result.run.automationId}`,
`title: ${result.run.title}`,
`status: ${result.run.status}`,
`trigger: ${result.run.trigger}`,
`scheduledFor: ${new Date(result.run.scheduledFor).toISOString()}`,
`workspaceId: ${result.run.workspaceId ?? 'null'}`,
`error: ${result.run.error ?? 'null'}`
].join('\n')
}
export function formatAutomationRuns(result: { runs: AutomationRun[] }): string {
if (result.runs.length === 0) {
return 'No automation runs found.'
}
return result.runs
.map(
(run) =>
`${run.id} ${run.automationId} ${run.status} ${run.trigger} ${new Date(run.scheduledFor).toISOString()}\n${run.title}${run.error ? `\nerror: ${run.error}` : ''}`
)
.join('\n\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 {
return formatTabListWithProfiles(result, false)
}
export function formatTabListWithProfiles(
result: BrowserTabListResult,
showProfile: boolean
): string {
if (result.tabs.length === 0) {
return 'No browser tabs open.'
}
return result.tabs
.map((t) => {
const marker = t.active ? '* ' : ' '
const profile = showProfile ? ` [${t.profileLabel ?? t.profileId ?? 'Unknown'}]` : ''
return `${marker}[${t.index}] ${t.browserPageId} ${t.title}${t.url}${profile}`
})
.join('\n')
}
export function formatBrowserProfileList(result: BrowserProfileListResult): string {
if (result.profiles.length === 0) {
return 'No browser profiles found.'
}
return result.profiles
.map((profile) => {
const marker = profile.scope === 'default' ? '* ' : ' '
const source = profile.source?.browserFamily ?? 'none'
return `${marker}${profile.id} ${profile.label} ${profile.scope} source:${source}`
})
.join('\n')
}
export function formatTabShow(result: BrowserTabShowResult | BrowserTabCurrentResult): string {
const tab = result.tab
return [
`page: ${tab.browserPageId}`,
`title: ${tab.title}`,
`url: ${tab.url}`,
`active: ${tab.active}`,
`worktree: ${tab.worktreeId ?? 'unknown'}`,
`profile: ${tab.profileLabel ?? tab.profileId ?? 'unknown'}`
].join('\n')
}
export function formatTabProfileShow(result: BrowserTabProfileShowResult): string {
return [
`page: ${result.browserPageId}`,
`worktree: ${result.worktreeId ?? 'unknown'}`,
`profileId: ${result.profileId ?? 'default'}`,
`profile: ${result.profileLabel ?? result.profileId ?? 'default'}`
].join('\n')
}
export function formatTabProfileClone(result: BrowserTabProfileCloneResult): string {
return `Cloned ${result.sourceBrowserPageId} to ${result.browserPageId} (${result.profileLabel ?? result.profileId ?? 'default'})`
}
export function formatGetAppState(result: ComputerSnapshotResult): string {
const app = result.snapshot.app
const bundle = app.bundleId ? `, ${app.bundleId}` : ''
const focused =
result.snapshot.focusedElementId === null ? 'none' : `#${result.snapshot.focusedElementId}`
const windowId =
result.snapshot.window.id === null || result.snapshot.window.id === undefined
? ''
: ` id:${result.snapshot.window.id}`
const origin =
result.snapshot.window.x === null ||
result.snapshot.window.x === undefined ||
result.snapshot.window.y === null ||
result.snapshot.window.y === undefined
? ''
: ` @ ${result.snapshot.window.x},${result.snapshot.window.y}`
const truncation = result.snapshot.truncation?.truncated
? ` Truncated: yes (max nodes ${result.snapshot.truncation.maxNodes ?? 'unknown'}, max depth ${result.snapshot.truncation.maxDepth ?? 'unknown'})`
: ' Truncated: no'
return [
`${app.name} (pid ${app.pid}${bundle})`,
` Window:${windowId} "${result.snapshot.window.title}" (${result.snapshot.window.width}x${result.snapshot.window.height}${origin})`,
` Elements: ${result.snapshot.elementCount} Focused: ${focused} Coordinates: ${result.snapshot.coordinateSpace}`,
truncation,
` ${formatComputerScreenshotStatus(result)}`,
'',
result.snapshot.treeText
].join('\n')
}
function prepareCliJsonResult<TResult>(
response: RuntimeRpcSuccess<TResult>
): RuntimeRpcSuccess<TResult> {
const record = response as RuntimeRpcSuccess<TResult> & {
result?: { screenshot?: { data?: unknown; format?: unknown; path?: unknown } | null }
}
const screenshot = record.result?.screenshot
if (!screenshot || typeof screenshot.data !== 'string' || screenshot.data.length === 0) {
return response
}
const extension = screenshot.format === 'png' ? 'png' : 'img'
const outputDir = computerScreenshotTempDir()
cleanupComputerScreenshots(outputDir)
const outputPath = join(outputDir, `${safeCliFileStem(response.id)}-screenshot.${extension}`)
writeFileSync(outputPath, Buffer.from(screenshot.data, 'base64'), { mode: 0o600 })
const expiresAt = new Date(Date.now() + COMPUTER_SCREENSHOT_TTL_MS).toISOString()
return {
...response,
result: {
...record.result,
screenshot: {
...screenshot,
data: undefined,
path: outputPath,
dataOmitted: true,
expiresAt
}
}
} as RuntimeRpcSuccess<TResult>
}
const COMPUTER_SCREENSHOT_TTL_MS = 24 * 60 * 60 * 1000
function computerScreenshotTempDir(): string {
const outputDir = join(tmpdir(), 'orca-computer-use')
mkdirSync(outputDir, { recursive: true, mode: 0o700 })
const stat = lstatSync(outputDir)
if (!stat.isDirectory() || stat.isSymbolicLink()) {
throw new Error(`Unsafe computer screenshot temp path: ${outputDir}`)
}
if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
throw new Error(`Computer screenshot temp path is not owned by the current user: ${outputDir}`)
}
chmodSync(outputDir, 0o700)
return outputDir
}
function cleanupComputerScreenshots(outputDir: string): void {
const cutoff = Date.now() - COMPUTER_SCREENSHOT_TTL_MS
for (const entry of readdirSync(outputDir)) {
if (!entry.endsWith('-screenshot.png') && !entry.endsWith('-screenshot.img')) {
continue
}
const path = join(outputDir, entry)
try {
if (statSync(path).mtimeMs < cutoff) {
rmSync(path, { force: true })
}
} catch {
// Best-effort cleanup only; formatting should not fail because a temp file raced.
}
}
}
function safeCliFileStem(value: string): string {
return value.replaceAll(/[^a-zA-Z0-9._-]/g, '_')
}
export function formatListApps(result: ComputerListAppsResult): string {
if (result.apps.length === 0) {
return 'No apps found.'
}
return result.apps
.map((app) => {
const bundle = app.bundleId ? ` ${app.bundleId}` : ''
return `${app.name} pid:${app.pid}${bundle}`
})
.join('\n')
}
export function formatListWindows(result: ComputerListWindowsResult): string {
if (result.windows.length === 0) {
return `No windows found for ${result.app.name}.`
}
return result.windows
.map((window) => {
const id = window.id === null || window.id === undefined ? 'none' : String(window.id)
const origin =
window.x === null || window.x === undefined || window.y === null || window.y === undefined
? ''
: ` @ ${window.x},${window.y}`
const screen =
window.screenIndex === null || window.screenIndex === undefined
? ''
: ` screen:${window.screenIndex}`
const state = [
window.isMinimized ? 'minimized' : null,
window.isOffscreen ? 'offscreen' : null
].filter(Boolean)
const stateText = state.length > 0 ? ` ${state.join(',')}` : ''
return `[${window.index}] id:${id} "${window.title}" (${window.width}x${window.height}${origin})${screen}${stateText}`
})
.join('\n')
}
export function formatComputerAction(verb: string, result: ComputerActionResult): string {
const path = result.action?.path ? ` via ${result.action.path}` : ''
const verification = formatActionVerification(result.action?.verification)
const app = shellQuote(result.snapshot.app.bundleId ?? result.snapshot.app.name)
const windowId =
result.snapshot.window.id === null || result.snapshot.window.id === undefined
? ''
: ` --window-id ${result.snapshot.window.id}`
return `${formatActionVerb(verb)} completed${path}${verification}; ${result.snapshot.elementCount} elements in current window. Use \`orca computer get-app-state --app ${app}${windowId}\` to inspect.`
}
function formatActionVerification(verification: ComputerActionVerification | undefined): string {
if (!verification) {
return ''
}
if (verification.state === 'verified') {
return `, verified ${verification.property}`
}
return `, unverified (${verification.reason.replaceAll('_', ' ')})`
}
function formatComputerScreenshotStatus(result: ComputerSnapshotResult): string {
if (result.screenshotStatus.state === 'captured' && result.screenshot) {
const bytes = result.screenshot.data
? `${Math.round(result.screenshot.data.length * 0.75)} bytes`
: `saved to ${result.screenshot.path ?? 'temporary file'}`
const engine = result.screenshotStatus.metadata?.engine
const detail = engine
? `${result.screenshot.format}, ${bytes}, ${engine}`
: `${result.screenshot.format}, ${bytes}`
return `Screenshot captured (${detail})`
}
if (result.screenshotStatus.state === 'skipped') {
return 'Screenshot skipped (--no-screenshot)'
}
if (result.screenshotStatus.state === 'failed') {
return `Screenshot failed (${result.screenshotStatus.code}): ${result.screenshotStatus.message}`
}
return 'Screenshot was not captured'
}
function shellQuote(value: string): string {
if (/^[a-zA-Z0-9._:/@-]+$/.test(value)) {
return value
}
return `'${value.replaceAll("'", "'\\''")}'`
}
function formatActionVerb(verb: string): string {
return verb
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}