Files
deepseek-harness/apps/cli/src/args.ts
T
Turtle 870fb1cafa refactor(cli): make dsh the sole terminal front door, drop RESUME_SESSION_ID
Remove the redundant dsh-tui-demo bin and the RESUME_SESSION_ID environment
variable, leaving dsh as the one terminal entrypoint.

The dsh-tui-demo package was a plugin (the TUI app bundle mounted by dsh's
config) plus a bin that booted a leaf cordis.yml — the same job `dsh [config]`
does. The bin, its ./bin export, its built-bin.e2e.ts, the tsdown bin entry,
and the now-unused dsh-app-boot dependency are removed; the package keeps its
plugin and invariant. demo:cordis, demo:code-mode, and the tui-agent and
cordis-agent keyless PTY smokes now launch through apps/cli/src/bin.ts with the
config as the positional argument. cli-demo/acp-demo/jsonrpc-demo keep their
bins (distinct surfaces).

RESUME_SESSION_ID was the only bridge from --resume into the shipped config;
--resume now provides the id on the boot context via ctx.provide(
RESUME_SESSION_ID_KEY, id), and the four configs read it as a bare identifier
through a quoted typeof-guarded !!js expression. The TUI resumeCommand fixtures
and docs move to `dsh --resume {session}`.

Agent Note and its Chinese pair updated; config-catalog regenerated.
2026-07-25 12:43:59 +08:00

162 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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; each mode module then consumes the
* already-parsed values instead of re-reading argv. Output is suppressed and
* `exitOverride` is set so Commander never writes or exits on its own — every
* outcome (including `--help`/`--version` and parse errors) is returned to the
* caller as data. The `web` subcommand is a reserved first token dispatched to
* its own parser, so root flags and `web` flags never share a grammar.
* @module @deepseek-ai/dsh/args
*/
import { Command, CommanderError, InvalidArgumentError, Option } 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 constrained to {@link LOOPBACK_HOST}/{@link ALL_INTERFACES_HOST};
* port already coerced and range-checked; `dev` mounts the client HMR driver and bundle watch.
*/
interface WebInvocation {
mode: 'web'
host: string
port: number
dev: boolean
}
/** `--help` or `--version` requested: `bin.ts` prints `text` to stdout and exits 0. */
interface InfoInvocation {
mode: 'help' | 'version'
text: string
}
/** A parse error (unknown option, missing/invalid argument): `bin.ts` prints `message` to stderr and exits 1. */
interface ErrorInvocation {
mode: 'error'
message: string
}
/** The resolved `dsh` invocation: exactly one mode, all values parsed and validated. */
export type DshInvocation =
| TuiInvocation
| HeadlessInvocation
| WebInvocation
| InfoInvocation
| ErrorInvocation
/** Coerce `--port` to an integer in 065535; a bad value fails loud as a parse error. */
function parsePort(raw: string): number {
const port = Number(raw)
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new InvalidArgumentError(`invalid --port ${raw}`)
}
return port
}
/**
* A configured `Command` under `exitOverride` with output captured into `sink`,
* so `--help`, `--version`, and parse errors surface as thrown `CommanderError`s
* (see {@link settle}) rather than writing to a stream or exiting.
*/
function program(name: string, version: string, sink: string[]): Command {
return new Command()
.name(name)
.version(version, '-V, --version', 'output the version number')
.exitOverride()
.configureOutput({
writeOut: chunk => void sink.push(chunk),
writeErr: chunk => void sink.push(chunk),
})
}
/**
* Run `command.parse` and map its thrown `CommanderError` to an info/error
* invocation, or `undefined` when the parse succeeded (the caller then reads the
* parsed options).
*/
function settle(command: Command, argv: readonly string[], sink: string[]): InfoInvocation | ErrorInvocation | undefined {
try {
command.parse(argv, { from: 'user' })
return undefined
} catch (error) {
/* v8 ignore next -- Commander only throws CommanderError from parse under exitOverride */
if (!(error instanceof CommanderError)) throw error
if (error.code === 'commander.helpDisplayed') return { mode: 'help', text: sink.join('') }
if (error.code === 'commander.version') return { mode: 'version', text: sink.join('') }
return { mode: 'error', message: error.message }
}
}
/** Parse `dsh web` arguments (everything after the `web` token). */
function parseWeb(argv: readonly string[], version: string): DshInvocation {
const sink: string[] = []
const web = program('dsh web', version, sink)
.description('serve the browser UI')
.addOption(new Option('--host <host>', 'bind host').choices([LOOPBACK_HOST, ALL_INTERFACES_HOST]).default(LOOPBACK_HOST))
.addOption(new Option('--port <port>', 'listen port').default(DEFAULT_WEB_PORT).argParser(parsePort))
.option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds')
const settled = settle(web, argv, sink)
if (settled !== undefined) return settled
const { host, port, dev } = web.opts<{ host: string; port: number; dev?: boolean }>()
return { mode: 'web', host, port, dev: dev ?? false }
}
/** Parse the default (TUI / headless) arguments: `[config]`, `-p/--prompt`, `--resume`. */
function parseRoot(argv: readonly string[], version: string): DshInvocation {
const sink: string[] = []
const root = program('dsh', version, sink)
.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)')
const settled = settle(root, argv, sink)
if (settled !== undefined) return settled
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.
if (prompt === '') return { mode: 'error', message: "error: option '-p, --prompt <task>' must not be empty" }
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 === '') return { mode: 'error', message: "error: option '--resume <id>' must not be empty" }
return {
mode: 'tui',
...config !== undefined ? { config } : {},
...resume !== undefined ? { resume } : {},
}
}
/**
* Resolve the raw argv into a single {@link DshInvocation}. Never writes to a
* stream and never exits; `--help`/`--version` and every parse error come back
* as data for `bin.ts` to act on. 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, discriminated by `mode`.
*/
export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
return argv[0] === 'web' ? parseWeb(argv.slice(1), version) : parseRoot(argv, version)
}