326 lines
9.4 KiB
TypeScript
326 lines
9.4 KiB
TypeScript
import { RuntimeClientError } from './runtime-client'
|
|
import { unknownCommandData, unknownFlagData } from './command-suggestion'
|
|
|
|
export type ParsedArgs = {
|
|
commandPath: string[]
|
|
flags: Map<string, string | boolean>
|
|
positionalFlagConflicts?: string[]
|
|
}
|
|
|
|
export type CommandSpec = {
|
|
path: string[]
|
|
// Why: conventional alternate verbs should resolve without duplicating specs
|
|
// or handler registrations.
|
|
aliases?: string[][]
|
|
argumentMode?: 'parsed' | 'passthrough'
|
|
// Why: irreversibly destroys persistent state — typo recovery must not steer a
|
|
// benign mistake into one of these via the agent nextSteps channel. #6303
|
|
destructive?: boolean
|
|
summary: string
|
|
usage: string
|
|
allowedFlags: string[]
|
|
positionalArgs?: string[]
|
|
examples?: string[]
|
|
notes?: string[]
|
|
}
|
|
|
|
export const GLOBAL_FLAGS = ['help', 'json', 'pairing-code', 'environment']
|
|
const GLOBAL_VALUE_FLAGS = new Set(['pairing-code', 'environment'])
|
|
export const BOOLEAN_FLAGS = new Set([
|
|
'all',
|
|
'attachments',
|
|
'children',
|
|
'comments',
|
|
'connect',
|
|
'current',
|
|
'dry-run',
|
|
'enter',
|
|
'focus',
|
|
'force',
|
|
'full',
|
|
'help',
|
|
'inject',
|
|
'interrupt',
|
|
'json',
|
|
'messages',
|
|
'me',
|
|
'mobile',
|
|
'mobile-pairing',
|
|
'no-pairing',
|
|
'parent-current',
|
|
'provision',
|
|
'ready',
|
|
'recipe-json',
|
|
'relations',
|
|
'reinstall',
|
|
'restore-window',
|
|
'return-preamble',
|
|
'run-hooks',
|
|
'show-profile',
|
|
'staged',
|
|
'tasks',
|
|
'text-stdin',
|
|
'unread',
|
|
'value-stdin',
|
|
'wait'
|
|
])
|
|
|
|
export const REPEATED_FLAG_SEPARATOR = '\u0000'
|
|
const REPEATABLE_STRING_FLAGS = new Set(['label'])
|
|
|
|
function setFlagValue(flags: Map<string, string | boolean>, name: string, value: string): void {
|
|
const existing = flags.get(name)
|
|
if (typeof existing === 'string' && REPEATABLE_STRING_FLAGS.has(name)) {
|
|
flags.set(name, `${existing}${REPEATED_FLAG_SEPARATOR}${value}`)
|
|
return
|
|
}
|
|
flags.set(name, value)
|
|
}
|
|
|
|
function commandPathStartsAt(argv: string[], tokenIndex: number, path: string[]): boolean {
|
|
let cursor = tokenIndex
|
|
for (const part of path) {
|
|
while (argv[cursor]?.startsWith('--')) {
|
|
const assignment = argv[cursor].slice(2)
|
|
const flag = assignment.split('=', 1)[0]
|
|
cursor += assignment.includes('=') || BOOLEAN_FLAGS.has(flag) ? 1 : 2
|
|
}
|
|
if (argv[cursor] !== part) {
|
|
return false
|
|
}
|
|
cursor += 1
|
|
}
|
|
return true
|
|
}
|
|
|
|
export function parseArgs(argv: string[], commandPaths?: readonly string[][]): ParsedArgs {
|
|
const commandPath: string[] = []
|
|
const flags = new Map<string, string | boolean>()
|
|
|
|
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) {
|
|
setFlagValue(flags, assignment.slice(0, equalsIndex), assignment.slice(equalsIndex + 1))
|
|
continue
|
|
}
|
|
|
|
const flag = assignment
|
|
if (BOOLEAN_FLAGS.has(flag)) {
|
|
flags.set(flag, true)
|
|
continue
|
|
}
|
|
// Why: a pre-command flag must not consume a registry-resolvable command path.
|
|
const startsCommandAt = (tokenIndex: number): boolean =>
|
|
commandPaths?.some((path) => commandPathStartsAt(argv, tokenIndex, path)) ?? false
|
|
if (commandPath.length === 0 && startsCommandAt(i + 1) && !startsCommandAt(i + 2)) {
|
|
flags.set(flag, true)
|
|
continue
|
|
}
|
|
const hasNext = i + 1 < argv.length
|
|
const next = argv[i + 1]
|
|
if (!hasNext || next.startsWith('--')) {
|
|
flags.set(flag, true)
|
|
continue
|
|
}
|
|
setFlagValue(flags, flag, next)
|
|
i += 1
|
|
}
|
|
|
|
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])
|
|
)
|
|
}
|
|
|
|
// Why: a spec is reachable by its canonical path plus any declared aliases — one
|
|
// definition so resolution, validation, help, and agent-context never disagree.
|
|
export function specPaths(spec: CommandSpec): string[][] {
|
|
return spec.aliases ? [spec.path, ...spec.aliases] : [spec.path]
|
|
}
|
|
|
|
export function supportsBrowserPageFlag(commandPath: string[]): boolean {
|
|
const joined = commandPath.join(' ')
|
|
if (['open', 'status'].includes(commandPath[0])) {
|
|
return false
|
|
}
|
|
if (
|
|
[
|
|
'automations',
|
|
'project',
|
|
'repo',
|
|
'worktree',
|
|
'terminal',
|
|
'file',
|
|
'orchestration',
|
|
'computer',
|
|
'emulator',
|
|
'note',
|
|
'diagnostics',
|
|
'linear',
|
|
'skills',
|
|
'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 &&
|
|
[
|
|
'automations',
|
|
'project',
|
|
'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))
|
|
)
|
|
}
|
|
}
|
|
}
|