Files
orca/src/cli/args.ts
T
Neil d563f8e0e0 feat(speech): set the OpenAI dictation key on a headless host (#31230)
* feat(speech): set the OpenAI dictation key on a headless host (orca speech openai-key)

Cloud dictation needs an OpenAI key on the host that transcribes, and
desktop Settings was the only way to save one. A headless orca serve or
managed server had none. speech.openAiKey.{status,set,clear} save it
through the host's existing key store and secret sealing; the CLI reads
the key from stdin so it never lands in shell history.

* fix(cli): name the update when an older server lacks the speech key methods; one key length cap; PowerShell example (Opus review)
2026-10-20 15:12:28 -07:00

290 lines
8.7 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',
'reference',
'worktree',
'terminal',
'file',
'orchestration',
'computer',
'emulator',
'note',
'diagnostics',
'speech',
'jira',
'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(specs: CommandSpec[], commandPath: string[]): boolean {
if (commandPath.length === 0) {
return false
}
return specs.some(
(spec) =>
spec.hidden !== true &&
specPaths(spec).some(
(candidate) =>
candidate.length > commandPath.length &&
matches(candidate.slice(0, commandPath.length), commandPath)
)
)
}
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 ||
(!spec.variadicPositional && 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[Math.min(index, positionalArgs.length - 1)]
)
const positionalFlagConflicts = providedPositionals.filter((name) => flags.has(name))
values.forEach((value, index) => {
const name = providedPositionals[index]
if (!parsed.flags.has(name)) {
setFlagValue(flags, name, value, new Set(spec.repeatableFlags ?? []))
}
})
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))
)
}
}
}