Files
orca/src/cli/args.ts
T
Jinwoo Hong 3e5eb0329a feat(cli): orca search over the agent session index (#20514)
* feat(cli): orca search over the agent session index

`orca search <query>` calls PR 5's `aiVault.searchSessions` over the CLI's
existing runtime RPC, against the host `--environment` / `--pairing-code`
selects and no other. `orca search --index-status` calls `aiVault.searchStatus`.
It is the proof the contract works with no panel.

Every flag maps onto a contract field and nothing else: `--scope`, `--fresh`,
`--limit`, `--cursor`, repeatable `--agent` and `--path`, `--since`, `--sort`,
`--debug`, `--json`. No fan-out, no merged output, no `--host`.

One command rather than a `search status` subcommand: the query is a bare
positional, so `orca search status` could not be told apart from searching for
the word "status". `--status` is unavailable because `orchestration task-list
--status <state>` already owns the name as a valued flag.

No new runtime capability. PR 5 decided an explicit `method_not_found` refusal
maps to `unavailable/no-service`, so reusing `createSessionSearchClient` gives
an old host a plain "this host runs no session search service" answer at exit 0
instead of a raw JSON-RPC error.

`CommandSpec.repeatableFlags` scopes repeatability per command, because
`--agent` must repeat for search and stay single-valued for `worktree create`.
`help.ts` sat exactly at max-lines, so `skills-command-flag-help.ts` becomes
`command-scoped-flag-help.ts` carrying both tables at the same call-site size.

* refactor(cli): drop the search type assertions main's casting gate now rejects

Main gained a `consistent-type-assertions: never` scan in the changed-code gate
after this branch was cut, and it reported twelve assertions in the new files.

The four in the argument parser were avoidable. `readEnum` now keeps the value
`find` returns, which already carries the narrow type, and the agent filter goes
through an `isAiVaultAgent` predicate over a `Set<string>` instead of widening
the agent tuple.

The test now narrows the printed envelope by shape and re-reads the printed
result through `AiVaultSearchResponseSchema`, so the JSON assertions are checked
rather than claimed, and the flag table is typed so its callback needs no cast.
One assertion is left, for the structural fake client, with the SAFETY rationale
AGENTS.md requires.

* fix(cli): sanitize host strings and scope pre-command repeatable flags

Route every host-supplied string the search formatter prints through the
escape stripper, and resolve the repeatable-flag set from the command
tokens ahead when a flag sits before the command.

* refactor(cli): resolve repeatable flag rules once per command

* fix(cli): clarify session search availability and SSH scope

* feat(cli): hide orca search until the settings toggle ships

`orca search` stays dispatchable but leaves every discovery surface: root
help, group help, unknown-command suggestions, and `agent-context --json`.
`buildAgentContext` did not filter hidden specs, so it also stops leaking
the hidden `terminal stop`.
2026-09-16 12:03:38 -04:00

306 lines
9.0 KiB
TypeScript

import { RuntimeClientError } from './runtime/types'
import { unknownCommandData, unknownFlagData } from './command-suggestion'
import { specPaths, type CommandSpec } from './command-spec'
import {
CLI_BOOLEAN_FLAGS,
CLI_GLOBAL_FLAGS,
CLI_GLOBAL_VALUE_FLAGS,
findCliCommandIndex
} from '../shared/cli-argument-boundary'
export { specPaths }
export type { CommandSpec }
export type ParsedArgs = {
commandPath: string[]
flags: Map<string, string | boolean>
positionalFlagConflicts?: string[]
}
export const GLOBAL_FLAGS = CLI_GLOBAL_FLAGS
const GLOBAL_VALUE_FLAGS = new Set(CLI_GLOBAL_VALUE_FLAGS)
export const BOOLEAN_FLAGS = CLI_BOOLEAN_FLAGS
export const REPEATED_FLAG_SEPARATOR = '\u0000'
const REPEATABLE_STRING_FLAGS = new Set(['label', 'skill'])
function setFlagValue(
flags: Map<string, string | boolean>,
name: string,
value: string,
repeatable: ReadonlySet<string>
): void {
const existing = flags.get(name)
if (typeof existing === 'string' && repeatable.has(name)) {
flags.set(name, `${existing}${REPEATED_FLAG_SEPARATOR}${value}`)
return
}
flags.set(name, value)
}
/** The most specific spec whose path prefixes `path`, so a group never shadows a leaf. */
function specForPathPrefix(
specs: readonly CommandSpec[],
path: readonly string[]
): CommandSpec | undefined {
let best: { spec: CommandSpec; length: number } | undefined
for (const spec of specs) {
for (const candidate of specPaths(spec)) {
if (
candidate.length <= path.length &&
candidate.every((part, index) => part === path[index]) &&
(!best || candidate.length > best.length)
) {
best = { spec, length: candidate.length }
}
}
}
return best?.spec
}
export function parseArgs(
argv: string[],
commandPaths?: readonly string[][],
specs: readonly CommandSpec[] = []
): ParsedArgs {
const commandPath: string[] = []
const flagEntries: [string, string | boolean][] = []
const commandIndex = findCliCommandIndex(argv, commandPaths ?? [])
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i]
if (!token.startsWith('--')) {
commandPath.push(token)
continue
}
const assignment = token.slice(2)
// Why: `--flag=value` is the only unambiguous way to pass a value that
// itself starts with `--` (e.g. `--text=--help`); the space-separated form
// treats a `--`-leading next token as a new flag, so it can't express one.
const equalsIndex = assignment.indexOf('=')
if (equalsIndex !== -1) {
flagEntries.push([assignment.slice(0, equalsIndex), assignment.slice(equalsIndex + 1)])
continue
}
const flag = assignment
if (BOOLEAN_FLAGS.has(flag)) {
flagEntries.push([flag, true])
continue
}
// Why: a pre-command flag must not consume a registry-resolvable command path.
if (commandPath.length === 0 && i + 1 === commandIndex) {
flagEntries.push([flag, true])
continue
}
const hasNext = i + 1 < argv.length
const next = argv[i + 1]
if (!hasNext || next.startsWith('--')) {
flagEntries.push([flag, true])
continue
}
flagEntries.push([flag, next])
i += 1
}
const declared = specForPathPrefix(specs, commandPath)?.repeatableFlags
const repeatable = declared
? new Set([...REPEATABLE_STRING_FLAGS, ...declared])
: REPEATABLE_STRING_FLAGS
const flags = new Map<string, string | boolean>()
for (const [name, value] of flagEntries) {
if (typeof value === 'string') {
setFlagValue(flags, name, value, repeatable)
} else {
flags.set(name, value)
}
}
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 (
[
'account',
'artifacts',
'automations',
'project',
'repo',
'worktree',
'terminal',
'file',
'orchestration',
'computer',
'emulator',
'note',
'diagnostics',
'linear',
'skills',
'search',
'agent-context'
].includes(commandPath[0])
) {
return false
}
return ![
'tab list',
'tab create',
'tab current',
'tab profile list',
'tab profile create',
'tab profile delete'
].includes(joined)
}
// Why: validation and agent discovery must expose the same effective flag set.
export function effectiveAllowedFlags(spec: CommandSpec): string[] {
if (spec.argumentMode === 'passthrough') {
return []
}
return [
...new Set([
...GLOBAL_FLAGS,
...spec.allowedFlags,
...(supportsBrowserPageFlag(spec.path) ? ['page'] : [])
])
]
}
export function isCommandGroup(commandPath: string[]): boolean {
return (
(commandPath.length === 1 &&
[
'account',
'artifacts',
'automations',
'project',
'host',
'repo',
'worktree',
'terminal',
'file',
'tab',
'cookie',
'intercept',
'capture',
'mouse',
'set',
'clipboard',
'dialog',
'storage',
'orchestration',
'computer',
'emulator',
'agent',
'environment',
'diagnostics',
'linear',
'skills',
'vm'
].includes(commandPath[0])) ||
(commandPath.length === 2 && commandPath[0] === 'agent' && commandPath[1] === 'hooks') ||
(commandPath.length === 2 &&
commandPath[0] === 'storage' &&
['local', 'session'].includes(commandPath[1]))
)
}
export function normalizeCommandPositionals(specs: CommandSpec[], parsed: ParsedArgs): ParsedArgs {
for (const spec of specs) {
const positionalArgs = spec.positionalArgs ?? []
// Why: aliased paths still need canonicalization when there are no positionals.
if (positionalArgs.length === 0 && !spec.aliases) {
continue
}
// Why: canonicalize aliases before validation and dispatch so both use one key.
for (const base of specPaths(spec)) {
// Why: `< 0` (not `<= 0`) so an exact base match with zero positionals
// still canonicalizes an aliased path; upper bound guards over-consumption.
const positionalCount = parsed.commandPath.length - base.length
if (positionalCount < 0 || positionalCount > positionalArgs.length) {
continue
}
if (!matches(parsed.commandPath.slice(0, base.length), base)) {
continue
}
const flags = new Map(parsed.flags)
const values = parsed.commandPath.slice(base.length)
// Why: validation runs inside main's error-reporting path, so normalization
// records ambiguity instead of throwing before CLI errors can be formatted.
const providedPositionals = values.map((_, index) => positionalArgs[index])
const positionalFlagConflicts = providedPositionals.filter((name) => flags.has(name))
values.forEach((value, index) => {
const name = positionalArgs[index]
if (!flags.has(name)) {
flags.set(name, value)
}
})
return { commandPath: spec.path, flags, positionalFlagConflicts }
}
}
return parsed
}
export function findCommandSpec(
specs: CommandSpec[],
commandPath: string[]
): CommandSpec | undefined {
return specs.find((spec) => specPaths(spec).some((candidate) => matches(candidate, 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(' ')}`,
unknownCommandData(specs, parsed.commandPath)
)
}
if (parsed.positionalFlagConflicts && parsed.positionalFlagConflicts.length > 0) {
throw new RuntimeClientError(
'invalid_argument',
`Pass ${parsed.positionalFlagConflicts
.map((flag) => `--${flag}`)
.join(', ')} either positionally or as a flag, not both.`
)
}
const pageAllowed = supportsBrowserPageFlag(spec.path)
for (const [flag, value] of parsed.flags) {
const isGlobalFlag = GLOBAL_FLAGS.includes(flag)
if (GLOBAL_VALUE_FLAGS.has(flag) && (typeof value !== 'string' || value.length === 0)) {
throw new RuntimeClientError('invalid_argument', `Flag --${flag} requires a value.`)
}
if (!isGlobalFlag && !spec.allowedFlags.includes(flag) && !(flag === 'page' && pageAllowed)) {
throw new RuntimeClientError(
'invalid_argument',
`Unknown flag --${flag} for command: ${spec.path.join(' ')}`,
unknownFlagData(flag, effectiveAllowedFlags(spec))
)
}
}
}