mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 17:17:38 +00:00
* 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)
290 lines
8.7 KiB
TypeScript
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))
|
|
)
|
|
}
|
|
}
|
|
}
|