Files
orca/src/cli/index.ts
T
e2b4bc2c2c feat(cli): make the CLI self-correcting and self-describing for agents (#6303)
* feat(cli): make the CLI self-correcting and self-describing for agents

Agents build a generalized model of how CLIs work and apply it to every
tool. When orca diverged — `rm` where git uses `remove` — a reasonable
first guess (`orca worktree remove`) dead-ended on a bare "Unknown
command" with no path forward. This makes the CLI degrade gracefully when
the orca-cli skill isn't loaded in context.

- First-class CommandSpec.aliases, resolved to the canonical path before
  dispatch (no new handler registrations). `worktree remove`/`delete` now
  resolve to `rm`; the ad-hoc `terminal focus` duplicate spec/handler is
  migrated onto the mechanism.
- Did-you-mean suggestions on unknown commands and unknown flags, ranked
  by edit distance over the live registry, surfaced in both stderr and
  --json error.data (reusing the existing nextSteps channel).
- `orca agent-context [--json]`: a versioned, machine-readable dump of the
  command schema. Pure local read (no RPC), so it works over SSH and when
  the app isn't running.
- CI guards: specs<->handlers parity, and a vocabulary policy that fails
  on new off-policy deletion/read verbs (existing ones grandfathered).

* Address PR review feedback (#6303)

- agent-context now emits each command's effective flag set (globals +
  conditional --page), not just allowedFlags, so the schema no longer
  under-reports --json/--help. Shared as effectiveAllowedFlags() between
  validation and the schema.
- Collision check now covers alias paths too, so a duplicate alias that
  would silently shadow a real command fails the build.

* fix(cli): harden agent recovery and introspection

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
2026-07-10 19:17:01 -07:00

156 lines
5.0 KiB
JavaScript

#!/usr/bin/env node
import {
findCommandSpec,
isCommandGroup,
normalizeCommandPositionals,
parseArgs,
resolveHelpPath,
specPaths,
validateCommandAndFlags
} from './args'
import { dispatch } from './dispatch'
import { reportCliError } from './format'
import { printHelp } from './help'
import { RuntimeClient } from './runtime-client'
import { COMMAND_SPECS } from './specs'
export { COMMAND_SPECS } from './specs'
export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors'
const COMMAND_PATHS = COMMAND_SPECS.flatMap((spec) => specPaths(spec))
function shouldIgnoreRemoteSelection(commandPath: string[]): boolean {
return (
commandPath[0] === 'environment' ||
commandPath[0] === 'serve' ||
commandPath[0] === 'agent' ||
commandPath[0] === 'vm' ||
commandPath[0] === 'agent-context'
)
}
// Why: the SSH relay bridge executes this CLI on the Orca host while the
// caller's shell cwd lives on the remote machine (which cannot be chdir'd
// into). ORCA_CLI_CWD carries that remote cwd so cwd-based selectors like
// `--worktree active` resolve against the caller's directory.
function resolveInvocationCwd(): string {
const override = process.env.ORCA_CLI_CWD
return typeof override === 'string' && override.length > 0 ? override : process.cwd()
}
export async function main(
argv = process.argv.slice(2),
cwd = resolveInvocationCwd()
): Promise<void> {
if (argv[0] === 'agent-teams-tmux') {
await runAgentTeamsTmuxShim(argv.slice(1))
return
}
if (argv[0] === 'claude-teams') {
await runClaudeTeams(argv.slice(1), cwd)
return
}
const parsed = normalizeCommandPositionals(COMMAND_SPECS, parseArgs(argv, COMMAND_PATHS))
const helpPath = resolveHelpPath(parsed)
if (helpPath !== null) {
printHelp(COMMAND_SPECS, helpPath)
if (
helpPath.length > 0 &&
!findCommandSpec(COMMAND_SPECS, helpPath) &&
!isCommandGroup(helpPath)
) {
process.exitCode = 1
}
return
}
if (parsed.commandPath.length === 0) {
printHelp(COMMAND_SPECS, [])
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(COMMAND_SPECS, parsed)
const ignoreRemoteSelection = shouldIgnoreRemoteSelection(parsed.commandPath)
const pairingCode = ignoreRemoteSelection ? null : parsed.flags.get('pairing-code')
const environmentSelector = ignoreRemoteSelection ? null : parsed.flags.get('environment')
// Why: pass `null` (not `undefined`) when remote selection is suppressed
// so the RuntimeClient default parameter does not re-activate the
// ORCA_PAIRING_CODE / ORCA_ENVIRONMENT env-var fallback for commands
// that must run locally (environment / serve).
let client: RuntimeClient | undefined
await dispatch(parsed.commandPath, {
flags: parsed.flags,
// Why: local-only handlers must not resolve runtime metadata just to dispatch.
get client() {
client ??= new RuntimeClient(
undefined,
undefined,
typeof pairingCode === 'string' ? pairingCode : ignoreRemoteSelection ? null : undefined,
typeof environmentSelector === 'string'
? environmentSelector
: ignoreRemoteSelection
? null
: undefined
)
return client
},
cwd,
json
})
} catch (error) {
reportCliError(error, json, { commandPath: parsed.commandPath })
process.exitCode = 1
}
}
async function runClaudeTeams(argv: string[], cwd: string): Promise<void> {
try {
// Why: everything after `orca claude-teams` belongs to Claude Code, not
// Orca's own flag parser, so new Claude flags work without Orca changes.
const client = new RuntimeClient(undefined, undefined, null, null)
await dispatch(['claude-teams'], {
flags: new Map(),
client,
cwd,
json: false,
rawArgs: argv
})
} catch (error) {
reportCliError(error, false, { commandPath: ['claude-teams'] })
process.exitCode = 1
}
}
async function runAgentTeamsTmuxShim(argv: string[]): Promise<void> {
try {
const client = new RuntimeClient(undefined, 10_000)
const response = await client.call<{
tmux: { stdout: string; stderr: string; exitCode: number }
}>(
'agentTeams.tmuxCompat',
{
teamId: process.env.ORCA_AGENT_TEAMS_TEAM_ID,
token: process.env.ORCA_AGENT_TEAMS_TOKEN,
envPane: process.env.TMUX_PANE,
cwd: process.cwd(),
argv
},
{ timeoutMs: 10_000 }
)
process.stdout.write(response.result.tmux.stdout)
process.stderr.write(response.result.tmux.stderr)
process.exitCode = response.result.tmux.exitCode
} catch (error) {
reportCliError(error, false, { commandPath: ['agent-teams-tmux'] })
process.exitCode = 1
}
}
if (require.main === module) {
void main()
}