mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 16:02:56 +00:00
* 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`.
306 lines
9.0 KiB
TypeScript
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))
|
|
)
|
|
}
|
|
}
|
|
}
|