/** * 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. One program: the default (no * subcommand) is the TUI/headless surface with option-only flags; `meta` and * `web` are real subcommands. 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 */ import { Command, CommanderError } from 'commander' /** * Interactive TUI: the default mode. `--config` applies an overlay over the * shipped composition in place of the personal one, `--config-replace` boots a * file as the whole tree instead, and `--resume ` rehydrates a session. */ interface TuiInvocation { mode: 'tui' config?: string configReplace?: string resume?: string } /** Headless one-shot: `dsh -p "task"`. */ interface HeadlessInvocation { mode: 'headless' prompt: string } /** * Interactive TUI over this harness checkout: `dsh meta`. Identical to * {@link TuiInvocation} except the workspace is the launcher's own source tree * rather than the invoking directory. No `--config`: booting a foreign tree * against the harness workspace is the `--config` case, not this one. */ interface MetaInvocation { mode: 'meta' resume?: string } /** * Guided fresh-session entries: `dsh migrate` seeds the first turn with the * `dsh-migrate` skill, `dsh upgrade` with `dsh-upgrade`. Each always mints a * fresh session in the invoking directory and takes no options — `--resume`, * `--config`, and `-p` are rejected as mistyped, so there is nothing to carry. */ interface SkillSessionInvocation { mode: 'migrate' | 'upgrade' } /** * Browser UI: `dsh web`. `host`/`port` are present only when the flag was * passed — pass-through overrides with no CLI default and no CLI validation: * the `dsh-host-webserver` schema (`host` a loopback/all-interfaces literal, * `port` a natural ≤ 65535) is the single source of both the default (the * shipped `cordis.yml` value stands when a flag is absent) and validity (a bad * value fails loud at boot). `port` is `Number`-coerced only because the schema * wants a number, not a string. `dev` mounts the client HMR driver; * `workspaceRoot` is the parent directory for name-created workspaces. */ interface WebInvocation { mode: 'web' /** Overlay of loader patches applied over the shipped web composition. */ config?: string host?: string port?: number dev: boolean workspaceRoot?: string /** Extra authorities for the /api browser-trust fence (`host` or `host:port`); LAN IP literals are derived, not listed here. */ trustedHosts?: string[] } /** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */ export type DshInvocation = | TuiInvocation | HeadlessInvocation | MetaInvocation | SkillSessionInvocation | WebInvocation /** Raw web-subcommand options straight from Commander. */ interface WebOptions { config?: string host?: string port?: string dev?: boolean workspaceRoot?: string trustedHost?: string[] } /** * Narrow the raw `web` options into a {@link WebInvocation}. No host/port * validation: both flow to the webserver schema, which is the sole gate. `port` * is coerced to a number (the schema rejects a string) but not range-checked * here — `NaN`/out-of-range fail loud at the schema on boot. */ function resolveWeb(options: WebOptions): WebInvocation { return { mode: 'web', ...options.config !== undefined && { config: options.config }, ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, dev: options.dev === true, ...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot }, ...options.trustedHost !== undefined && { trustedHosts: options.trustedHost }, } } /** * Resolve the raw argv into a {@link DshInvocation}, or print and exit for * `--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: DeepSeek Harness — an interactive coding agent for your terminal.\nRun `dsh` with no arguments to start a session in the current directory.') // The default surface takes no positional task, so `dsh "task"` fails // commander's arity check with no hint; these examples are where a first // reader learns the entry points and that a one-shot task rides `-p`. .addHelpText('after', ` Examples: dsh start an interactive session in this directory dsh -p "run the tests" answer one task, print the result, and exit dsh --resume continue a past session `) .exitOverride() // Default surface: option-only (no positional), so `web` can be a real // subcommand without a positional collision. .option('-p, --prompt ', 'answer this task without the interactive UI, then exit') .option('--resume ', 'continue a past session by id') .option('--config ', 'apply this overlay of loader patches instead of the personal one') .option('--config-replace ', 'boot this file as the entire tree, ignoring the shipped and personal configuration') .action((options: { config?: string; configReplace?: 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.configReplace !== undefined || options.resume !== undefined) { program.error('error: --prompt takes no --config, --config-replace, 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') // The two config flags are mutually exclusive: one layers over the shipped // tree, the other discards it, so accepting both would silently drop one. if (options.config !== undefined && options.configReplace !== undefined) { program.error('error: --config and --config-replace are mutually exclusive') } resolved = { mode: 'tui', ...options.config !== undefined && { config: options.config }, ...options.configReplace !== undefined && { configReplace: options.configReplace }, ...options.resume !== undefined && { resume: options.resume }, } }) // Commander parses the parent (default-surface) options on either side of a // subcommand into `program.opts()`. For a subcommand that shares none of them, // a leaked `--config`/`-p`/`--resume` is a mistyped invocation that must fail // loud rather than silently run and drop the input. const rejectParentOptions = (command: string): void => { const parent = program.opts<{ config?: string; configReplace?: string; prompt?: string; resume?: string }>() if (parent.config !== undefined || parent.configReplace !== undefined || parent.prompt !== undefined || parent.resume !== undefined) { program.error(`error: ${command} takes none of --config, -p/--prompt, or --resume`) } } // Registration order is the rendered help order, so daily use comes first // and the harness-development surfaces (`web --dev`, `meta`) come last. // `migrate` and `upgrade` are guided fresh-session entries: they take no // options and always mint a fresh session, so nothing is left to carry. Each // description names the outcome, not the skill the first turn invokes. const guided = { migrate: 'import settings from another coding agent (Claude Code, Codex, opencode)', upgrade: 'update this dsh installation to the latest version', } as const for (const mode of ['migrate', 'upgrade'] as const) { program .command(mode) .description(guided[mode]) .action(() => { rejectParentOptions(mode) resolved = { mode } }) } // Host and port name no default: the CLI passes neither through when the flag // is absent, so the shipped `cordis.yml` value stands and restating it here // would duplicate a fact this file does not own. const web = program.command('web').description('serve the browser UI on the configured host and port') web .option('--config ', 'apply this overlay of loader patches over the shipped configuration') .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') .option('--port ', 'listen port; pass 0 to let the OS pick a free one') .option('--dev', 'developer mode: hot-reload the browser client') .option('--workspace-root ', 'parent directory for workspaces created from the browser UI') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .action((options: WebOptions) => { rejectParentOptions('web') resolved = resolveWeb(options) }) program .command('meta') .description('work on the dsh source that runs this command, from any directory') .action(() => { rejectParentOptions('meta') resolved = { mode: 'meta' } }) try { 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 }