2026-07-24 19:43:59 +08:00
/**
* 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
2026-07-25 15:47:55 +08:00
* and dynamic-imports that mode's module. One program: the default (no
2026-07-31 20:16:19 +08:00
* subcommand) is the TUI/headless surface with option-only flags;
2026-07-31 23:00:11 +08:00
* `meta`, `upgrade`, and `web` are real subcommands; the experimental ones
* (`meta`, `upgrade`) run only under the `--experimental` flag or
* `DSH_EXPERIMENTAL=1`. Commander owns
2026-07-31 20:16:19 +08:00
* `--help`/`--version` and parse
2026-07-29 15:28:38 +08:00
* errors — it prints and exits at the point of failure (a domain failure routes through
2026-07-25 15:47:55 +08:00
* `command.error`), so this returns only a resolved mode.
2026-07-24 19:43:59 +08:00
* @module @deepseek-ai/dsh/args
*/
2026-07-25 14:15:25 +08:00
import { Command , CommanderError } from 'commander'
2026-07-24 19:43:59 +08:00
2026-07-29 15:28:38 +08:00
/**
* 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 <id>` rehydrates a session.
*/
2026-07-24 19:43:59 +08:00
interface TuiInvocation {
mode : 'tui'
config? : string
2026-07-29 15:28:38 +08:00
configReplace? : string
2026-07-24 19:43:59 +08:00
resume? : string
}
2026-07-31 02:00:01 +08:00
/**
* Print the composed config tree and exit, without booting: `--dump-config`
* composes the shipped base, the surface overlay, and the `--config` or
* personal overlay — exactly the layers that surface would boot;
* `--dump-default-config` stops at the surface overlay (the shipped tree, no
* user layer).
*/
interface DumpConfigInvocation {
mode : 'dump-config'
surface : 'tui' | 'web'
/** Omit the `--config`/personal layer and print only the shipped composition. */
defaultOnly : boolean
/** The `--config` overlay to compose instead of the personal one. */
config? : string
}
2026-07-24 19:43:59 +08:00
/** Headless one-shot: `dsh -p "task"`. */
interface HeadlessInvocation {
mode : 'headless'
prompt : string
}
2026-07-31 23:00:11 +08:00
/** Interactive fresh TUI over this harness checkout; accepts no default-surface options, only the experimental gate. */
2026-07-29 15:28:38 +08:00
interface MetaInvocation {
mode : 'meta'
}
/**
2026-07-31 23:00:11 +08:00
* Guided fresh-session entry: `dsh upgrade` seeds the first turn
2026-07-31 20:16:19 +08:00
* with the `dsh-upgrade` skill. It always mints a
2026-07-31 23:00:11 +08:00
* fresh session in the invoking directory and takes no options beyond the
* experimental gate — `--resume`, `--config`, and `-p` are rejected as
* mistyped, so there is nothing to carry.
2026-07-29 15:28:38 +08:00
*/
interface SkillSessionInvocation {
2026-07-29 21:41:51 +08:00
mode : 'upgrade'
2026-07-29 15:28:38 +08:00
}
2026-07-25 15:03:17 +08:00
/**
* Browser UI: `dsh web`. `host`/`port` are present only when the flag was
2026-07-25 16:19:02 +08:00
* 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
2026-07-29 21:59:44 +08:00
* shipped Web overlay value stands when a flag is absent) and validity (a bad
2026-07-25 16:19:02 +08:00
* value fails loud at boot). `port` is `Number`-coerced only because the schema
2026-07-25 18:05:39 +08:00
* wants a number, not a string. `dev` mounts the client HMR driver;
* `workspaceRoot` is the parent directory for name-created workspaces.
2026-07-25 15:03:17 +08:00
*/
2026-07-24 19:43:59 +08:00
interface WebInvocation {
mode : 'web'
2026-07-29 15:28:38 +08:00
/** Overlay of loader patches applied over the shipped web composition. */
config? : string
2026-07-25 15:03:17 +08:00
host? : string
port? : number
2026-07-25 12:02:28 +08:00
dev : boolean
2026-07-25 18:05:39 +08:00
workspaceRoot? : string
2026-07-28 15:40:02 +08:00
/** Extra authorities for the /api browser-trust fence (`host` or `host:port`); LAN IP literals are derived, not listed here. */
trustedHosts? : string [ ]
2026-07-24 19:43:59 +08:00
}
2026-07-25 14:15:25 +08:00
/** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */
2026-07-29 15:28:38 +08:00
export type DshInvocation =
| TuiInvocation
2026-07-31 02:00:01 +08:00
| DumpConfigInvocation
2026-07-29 15:28:38 +08:00
| HeadlessInvocation
| MetaInvocation
| SkillSessionInvocation
| WebInvocation
2026-07-24 19:43:59 +08:00
2026-07-25 16:19:02 +08:00
/** Raw web-subcommand options straight from Commander. */
2026-07-25 15:47:55 +08:00
interface WebOptions {
2026-07-29 15:28:38 +08:00
config? : string
2026-07-25 15:47:55 +08:00
host? : string
port? : string
dev? : boolean
2026-07-25 18:05:39 +08:00
workspaceRoot? : string
2026-07-28 15:40:02 +08:00
trustedHost? : string [ ]
2026-07-31 02:00:01 +08:00
dumpConfig? : boolean
dumpDefaultConfig? : boolean
}
/**
* Resolve the two dump flags for one surface, or return `undefined` when
* neither was passed. Both flags together are contradictory (one includes the
* user layer, the other excludes it) and fail loud through `error`.
*/
function resolveDump (
surface : 'tui' | 'web' ,
options : { config? : string ; dumpConfig? : boolean ; dumpDefaultConfig? : boolean } ,
error : ( message : string ) = > never ,
) : DumpConfigInvocation | undefined {
if ( options . dumpConfig !== true && options . dumpDefaultConfig !== true ) return undefined
if ( options . dumpConfig === true && options . dumpDefaultConfig === true ) {
error ( 'error: --dump-config and --dump-default-config are mutually exclusive' )
}
const defaultOnly = options . dumpDefaultConfig === true
if ( defaultOnly && options . config !== undefined ) {
error ( 'error: --dump-default-config prints the shipped tree and takes no --config' )
}
return {
mode : 'dump-config' ,
surface ,
defaultOnly ,
. . . options . config !== undefined && { config : options.config } ,
}
2026-07-24 20:01:38 +08:00
}
2026-07-25 16:19:02 +08:00
/**
* 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 {
2026-07-25 15:03:17 +08:00
return {
mode : 'web' ,
2026-07-29 15:28:38 +08:00
. . . options . config !== undefined && { config : options.config } ,
2026-07-25 15:47:55 +08:00
. . . options . host !== undefined && { host : options.host } ,
2026-07-25 16:19:02 +08:00
. . . options . port !== undefined && { port : Number ( options . port ) } ,
2026-07-25 15:47:55 +08:00
dev : options.dev === true ,
2026-07-25 18:05:39 +08:00
. . . options . workspaceRoot !== undefined && { workspaceRoot : options.workspaceRoot } ,
2026-07-28 15:40:02 +08:00
. . . options . trustedHost !== undefined && { trustedHosts : options.trustedHost } ,
2026-07-25 14:15:25 +08:00
}
2026-07-24 20:01:38 +08:00
}
2026-07-24 19:43:59 +08:00
2026-07-24 20:01:38 +08:00
/**
2026-07-25 14:15:25 +08:00
* Resolve the raw argv into a {@link DshInvocation}, or print and exit for
2026-07-25 15:47:55 +08:00
* `--help`/`--version`/a parse error. The default (no subcommand) is the
* TUI/headless surface; `web` is a subcommand.
2026-07-24 20:01:38 +08:00
* @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.
2026-07-31 23:00:11 +08:00
* @param experimentalEnv - whether the environment opts into experimental
* subcommands (`DSH_EXPERIMENTAL=1`); the caller reads the process boundary.
2026-07-25 14:15:25 +08:00
* @returns the resolved invocation (only reached on a valid, non-help invocation).
2026-07-24 20:01:38 +08:00
*/
2026-07-31 23:00:11 +08:00
export function parseDshArgs ( argv : readonly string [ ] , version : string , experimentalEnv : boolean ) : DshInvocation {
2026-07-25 15:47:55 +08:00
let resolved : DshInvocation | undefined
const program = new Command ( )
. name ( 'dsh' )
. version ( version , '-V, --version' , 'output the version number' )
2026-07-29 15:28:38 +08:00
. 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 <id> continue a past session
` )
2026-07-25 15:47:55 +08:00
. exitOverride ( )
2026-07-30 14:56:39 +08:00
// Stop parent options at a subcommand boundary so `web --config` belongs to
// Web while `--config ... web` remains a leaked default-surface option.
. enablePositionalOptions ( )
2026-07-25 15:47:55 +08:00
// Default surface: option-only (no positional), so `web` can be a real
// subcommand without a positional collision.
2026-07-29 15:28:38 +08:00
. option ( '-p, --prompt <task>' , 'answer this task without the interactive UI, then exit' )
. option ( '--resume <id>' , 'continue a past session by id' )
. option ( '--config <path>' , 'apply this overlay of loader patches instead of the personal one' )
. option ( '--config-replace <path>' , 'boot this file as the entire tree, ignoring the shipped and personal configuration' )
2026-07-31 02:00:01 +08:00
. option ( '--dump-config' , 'print the composed config tree (base + surface + --config/personal overlay) and exit' )
. option ( '--dump-default-config' , 'print the shipped config tree (base + surface overlay, no user layer) and exit' )
. action ( ( options : {
config? : string
configReplace? : string
prompt? : string
resume? : string
dumpConfig? : boolean
dumpDefaultConfig? : boolean
} ) = > {
const dump = resolveDump ( 'tui' , options , message = > program . error ( message ) )
if ( dump !== undefined ) {
// The dump prints composition; a boot-only flag alongside it would be
// silently ignored, so reject the mix loud.
if ( options . prompt !== undefined || options . resume !== undefined || options . configReplace !== undefined ) {
program . error ( 'error: --dump-config/--dump-default-config take none of -p/--prompt, --resume, or --config-replace' )
}
resolved = dump
return
}
2026-07-25 15:47:55 +08:00
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' )
2026-07-29 15:28:38 +08:00
if ( options . config !== undefined || options . configReplace !== undefined || options . resume !== undefined ) {
program . error ( 'error: --prompt takes no --config, --config-replace, or --resume' )
2026-07-25 15:47:55 +08:00
}
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' )
2026-07-29 15:28:38 +08:00
// 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' )
}
2026-07-25 15:47:55 +08:00
resolved = {
mode : 'tui' ,
. . . options . config !== undefined && { config : options.config } ,
2026-07-29 15:28:38 +08:00
. . . options . configReplace !== undefined && { configReplace : options.configReplace } ,
2026-07-25 15:47:55 +08:00
. . . options . resume !== undefined && { resume : options.resume } ,
}
} )
2026-07-29 15:28:38 +08:00
// Commander parses the parent (default-surface) options on either side of a
// subcommand into `program.opts()`. For a subcommand that shares none of them,
2026-07-29 21:39:38 +08:00
// a leaked config/prompt/resume option is a mistyped invocation that must fail
2026-07-29 15:28:38 +08:00
// loud rather than silently run and drop the input.
const rejectParentOptions = ( command : string ) : void = > {
2026-07-31 02:00:01 +08:00
const parent = program . opts < {
config? : string
configReplace? : string
prompt? : string
resume? : string
dumpConfig? : boolean
dumpDefaultConfig? : boolean
} > ( )
2026-07-29 15:28:38 +08:00
if ( parent . config !== undefined || parent . configReplace !== undefined
2026-07-31 02:00:01 +08:00
|| parent . prompt !== undefined || parent . resume !== undefined
|| parent . dumpConfig !== undefined || parent . dumpDefaultConfig !== undefined ) {
program . error ( ` error: ${ command } takes none of --config, --config-replace, -p/--prompt, --resume, --dump-config, or --dump-default-config ` )
2026-07-29 15:28:38 +08:00
}
}
2026-07-31 23:00:11 +08:00
// `meta` and `upgrade` are experimental: each runs only under its own
// `--experimental` flag or an environment-wide `DSH_EXPERIMENTAL=1` opt-in,
// and fails loud otherwise so the gate is never silently skipped.
const requireExperimental = ( command : string , flag : boolean | undefined ) : void = > {
if ( flag !== true && ! experimentalEnv ) {
program . error ( ` error: ${ command } is experimental; pass --experimental or set DSH_EXPERIMENTAL=1 ` )
}
}
2026-07-29 15:28:38 +08:00
// Registration order is the rendered help order, so daily use comes first
2026-07-31 23:00:11 +08:00
// and the harness-development surfaces (`web --dev`, `meta`)
// come last. `upgrade` is a guided fresh-session entry: beyond the
// experimental gate it takes no options and always mints a fresh session,
// so nothing is left to carry.
2026-07-29 21:41:51 +08:00
program
2026-07-31 23:00:11 +08:00
. command ( 'upgrade' )
. description ( 'update this dsh installation to the latest version (experimental)' )
. option ( '--experimental' , 'acknowledge this subcommand is experimental' )
. action ( ( options : { experimental? : boolean } ) = > {
rejectParentOptions ( 'upgrade' )
requireExperimental ( 'upgrade' , options . experimental )
2026-07-29 21:41:51 +08:00
resolved = { mode : 'upgrade' }
} )
2026-07-29 15:28:38 +08:00
// Host and port name no default: the CLI passes neither through when the flag
2026-07-29 21:59:44 +08:00
// is absent, so the shipped Web overlay value stands and restating it here
2026-07-29 15:28:38 +08:00
// 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' )
2026-07-25 15:47:55 +08:00
web
2026-07-29 15:28:38 +08:00
. option ( '--config <path>' , 'apply this overlay of loader patches over the shipped configuration' )
. option ( '--host <host>' , 'bind host; pass 0.0.0.0 to reach it from another machine' )
. option ( '--port <port>' , 'listen port; pass 0 to let the OS pick a free one' )
2026-07-29 11:22:48 +08:00
. option ( '--dev' , 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)' )
2026-07-29 15:28:38 +08:00
. option ( '--workspace-root <path>' , 'parent directory for workspaces created from the browser UI' )
2026-07-28 15:40:02 +08:00
. option ( '--trusted-host <authority...>' , 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)' )
2026-07-31 02:00:01 +08:00
. option ( '--dump-config' , 'print the composed config tree (base + web + --config/personal overlay) and exit' )
. option ( '--dump-default-config' , 'print the shipped config tree (base + web overlay, no user layer) and exit' )
2026-07-25 17:24:39 +08:00
. action ( ( options : WebOptions ) = > {
2026-07-29 15:28:38 +08:00
rejectParentOptions ( 'web' )
2026-07-31 02:00:01 +08:00
const dump = resolveDump ( 'web' , options , message = > program . error ( message ) )
if ( dump !== undefined ) {
resolved = dump
return
2026-07-25 17:24:39 +08:00
}
resolved = resolveWeb ( options )
} )
2026-07-25 15:47:55 +08:00
2026-07-29 15:28:38 +08:00
program
2026-07-31 23:00:11 +08:00
. command ( 'meta' )
. description ( 'work on the dsh source that runs this command, from any directory (experimental)' )
. option ( '--experimental' , 'acknowledge this subcommand is experimental' )
. action ( ( options : { experimental? : boolean } ) = > {
rejectParentOptions ( 'meta' )
requireExperimental ( 'meta' , options . experimental )
2026-07-29 17:34:50 +08:00
resolved = { mode : 'meta' }
2026-07-25 17:24:39 +08:00
} )
2026-07-25 15:47:55 +08:00
2026-07-25 14:15:25 +08:00
try {
2026-07-25 15:47:55 +08:00
program . parse ( argv , { from : 'user' } )
2026-07-25 14:15:25 +08:00
} 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 )
}
2026-07-25 15:47:55 +08:00
/* 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
2026-07-24 19:43:59 +08:00
}