refactor(cli): unify the arg grammar — one program, --config flag, real web subcommand

Drop the bare `dsh <config>` positional in favor of a `--config <path>` flag.
Without a root positional, `web` can be a real Commander subcommand in one
program instead of the reserved-first-token dispatch to a second parser, so
`dsh --help` lists every mode natively (no hand-pasted command text) and the
second parser + reserved-token machinery are gone.

Grammar:
  dsh                       TUI (shipped tree + ~/.dsh overlay)
  dsh --config <path>       TUI, alternate tree (demos/tests only)
  dsh --resume <id>         TUI, resume a session
  dsh -p "task"             headless one-shot
  dsh web [--host --port --dev]

`dsh` is the product front door with no positional; `--config` exists only so
demo:cordis, demo:code-mode, and the keyless PTY smokes can point the shipped
bin at an example tree. Those three sites and the /resume re-exec argv move to
`--config <path>`. The `-p` + `--config`/`--resume` mode-mixing guard and the
cordis.yml-owns-host/port-default fix are preserved.

Agent Note + Chinese pair, README, tui.ts docs updated. All 13 PTY smokes
(including code-mode via --config and the exec-replace resume handoff) green.
This commit is contained in:
Turtle
2026-07-25 15:47:55 +08:00
parent 91d86f9b21
commit fca2dda37d
13 changed files with 110 additions and 93 deletions

View File

@@ -1,10 +1,11 @@
/**
* 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.
* and dynamic-imports that mode's module. One program: the default (no
* subcommand) is the TUI/headless surface with option-only flags; `web` is a
* real subcommand. 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.
* @module @deepseek-ai/dsh/args
*/
@@ -15,7 +16,7 @@ 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'
/** Interactive TUI: the default mode. Optional positional config and `--resume <id>`. */
/** Interactive TUI: the default mode. `--config` swaps the tree; `--resume <id>` rehydrates a session. */
interface TuiInvocation {
mode: 'tui'
config?: string
@@ -44,83 +45,91 @@ interface WebInvocation {
/** 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()
/** Raw web-subcommand options before validation. */
interface WebOptions {
host?: string
port?: string
dev?: boolean
}
/** Parse `dsh web` arguments (everything after the `web` token). */
function parseWeb(argv: readonly string[], version: string): WebInvocation {
// No Commander `default`: an absent flag leaves the option undefined so the
// shipped cordis.yml value stands (the single source of the host/port default).
const web = program('dsh web', version)
.description('serve the browser UI (host/port default to the shipped config)')
.option('--host <host>', `bind host (${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST})`)
.option('--port <port>', 'listen port (0 requests an OS-assigned 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 !== undefined && host !== LOOPBACK_HOST && host !== ALL_INTERFACES_HOST) {
web.error(`error: --host must be ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}`)
/** Validate and narrow the raw `web` options; a bad value fails loud via `command.error`. */
function resolveWeb(command: Command, options: WebOptions): WebInvocation {
if (options.host !== undefined && options.host !== LOOPBACK_HOST && options.host !== ALL_INTERFACES_HOST) {
command.error(`error: --host must be ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}`)
}
let portNumber: number | undefined
if (port !== undefined) {
portNumber = Number(port)
if (!/^\d+$/.test(port) || !Number.isInteger(portNumber) || portNumber > 65535) {
web.error('error: --port must be an integer in 0-65535')
let port: number | undefined
if (options.port !== undefined) {
port = Number(options.port)
if (!/^\d+$/.test(options.port) || !Number.isInteger(port) || port > 65535) {
command.error('error: --port must be an integer in 0-65535')
}
}
return {
mode: 'web',
...host !== undefined && { host },
...portNumber !== undefined && { port: portNumber },
dev: dev === true,
...options.host !== undefined && { host: options.host },
...port !== undefined && { port },
dev: options.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.
* `--help`/`--version`/a parse error. The default (no subcommand) is the
* TUI/headless surface; `web` is a subcommand.
* @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 {
let resolved: DshInvocation | undefined
const program = new Command()
.name('dsh')
.version(version, '-V, --version', 'output the version number')
.description('dsh: interactive TUI (default), headless task, and browser UI')
.exitOverride()
// Default surface: option-only (no positional), so `web` can be a real
// subcommand without a positional collision.
.option('--config <path>', 'boot an alternate cordis.yml instead of the shipped tree (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)')
.action((options: { config?: string; prompt?: string; resume?: string }) => {
if (options.prompt !== undefined) {
// A headless prompt owns the invocation; an empty task has nothing to
// run, and --config/--resume are TUI inputs that must not silently
// vanish from a headless run.
if (options.prompt === '') program.error('error: --prompt needs a task')
if (options.config !== undefined || options.resume !== undefined) {
program.error('error: --prompt takes no --config or --resume')
}
resolved = { mode: 'headless', prompt: options.prompt }
return
}
// 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 (options.resume === '') program.error('error: --resume needs a session id')
resolved = {
mode: 'tui',
...options.config !== undefined && { config: options.config },
...options.resume !== undefined && { resume: options.resume },
}
})
const web = program.command('web').description('serve the browser UI (host/port default to the shipped config)')
web
.option('--host <host>', `bind host (${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST})`)
.option('--port <port>', 'listen port (0 requests an OS-assigned port)')
.option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds')
.action((options: WebOptions) => { resolved = resolveWeb(web, options) })
try {
return argv[0] === 'web' ? parseWeb(argv.slice(1), version) : parseRoot(argv, version)
program.parse(argv, { from: 'user' })
} 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)
}
/* v8 ignore next -- the default action or a subcommand action always resolves, or parse throws above */
if (resolved === undefined) throw new Error('dsh: no invocation resolved')
return resolved
}