914 lines
32 KiB
JavaScript
914 lines
32 KiB
JavaScript
#!/usr/bin/env node
|
|
/* eslint-disable max-lines -- Why: the public CLI entrypoint keeps command dispatch in one place so the bundled shell command and development fallback stay behaviorally identical. */
|
|
|
|
import { isAbsolute, relative, resolve as resolvePath } from 'path'
|
|
import type {
|
|
CliStatusResult,
|
|
RuntimeRepoList,
|
|
RuntimeRepoSearchRefs,
|
|
RuntimeWorktreeRecord,
|
|
RuntimeWorktreePsResult,
|
|
RuntimeWorktreeListResult,
|
|
RuntimeTerminalRead,
|
|
RuntimeTerminalListResult,
|
|
RuntimeTerminalShow,
|
|
RuntimeTerminalSend,
|
|
RuntimeTerminalWait
|
|
} from '../shared/runtime-types'
|
|
import {
|
|
RuntimeClient,
|
|
RuntimeClientError,
|
|
RuntimeRpcFailureError,
|
|
type RuntimeRpcSuccess
|
|
} from './runtime-client'
|
|
import type { RuntimeRpcFailure } from './runtime-client'
|
|
|
|
type ParsedArgs = {
|
|
commandPath: string[]
|
|
flags: Map<string, string | boolean>
|
|
}
|
|
|
|
type CommandSpec = {
|
|
path: string[]
|
|
summary: string
|
|
usage: string
|
|
allowedFlags: string[]
|
|
examples?: string[]
|
|
notes?: string[]
|
|
}
|
|
|
|
const DEFAULT_TERMINAL_WAIT_RPC_TIMEOUT_MS = 5 * 60 * 1000
|
|
const GLOBAL_FLAGS = ['help', 'json']
|
|
const COMMAND_SPECS: CommandSpec[] = [
|
|
{
|
|
path: ['open'],
|
|
summary: 'Launch Orca and wait for the runtime to be reachable',
|
|
usage: 'orca open [--json]',
|
|
allowedFlags: [...GLOBAL_FLAGS],
|
|
examples: ['orca open', 'orca open --json']
|
|
},
|
|
{
|
|
path: ['status'],
|
|
summary: 'Show app/runtime/graph readiness',
|
|
usage: 'orca status [--json]',
|
|
allowedFlags: [...GLOBAL_FLAGS],
|
|
examples: ['orca status', 'orca status --json']
|
|
},
|
|
{
|
|
path: ['repo', 'list'],
|
|
summary: 'List repos registered in Orca',
|
|
usage: 'orca repo list [--json]',
|
|
allowedFlags: [...GLOBAL_FLAGS]
|
|
},
|
|
{
|
|
path: ['repo', 'add'],
|
|
summary: 'Add a repo 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> [--json]',
|
|
allowedFlags: [...GLOBAL_FLAGS, 'terminal']
|
|
},
|
|
{
|
|
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 [--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']
|
|
}
|
|
]
|
|
|
|
export async function main(argv = process.argv.slice(2), cwd = process.cwd()): Promise<void> {
|
|
const parsed = parseArgs(argv)
|
|
const helpPath = resolveHelpPath(parsed)
|
|
if (helpPath !== null) {
|
|
printHelp(helpPath)
|
|
if (helpPath.length > 0 && !findCommandSpec(helpPath) && !isCommandGroup(helpPath)) {
|
|
process.exitCode = 1
|
|
}
|
|
return
|
|
}
|
|
if (parsed.commandPath.length === 0) {
|
|
printHelp([])
|
|
return
|
|
}
|
|
const json = parsed.flags.has('json')
|
|
|
|
try {
|
|
// Why: CLI syntax and flag errors should be reported before any runtime
|
|
// lookup so users do not get misleading "Orca is not running" failures for
|
|
// simple command typos or unsupported flags.
|
|
validateCommandAndFlags(parsed)
|
|
|
|
const client = new RuntimeClient()
|
|
const { commandPath } = parsed
|
|
|
|
if (matches(commandPath, ['open'])) {
|
|
const result = await client.openOrca()
|
|
return printResult(result, json, formatCliStatus)
|
|
}
|
|
|
|
if (matches(commandPath, ['status'])) {
|
|
const result = await client.getCliStatus()
|
|
if (!json && !result.result.runtime.reachable) {
|
|
process.exitCode = 1
|
|
}
|
|
return printResult(result, json, formatStatus)
|
|
}
|
|
|
|
if (matches(commandPath, ['repo', 'list'])) {
|
|
const result = await client.call<RuntimeRepoList>('repo.list')
|
|
return printResult(result, json, formatRepoList)
|
|
}
|
|
|
|
if (matches(commandPath, ['repo', 'add'])) {
|
|
const result = await client.call<{ repo: Record<string, unknown> }>('repo.add', {
|
|
path: getRequiredStringFlag(parsed.flags, 'path')
|
|
})
|
|
return printResult(result, json, formatRepoShow)
|
|
}
|
|
|
|
if (matches(commandPath, ['repo', 'show'])) {
|
|
const result = await client.call<{ repo: Record<string, unknown> }>('repo.show', {
|
|
repo: getRequiredStringFlag(parsed.flags, 'repo')
|
|
})
|
|
return printResult(result, json, formatRepoShow)
|
|
}
|
|
|
|
if (matches(commandPath, ['repo', 'set-base-ref'])) {
|
|
const result = await client.call<{ repo: Record<string, unknown> }>('repo.setBaseRef', {
|
|
repo: getRequiredStringFlag(parsed.flags, 'repo'),
|
|
ref: getRequiredStringFlag(parsed.flags, 'ref')
|
|
})
|
|
return printResult(result, json, formatRepoShow)
|
|
}
|
|
|
|
if (matches(commandPath, ['repo', 'search-refs'])) {
|
|
const result = await client.call<RuntimeRepoSearchRefs>('repo.searchRefs', {
|
|
repo: getRequiredStringFlag(parsed.flags, 'repo'),
|
|
query: getRequiredStringFlag(parsed.flags, 'query'),
|
|
limit: getOptionalPositiveIntegerFlag(parsed.flags, 'limit')
|
|
})
|
|
return printResult(result, json, formatRepoRefs)
|
|
}
|
|
|
|
if (matches(commandPath, ['terminal', 'list'])) {
|
|
const result = await client.call<RuntimeTerminalListResult>('terminal.list', {
|
|
worktree: await getOptionalWorktreeSelector(parsed.flags, 'worktree', cwd, client),
|
|
limit: getOptionalPositiveIntegerFlag(parsed.flags, 'limit')
|
|
})
|
|
return printResult(result, json, formatTerminalList)
|
|
}
|
|
|
|
if (matches(commandPath, ['terminal', 'show'])) {
|
|
const result = await client.call<{ terminal: RuntimeTerminalShow }>('terminal.show', {
|
|
terminal: getRequiredStringFlag(parsed.flags, 'terminal')
|
|
})
|
|
return printResult(result, json, formatTerminalShow)
|
|
}
|
|
|
|
if (matches(commandPath, ['terminal', 'read'])) {
|
|
const result = await client.call<{ terminal: RuntimeTerminalRead }>('terminal.read', {
|
|
terminal: getRequiredStringFlag(parsed.flags, 'terminal')
|
|
})
|
|
return printResult(result, json, formatTerminalRead)
|
|
}
|
|
|
|
if (matches(commandPath, ['terminal', 'send'])) {
|
|
const result = await client.call<{ send: RuntimeTerminalSend }>('terminal.send', {
|
|
terminal: getRequiredStringFlag(parsed.flags, 'terminal'),
|
|
text: getOptionalStringFlag(parsed.flags, 'text'),
|
|
enter: parsed.flags.get('enter') === true,
|
|
interrupt: parsed.flags.get('interrupt') === true
|
|
})
|
|
return printResult(result, json, formatTerminalSend)
|
|
}
|
|
|
|
if (matches(commandPath, ['terminal', 'wait'])) {
|
|
const timeoutMs = getOptionalPositiveIntegerFlag(parsed.flags, 'timeout-ms')
|
|
const result = await client.call<{ wait: RuntimeTerminalWait }>(
|
|
'terminal.wait',
|
|
{
|
|
terminal: getRequiredStringFlag(parsed.flags, 'terminal'),
|
|
for: getRequiredStringFlag(parsed.flags, 'for'),
|
|
timeoutMs
|
|
},
|
|
{
|
|
// Why: terminal wait legitimately needs to outlive the CLI's default
|
|
// RPC timeout. Even without an explicit server timeout, the client must
|
|
// allow long waits instead of failing at the generic 15s transport cap.
|
|
timeoutMs: timeoutMs ? timeoutMs + 5000 : DEFAULT_TERMINAL_WAIT_RPC_TIMEOUT_MS
|
|
}
|
|
)
|
|
return printResult(result, json, formatTerminalWait)
|
|
}
|
|
|
|
if (matches(commandPath, ['terminal', 'stop'])) {
|
|
const result = await client.call<{ stopped: number }>('terminal.stop', {
|
|
worktree: await getRequiredWorktreeSelector(parsed.flags, 'worktree', cwd, client)
|
|
})
|
|
return printResult(result, json, (value) => `Stopped ${value.stopped} terminals.`)
|
|
}
|
|
|
|
if (matches(commandPath, ['worktree', 'ps'])) {
|
|
const result = await client.call<RuntimeWorktreePsResult>('worktree.ps', {
|
|
limit: getOptionalPositiveIntegerFlag(parsed.flags, 'limit')
|
|
})
|
|
return printResult(result, json, formatWorktreePs)
|
|
}
|
|
|
|
if (matches(commandPath, ['worktree', 'list'])) {
|
|
const result = await client.call<RuntimeWorktreeListResult>('worktree.list', {
|
|
repo: getOptionalStringFlag(parsed.flags, 'repo'),
|
|
limit: getOptionalPositiveIntegerFlag(parsed.flags, 'limit')
|
|
})
|
|
return printResult(result, json, formatWorktreeList)
|
|
}
|
|
|
|
if (matches(commandPath, ['worktree', 'show'])) {
|
|
const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', {
|
|
worktree: await getRequiredWorktreeSelector(parsed.flags, 'worktree', cwd, client)
|
|
})
|
|
return printResult(result, json, formatWorktreeShow)
|
|
}
|
|
|
|
if (matches(commandPath, ['worktree', 'current'])) {
|
|
const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', {
|
|
worktree: await resolveCurrentWorktreeSelector(cwd, client)
|
|
})
|
|
return printResult(result, json, formatWorktreeShow)
|
|
}
|
|
|
|
if (matches(commandPath, ['worktree', 'create'])) {
|
|
const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.create', {
|
|
repo: getRequiredStringFlag(parsed.flags, 'repo'),
|
|
name: getRequiredStringFlag(parsed.flags, 'name'),
|
|
baseBranch: getOptionalStringFlag(parsed.flags, 'base-branch'),
|
|
linkedIssue: getOptionalNumberFlag(parsed.flags, 'issue'),
|
|
comment: getOptionalStringFlag(parsed.flags, 'comment')
|
|
})
|
|
return printResult(result, json, formatWorktreeShow)
|
|
}
|
|
|
|
if (matches(commandPath, ['worktree', 'set'])) {
|
|
const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.set', {
|
|
worktree: await getRequiredWorktreeSelector(parsed.flags, 'worktree', cwd, client),
|
|
displayName: getOptionalStringFlag(parsed.flags, 'display-name'),
|
|
linkedIssue: getOptionalNullableNumberFlag(parsed.flags, 'issue'),
|
|
comment: getOptionalStringFlag(parsed.flags, 'comment')
|
|
})
|
|
return printResult(result, json, formatWorktreeShow)
|
|
}
|
|
|
|
if (matches(commandPath, ['worktree', 'rm'])) {
|
|
const result = await client.call<{ removed: boolean }>('worktree.rm', {
|
|
worktree: await getRequiredWorktreeSelector(parsed.flags, 'worktree', cwd, client),
|
|
force: parsed.flags.get('force') === true
|
|
})
|
|
return printResult(result, json, (value) => `removed: ${value.removed}`)
|
|
}
|
|
|
|
throw new RuntimeClientError('invalid_argument', `Unknown command: ${commandPath.join(' ')}`)
|
|
} catch (error) {
|
|
if (json) {
|
|
if (error instanceof RuntimeRpcFailureError) {
|
|
console.log(JSON.stringify(error.response, null, 2))
|
|
} else {
|
|
const response: RuntimeRpcFailure = {
|
|
id: 'local',
|
|
ok: false,
|
|
error: {
|
|
code: error instanceof RuntimeClientError ? error.code : 'runtime_error',
|
|
message: formatCliError(error)
|
|
},
|
|
_meta: {
|
|
runtimeId: null
|
|
}
|
|
}
|
|
console.log(JSON.stringify(response, null, 2))
|
|
}
|
|
} else {
|
|
console.error(formatCliError(error))
|
|
}
|
|
process.exitCode = 1
|
|
}
|
|
}
|
|
|
|
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 validateCommandAndFlags(parsed: ParsedArgs): void {
|
|
const spec = findCommandSpec(parsed.commandPath)
|
|
if (!spec) {
|
|
throw new RuntimeClientError(
|
|
'invalid_argument',
|
|
`Unknown command: ${parsed.commandPath.join(' ')}`
|
|
)
|
|
}
|
|
|
|
for (const flag of parsed.flags.keys()) {
|
|
if (!spec.allowedFlags.includes(flag)) {
|
|
throw new RuntimeClientError(
|
|
'invalid_argument',
|
|
`Unknown flag --${flag} for command: ${spec.path.join(' ')}`
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
export function findCommandSpec(commandPath: string[]): CommandSpec | undefined {
|
|
return COMMAND_SPECS.find((spec) => matches(spec.path, commandPath))
|
|
}
|
|
|
|
function isCommandGroup(commandPath: string[]): boolean {
|
|
return commandPath.length === 1 && ['repo', 'worktree', 'terminal'].includes(commandPath[0])
|
|
}
|
|
|
|
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}`)
|
|
}
|
|
|
|
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 buildCurrentWorktreeSelector(cwd: string): string {
|
|
return `path:${resolvePath(cwd)}`
|
|
}
|
|
|
|
export function normalizeWorktreeSelector(selector: string, cwd: string): string {
|
|
if (selector === 'active' || selector === 'current') {
|
|
return buildCurrentWorktreeSelector(cwd)
|
|
}
|
|
return selector
|
|
}
|
|
|
|
function isWithinPath(parentPath: string, childPath: string): boolean {
|
|
const relativePath = relative(parentPath, childPath)
|
|
return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))
|
|
}
|
|
|
|
async function resolveCurrentWorktreeSelector(cwd: string, client: RuntimeClient): Promise<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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
export function matches(actual: string[], expected: string[]): boolean {
|
|
return (
|
|
actual.length === expected.length && actual.every((value, index) => value === expected[index])
|
|
)
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
function formatStatus(status: CliStatusResult): string {
|
|
return formatCliStatus(status)
|
|
}
|
|
|
|
function formatCliStatus(status: CliStatusResult): string {
|
|
return [
|
|
`appRunning: ${status.app.running}`,
|
|
`pid: ${status.app.pid ?? 'none'}`,
|
|
`runtimeState: ${status.runtime.state}`,
|
|
`runtimeReachable: ${status.runtime.reachable}`,
|
|
`runtimeId: ${status.runtime.runtimeId ?? 'none'}`,
|
|
`graphState: ${status.graph.state}`
|
|
].join('\n')
|
|
}
|
|
|
|
function formatCliError(error: unknown): string {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
if (
|
|
error instanceof RuntimeClientError &&
|
|
(error.code === 'runtime_unavailable' || error.code === 'runtime_timeout')
|
|
) {
|
|
return `${message}\nOrca is not running. Run 'orca open' first.`
|
|
}
|
|
if (
|
|
error instanceof RuntimeRpcFailureError &&
|
|
error.response.error.code === 'runtime_unavailable'
|
|
) {
|
|
return `${message}\nOrca is not running. Run 'orca open' first.`
|
|
}
|
|
return message
|
|
}
|
|
|
|
function formatTerminalList(result: RuntimeTerminalListResult): string {
|
|
if (result.terminals.length === 0) {
|
|
return 'No live terminals.'
|
|
}
|
|
const body = result.terminals
|
|
.map(
|
|
(terminal) =>
|
|
`${terminal.handle} ${terminal.title ?? '(untitled)'} ${terminal.connected ? 'connected' : 'disconnected'} ${terminal.worktreePath}\n${terminal.preview ? `preview: ${terminal.preview}` : 'preview: <empty>'}`
|
|
)
|
|
.join('\n\n')
|
|
return result.truncated
|
|
? `${body}\n\ntruncated: showing ${result.terminals.length} of ${result.totalCount}`
|
|
: body
|
|
}
|
|
|
|
function formatTerminalShow(result: { terminal: RuntimeTerminalShow }): string {
|
|
const terminal = result.terminal
|
|
return [
|
|
`handle: ${terminal.handle}`,
|
|
`title: ${terminal.title ?? '(untitled)'}`,
|
|
`worktree: ${terminal.worktreePath}`,
|
|
`branch: ${terminal.branch}`,
|
|
`leaf: ${terminal.leafId}`,
|
|
`ptyId: ${terminal.ptyId ?? 'none'}`,
|
|
`connected: ${terminal.connected}`,
|
|
`writable: ${terminal.writable}`,
|
|
`preview: ${terminal.preview || '<empty>'}`
|
|
].join('\n')
|
|
}
|
|
|
|
function formatTerminalRead(result: { terminal: RuntimeTerminalRead }): string {
|
|
const terminal = result.terminal
|
|
return [`handle: ${terminal.handle}`, `status: ${terminal.status}`, '', ...terminal.tail].join(
|
|
'\n'
|
|
)
|
|
}
|
|
|
|
function formatTerminalSend(result: { send: RuntimeTerminalSend }): string {
|
|
return `Sent ${result.send.bytesWritten} bytes to ${result.send.handle}.`
|
|
}
|
|
|
|
function formatTerminalWait(result: { wait: RuntimeTerminalWait }): string {
|
|
return [
|
|
`handle: ${result.wait.handle}`,
|
|
`condition: ${result.wait.condition}`,
|
|
`satisfied: ${result.wait.satisfied}`,
|
|
`status: ${result.wait.status}`,
|
|
`exitCode: ${result.wait.exitCode ?? 'null'}`
|
|
].join('\n')
|
|
}
|
|
|
|
function formatWorktreePs(result: RuntimeWorktreePsResult): string {
|
|
if (result.worktrees.length === 0) {
|
|
return 'No worktrees found.'
|
|
}
|
|
const body = result.worktrees
|
|
.map(
|
|
(worktree) =>
|
|
`${worktree.repo} ${worktree.branch} live:${worktree.liveTerminalCount} pty:${worktree.hasAttachedPty ? 'yes' : 'no'} unread:${worktree.unread ? 'yes' : 'no'}\n${worktree.path}${worktree.preview ? `\npreview: ${worktree.preview}` : ''}`
|
|
)
|
|
.join('\n\n')
|
|
return result.truncated
|
|
? `${body}\n\ntruncated: showing ${result.worktrees.length} of ${result.totalCount}`
|
|
: body
|
|
}
|
|
|
|
function formatRepoList(result: RuntimeRepoList): string {
|
|
if (result.repos.length === 0) {
|
|
return 'No repos found.'
|
|
}
|
|
return result.repos.map((repo) => `${repo.id} ${repo.displayName} ${repo.path}`).join('\n')
|
|
}
|
|
|
|
function formatRepoShow(result: { repo: Record<string, unknown> }): string {
|
|
return Object.entries(result.repo)
|
|
.map(
|
|
([key, value]) =>
|
|
`${key}: ${typeof value === 'object' ? JSON.stringify(value) : String(value)}`
|
|
)
|
|
.join('\n')
|
|
}
|
|
|
|
function formatRepoRefs(result: RuntimeRepoSearchRefs): string {
|
|
if (result.refs.length === 0) {
|
|
return 'No refs found.'
|
|
}
|
|
return result.truncated ? `${result.refs.join('\n')}\n\ntruncated: yes` : result.refs.join('\n')
|
|
}
|
|
|
|
function formatWorktreeList(result: RuntimeWorktreeListResult): string {
|
|
if (result.worktrees.length === 0) {
|
|
return 'No worktrees found.'
|
|
}
|
|
const body = result.worktrees
|
|
.map(
|
|
(worktree) =>
|
|
`${String(worktree.id)} ${String(worktree.branch)} ${String(worktree.path)}\ndisplayName: ${String(worktree.displayName ?? '')}\nlinkedIssue: ${String(worktree.linkedIssue ?? 'null')}\ncomment: ${String(worktree.comment ?? '')}`
|
|
)
|
|
.join('\n\n')
|
|
return result.truncated
|
|
? `${body}\n\ntruncated: showing ${result.worktrees.length} of ${result.totalCount}`
|
|
: body
|
|
}
|
|
|
|
function formatWorktreeShow(result: { worktree: RuntimeWorktreeRecord }): string {
|
|
const worktree = result.worktree
|
|
return Object.entries(worktree)
|
|
.map(
|
|
([key, value]) =>
|
|
`${key}: ${typeof value === 'object' ? JSON.stringify(value) : String(value)}`
|
|
)
|
|
.join('\n')
|
|
}
|
|
|
|
function printHelp(commandPath: string[] = []): void {
|
|
const exactSpec = findCommandSpec(commandPath)
|
|
if (exactSpec) {
|
|
console.log(formatCommandHelp(exactSpec))
|
|
return
|
|
}
|
|
|
|
if (isCommandGroup(commandPath)) {
|
|
console.log(formatGroupHelp(commandPath[0]))
|
|
return
|
|
}
|
|
|
|
if (commandPath.length > 0) {
|
|
console.log(`Unknown command: ${commandPath.join(' ')}\n`)
|
|
}
|
|
|
|
console.log(`orca
|
|
|
|
Usage: orca <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 repo 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
|
|
terminal stop Stop terminals for a worktree
|
|
|
|
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 [--timeout-ms <ms>] [--json]
|
|
orca terminal stop --worktree <selector> [--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.
|
|
|
|
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`)
|
|
}
|
|
|
|
function formatCommandHelp(spec: CommandSpec): string {
|
|
const lines = [`orca ${spec.path.join(' ')}`, '', `Usage: ${spec.usage}`, '', spec.summary]
|
|
|
|
if (spec.allowedFlags.length > 0) {
|
|
lines.push('', 'Options:')
|
|
for (const flag of spec.allowedFlags) {
|
|
lines.push(` ${formatFlagHelp(flag)}`)
|
|
}
|
|
}
|
|
|
|
if (spec.notes && spec.notes.length > 0) {
|
|
lines.push('', 'Notes:')
|
|
for (const note of spec.notes) {
|
|
lines.push(` ${note}`)
|
|
}
|
|
}
|
|
|
|
if (spec.examples && spec.examples.length > 0) {
|
|
lines.push('', 'Examples:')
|
|
for (const example of spec.examples) {
|
|
lines.push(` $ ${example}`)
|
|
}
|
|
}
|
|
|
|
return lines.join('\n')
|
|
}
|
|
|
|
function formatGroupHelp(group: string): string {
|
|
const specs = COMMAND_SPECS.filter((spec) => spec.path[0] === group)
|
|
const lines = [`orca ${group}`, '', `Usage: orca ${group} <command> [options]`, '', 'Commands:']
|
|
for (const spec of specs) {
|
|
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')
|
|
}
|
|
|
|
function formatFlagHelp(flag: string): string {
|
|
const helpByFlag: Record<string, string> = {
|
|
'base-branch': '--base-branch <ref> Base branch/ref to create the worktree from',
|
|
comment: '--comment <text> Comment stored in Orca metadata',
|
|
'display-name': '--display-name <name> Override the Orca display name',
|
|
enter: '--enter Append Enter after sending text',
|
|
force: '--force Force worktree removal when supported',
|
|
for: '--for exit 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'
|
|
}
|
|
|
|
return helpByFlag[flag] ?? `--${flag}`
|
|
}
|
|
|
|
if (require.main === module) {
|
|
void main()
|
|
}
|