Address review and cut ceremony: the adapter no longer models help/version/ errors as DshInvocation members. Commander owns those under exitOverride — it prints usage or the diagnostic and one try/catch in parseDshArgs turns the thrown CommanderError into process.exit with the intended code. bin.ts drops its help/version/error cases; the union is the three real modes. Domain checks bail via command.error(print + exit 1): --prompt rejects an empty task or a stray config/--resume, empty --resume= fails loud, and --host/--port are validated. A repeated --resume or a flag captured as a value is Commander's standard behavior, left alone (a bad id fails loud downstream). dsh --help discloses web via addHelpText. Net: args.ts 185 -> 112 lines. Also fixes review nits: built-bin e2e resolves on `close`; the /resume handoff uses `dsh --resume=<id> -- <config>` so a config named `web` stays a positional; and stale prose (cordis.yml comment, app-boot module doc + duplicate JSDoc, ui/README, two feature notes, an agent-loop test name) tracks the shipped state. Removes tui-demo's now-dead plugin-include dep and vendor/loader + app-boot tsconfig references.
113 lines
5.5 KiB
TypeScript
113 lines
5.5 KiB
TypeScript
/**
|
||
* Commander adapter for the `dsh` command-line entry: the one place argv is
|
||
* parsed and routed to a mode. `bin.ts` switches on the returned discriminant
|
||
* and dynamic-imports that mode's module. Commander owns `--help`/`--version`
|
||
* and parse errors: it prints and exits at the point of failure (a domain
|
||
* failure routes through `command.error`), so this returns only a resolved mode.
|
||
* The `web` subcommand is a reserved first token dispatched to its own parser.
|
||
* @module @deepseek-ai/dsh/args
|
||
*/
|
||
|
||
import { Command, CommanderError } from 'commander'
|
||
|
||
/** The loopback host `dsh web` binds by default. */
|
||
export const LOOPBACK_HOST = '127.0.0.1'
|
||
/** The all-interfaces host `dsh web` accepts to expose the UI on the LAN. */
|
||
export const ALL_INTERFACES_HOST = '0.0.0.0'
|
||
const DEFAULT_WEB_PORT = 3080
|
||
|
||
/** Interactive TUI: the default mode. Optional positional config and `--resume <id>`. */
|
||
interface TuiInvocation {
|
||
mode: 'tui'
|
||
config?: string
|
||
resume?: string
|
||
}
|
||
|
||
/** Headless one-shot: `dsh -p "task"`. */
|
||
interface HeadlessInvocation {
|
||
mode: 'headless'
|
||
prompt: string
|
||
}
|
||
|
||
/** Browser UI: `dsh web`. Host is loopback/all-interfaces, port a 0–65535 integer, `dev` mounts the HMR driver. */
|
||
interface WebInvocation {
|
||
mode: 'web'
|
||
host: string
|
||
port: number
|
||
dev: boolean
|
||
}
|
||
|
||
/** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */
|
||
export type DshInvocation = TuiInvocation | HeadlessInvocation | WebInvocation
|
||
|
||
/** A `Command` under `exitOverride`, so {@link parseDshArgs} owns the exit, named for its usage line. */
|
||
function program(name: string, version: string): Command {
|
||
return new Command().name(name).version(version, '-V, --version', 'output the version number').exitOverride()
|
||
}
|
||
|
||
/** Parse `dsh web` arguments (everything after the `web` token). */
|
||
function parseWeb(argv: readonly string[], version: string): WebInvocation {
|
||
const web = program('dsh web', version)
|
||
.description('serve the browser UI')
|
||
.option('--host <host>', `bind host (${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST})`, LOOPBACK_HOST)
|
||
.option('--port <port>', 'listen port', String(DEFAULT_WEB_PORT))
|
||
.option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds')
|
||
web.parse(argv, { from: 'user' })
|
||
const { host, port, dev } = web.opts<{ host: string; port: string; dev?: boolean }>()
|
||
if (host !== LOOPBACK_HOST && host !== ALL_INTERFACES_HOST) {
|
||
web.error(`error: --host must be ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}`)
|
||
}
|
||
const portNumber = Number(port)
|
||
if (!/^\d+$/.test(port) || !Number.isInteger(portNumber) || portNumber > 65535) {
|
||
web.error('error: --port must be an integer in 0-65535')
|
||
}
|
||
return { mode: 'web', host, port: portNumber, dev: dev === true }
|
||
}
|
||
|
||
/** Parse the default (TUI / headless) arguments: `[config]`, `-p/--prompt`, `--resume`. */
|
||
function parseRoot(argv: readonly string[], version: string): DshInvocation {
|
||
const root = program('dsh', version)
|
||
.description('dsh: interactive TUI, headless task, and browser UI')
|
||
.argument('[config]', 'config to boot instead of the shipped default (TUI mode)')
|
||
.option('-p, --prompt <task>', 'run one headless turn for this task, print the result, and exit')
|
||
.option('--resume <id>', 'resume the persisted session with this id (TUI mode)')
|
||
// Disclose the web mode in `dsh --help`; a real `web` subcommand would
|
||
// hijack the `[config]` positional. `parseDshArgs` intercepts `web` first.
|
||
.addHelpText('after', '\nCommands:\n web serve the browser UI (run `dsh web --help`)')
|
||
root.parse(argv, { from: 'user' })
|
||
const { prompt, resume } = root.opts<{ prompt?: string; resume?: string }>()
|
||
const config = root.processedArgs[0] as string | undefined
|
||
|
||
if (prompt !== undefined) {
|
||
// A headless prompt owns the invocation; an empty task has nothing to run,
|
||
// and a config or --resume alongside it is a TUI input that must not
|
||
// silently vanish from the run.
|
||
if (prompt === '') root.error('error: --prompt needs a task')
|
||
if (config !== undefined || resume !== undefined) root.error('error: --prompt takes no config or --resume')
|
||
return { mode: 'headless', prompt }
|
||
}
|
||
// An empty `--resume=` id would silently start a fresh session downstream
|
||
// (agent-loop treats '' as no-resume), so a mistyped resume must fail loud.
|
||
if (resume === '') root.error('error: --resume needs a session id')
|
||
return { mode: 'tui', ...config !== undefined && { config }, ...resume !== undefined && { resume } }
|
||
}
|
||
|
||
/**
|
||
* Resolve the raw argv into a {@link DshInvocation}, or print and exit for
|
||
* `--help`/`--version`/a parse error. A leading `web` token dispatches to the
|
||
* web parser; everything else is the default TUI/headless grammar.
|
||
* @param argv - the arguments after the node binary and script (`process.argv.slice(2)`).
|
||
* @param version - the version string `--version` prints; read from this app's package.json.
|
||
* @returns the resolved invocation (only reached on a valid, non-help invocation).
|
||
*/
|
||
export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
|
||
try {
|
||
return argv[0] === 'web' ? parseWeb(argv.slice(1), version) : parseRoot(argv, version)
|
||
} catch (error) {
|
||
// Commander printed help/version/the error under `exitOverride`; exit with
|
||
// the code it chose (0 for help/version, 1 for a parse or domain error).
|
||
/* v8 ignore next -- Commander only throws CommanderError from parse/error under exitOverride */
|
||
return process.exit(error instanceof CommanderError ? error.exitCode : 1)
|
||
}
|
||
}
|