// shared CLI argument parser — handles global flags, commands, and per-command flags import { RNX_COMMAND_NAMES, RNX_NODE_COMMAND_NAMES } from './command-registry' export interface ParsedArgs { command: string | null commandArgs: string[] globalFlags: Record help: boolean version: boolean verbose: boolean warnings: string[] } const GLOBAL_FLAGS: Record< string, { type: 'string' | 'boolean' | 'number'; short?: string } > = { '--help': { type: 'boolean', short: '-h' }, '--version': { type: 'boolean', short: '-V' }, '--verbose': { type: 'boolean', short: '-v' }, '--port': { type: 'number', short: '-p' }, '--device': { type: 'string', short: '-d' }, '--theme': { type: 'string', short: '-t' }, '--headless': { type: 'boolean' }, '--driver': { type: 'string' }, '--sim': { type: 'string' }, // documented aliases for --sim (CLAUDE.md / skills / engine docs say // `--session `). normalized to the sim target in bin.ts. '--session': { type: 'string' }, '--tab': { type: 'string' }, } // every spelling parseRnxArgs consumes before the command name, mapped to // whether it takes a value. a command that rejects unknown flags uses this to // tell a genuinely unknown flag apart from a global one typed after the // command, where nothing reads it, and to show how to pass that one: telling // someone to write `rnx --verbose ` earns them a second error. export const GLOBAL_FLAG_TAKES_VALUE: ReadonlyMap = new Map( Object.entries(GLOBAL_FLAGS).flatMap(([name, def]): [string, boolean][] => { const takesValue = def.type !== 'boolean' return def.short ? [ [name, takesValue], [def.short, takesValue], ] : [[name, takesValue]] }), ) // top-level verbs that route into runInspect. the surface is intentionally // tiny — everything else lives under one of the grouping verbs: // - `rnx get ` reads // - `rnx do ` writes // - `rnx debug ` instrumentation (handled by runDebug) // `describe` and `find` stay at root as the most common entrypoints. // `list` stays at root because it's a sim-management read. export const TOP_LEVEL_RUNTIME_COMMANDS = new Set([ 'list', 'describe', 'find', 'get', 'do', 'wait', 'network', 'logs', 'shell', ]) // grouping verbs that take a sub-verb (`rnx wait ready`, // `rnx do tap-id`). agents and humans very often type the hyphenated // form (`rnx wait-ready`, `rnx do-tap-id`) — it reads as one // command and matches the typed-tool names (rnx_wait_ready). bin.ts // normalizes `-` → ` ` for these so the intuitive // spelling just works instead of failing as "unknown command". export const HYPHENATED_GROUPING_VERBS = new Set(['do', 'get', 'wait', 'shell', 'debug']) // known commands — if first positional arg matches, it's a command. // every entry here must have a `cliCommandMetas` entry in // `packages/rnx-skills/src/cli/meta.ts` (or sit on the internal // allowlist in `test/sootsimCliRegistry.test.ts`), so help, website docs, // and agent skills can never drift from what the parser accepts. export const COMMANDS = new Set(RNX_NODE_COMMAND_NAMES) export function parseRnxArgs(args: string[]): ParsedArgs { const result: ParsedArgs = { command: null, commandArgs: [], globalFlags: {}, help: false, version: false, verbose: false, warnings: [], } let i = 0 // parse global flags and detect command while (i < args.length) { const arg = args[i] // explicit separator — strip it, everything after is command args if (arg === '--') { result.commandArgs.push(...args.slice(i + 1)) break } // check for global flag const flagEntry = Object.entries(GLOBAL_FLAGS).find( ([name, def]) => name === arg || def.short === arg, ) if (flagEntry) { const [name, def] = flagEntry const key = name.replace(/^--/, '') if (def.type === 'boolean') { result.globalFlags[key] = true i++ } else { const val = args[i + 1] if (val === undefined || val.startsWith('-')) { // missing value — treat as error, pass through result.warnings.push(` warning: ${arg} requires a value`) i++ continue } if (def.type === 'number') { const num = Number(val) if (Number.isNaN(num)) { result.warnings.push(` warning: ${arg} requires a number, got "${val}"`) i += 2 continue } result.globalFlags[key] = num } else { result.globalFlags[key] = val } i += 2 } continue } // first non-flag arg — is it a command? if (!result.command && !arg.startsWith('-')) { // hyphenated grouping-verb spelling (`wait-ready`, `do-tap-id`): // agents and humans type it constantly (it reads as one command and // mirrors the typed tool names). split only the FIRST hyphen so // multi-segment sub-verbs survive (`do-tap-id` → `do` `tap-id`). // without this it fell through to the unrecognized branch as // "unknown command: wait-ready" with empty stdout. const dash = arg.indexOf('-') if (dash > 0) { const verb = arg.slice(0, dash) const sub = arg.slice(dash + 1) if (HYPHENATED_GROUPING_VERBS.has(verb) && sub.length > 0) { result.command = verb const rest = args.slice(i + 1) result.commandArgs = [sub, ...(rest[0] === '--' ? rest.slice(1) : rest)] break } } if (RNX_COMMAND_NAMES.has(arg)) { result.command = arg const rest = args.slice(i + 1) // strip leading -- separator if present result.commandArgs = rest[0] === '--' ? rest.slice(1) : rest break } // not a recognized command — leave it as command args so bin.ts can // print a focused unknown-command message. result.commandArgs = args.slice(i) break } // unrecognized flag — pass through to command result.commandArgs.push(arg) i++ } result.help = !!result.globalFlags['help'] result.version = !!result.globalFlags['version'] result.verbose = !!result.globalFlags['verbose'] return result } export function parseArgs(argv: string[]): ParsedArgs { return parseRnxArgs(argv.slice(2)) }