args.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. /**
  2. * Commander adapter for the `dsh` command-line entry: the one place argv is
  3. * parsed and routed to a mode. `bin.ts` switches on the returned discriminant
  4. * and dynamic-imports that mode's module. One program: the default (no
  5. * subcommand) is the TUI/headless surface with option-only flags;
  6. * `meta`, `upgrade`, and `web` are real subcommands; the experimental ones
  7. * (`meta`, `upgrade`) run only under the `--experimental` flag or
  8. * `DSH_EXPERIMENTAL=1`. Commander owns
  9. * `--help`/`--version` and parse
  10. * errors — it prints and exits at the point of failure (a domain failure routes through
  11. * `command.error`), so this returns only a resolved mode.
  12. * @module @deepseek-ai/dsh/args
  13. */
  14. import { Command, CommanderError } from 'commander'
  15. /**
  16. * Interactive TUI: the default mode. `--config` applies an overlay over the
  17. * shipped composition in place of the personal one, `--config-replace` boots a
  18. * file as the whole tree instead, and `--resume <id>` rehydrates a session.
  19. */
  20. interface TuiInvocation {
  21. mode: 'tui'
  22. config?: string
  23. configReplace?: string
  24. resume?: string
  25. }
  26. /**
  27. * Print the composed config tree and exit, without booting: `--dump-config`
  28. * composes the shipped base, the surface overlay, and the `--config` or
  29. * personal overlay — exactly the layers that surface would boot;
  30. * `--dump-default-config` stops at the surface overlay (the shipped tree, no
  31. * user layer).
  32. */
  33. interface DumpConfigInvocation {
  34. mode: 'dump-config'
  35. surface: 'tui' | 'web'
  36. /** Omit the `--config`/personal layer and print only the shipped composition. */
  37. defaultOnly: boolean
  38. /** The `--config` overlay to compose instead of the personal one. */
  39. config?: string
  40. }
  41. /** Headless one-shot: `dsh -p "task"`. */
  42. interface HeadlessInvocation {
  43. mode: 'headless'
  44. prompt: string
  45. }
  46. /** Interactive fresh TUI over this harness checkout; accepts no default-surface options, only the experimental gate. */
  47. interface MetaInvocation {
  48. mode: 'meta'
  49. }
  50. /**
  51. * Guided fresh-session entry: `dsh upgrade` seeds the first turn
  52. * with the `dsh-upgrade` skill. It always mints a
  53. * fresh session in the invoking directory and takes no options beyond the
  54. * experimental gate — `--resume`, `--config`, and `-p` are rejected as
  55. * mistyped, so there is nothing to carry.
  56. */
  57. interface SkillSessionInvocation {
  58. mode: 'upgrade'
  59. }
  60. /**
  61. * Browser UI: `dsh web`. `host`/`port` are present only when the flag was
  62. * passed — pass-through overrides with no CLI default and no CLI validation:
  63. * the `dsh-host-webserver` schema (`host` a loopback/all-interfaces literal,
  64. * `port` a natural ≤ 65535) is the single source of both the default (the
  65. * shipped Web overlay value stands when a flag is absent) and validity (a bad
  66. * value fails loud at boot). `port` is `Number`-coerced only because the schema
  67. * wants a number, not a string. `dev` mounts the client HMR driver;
  68. * `workspaceRoot` is the parent directory for name-created workspaces.
  69. */
  70. interface WebInvocation {
  71. mode: 'web'
  72. /** Overlay of loader patches applied over the shipped web composition. */
  73. config?: string
  74. host?: string
  75. port?: number
  76. dev: boolean
  77. workspaceRoot?: string
  78. /** Extra authorities for the /api browser-trust fence (`host` or `host:port`); LAN IP literals are derived, not listed here. */
  79. trustedHosts?: string[]
  80. }
  81. /** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */
  82. export type DshInvocation =
  83. | TuiInvocation
  84. | DumpConfigInvocation
  85. | HeadlessInvocation
  86. | MetaInvocation
  87. | SkillSessionInvocation
  88. | WebInvocation
  89. /** Raw web-subcommand options straight from Commander. */
  90. interface WebOptions {
  91. config?: string
  92. host?: string
  93. port?: string
  94. dev?: boolean
  95. workspaceRoot?: string
  96. trustedHost?: string[]
  97. dumpConfig?: boolean
  98. dumpDefaultConfig?: boolean
  99. }
  100. /**
  101. * Resolve the two dump flags for one surface, or return `undefined` when
  102. * neither was passed. Both flags together are contradictory (one includes the
  103. * user layer, the other excludes it) and fail loud through `error`.
  104. */
  105. function resolveDump(
  106. surface: 'tui' | 'web',
  107. options: { config?: string; dumpConfig?: boolean; dumpDefaultConfig?: boolean },
  108. error: (message: string) => never,
  109. ): DumpConfigInvocation | undefined {
  110. if (options.dumpConfig !== true && options.dumpDefaultConfig !== true) return undefined
  111. if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
  112. error('error: --dump-config and --dump-default-config are mutually exclusive')
  113. }
  114. const defaultOnly = options.dumpDefaultConfig === true
  115. if (defaultOnly && options.config !== undefined) {
  116. error('error: --dump-default-config prints the shipped tree and takes no --config')
  117. }
  118. return {
  119. mode: 'dump-config',
  120. surface,
  121. defaultOnly,
  122. ...options.config !== undefined && { config: options.config },
  123. }
  124. }
  125. /**
  126. * Narrow the raw `web` options into a {@link WebInvocation}. No host/port
  127. * validation: both flow to the webserver schema, which is the sole gate. `port`
  128. * is coerced to a number (the schema rejects a string) but not range-checked
  129. * here — `NaN`/out-of-range fail loud at the schema on boot.
  130. */
  131. function resolveWeb(options: WebOptions): WebInvocation {
  132. return {
  133. mode: 'web',
  134. ...options.config !== undefined && { config: options.config },
  135. ...options.host !== undefined && { host: options.host },
  136. ...options.port !== undefined && { port: Number(options.port) },
  137. dev: options.dev === true,
  138. ...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot },
  139. ...options.trustedHost !== undefined && { trustedHosts: options.trustedHost },
  140. }
  141. }
  142. /**
  143. * Resolve the raw argv into a {@link DshInvocation}, or print and exit for
  144. * `--help`/`--version`/a parse error. The default (no subcommand) is the
  145. * TUI/headless surface; `web` is a subcommand.
  146. * @param argv - the arguments after the node binary and script (`process.argv.slice(2)`).
  147. * @param version - the version string `--version` prints; read from this app's package.json.
  148. * @param experimentalEnv - whether the environment opts into experimental
  149. * subcommands (`DSH_EXPERIMENTAL=1`); the caller reads the process boundary.
  150. * @returns the resolved invocation (only reached on a valid, non-help invocation).
  151. */
  152. export function parseDshArgs(argv: readonly string[], version: string, experimentalEnv: boolean): DshInvocation {
  153. let resolved: DshInvocation | undefined
  154. const program = new Command()
  155. .name('dsh')
  156. .version(version, '-V, --version', 'output the version number')
  157. .description('dsh: DeepSeek Harness — an interactive coding agent for your terminal.\nRun `dsh` with no arguments to start a session in the current directory.')
  158. // The default surface takes no positional task, so `dsh "task"` fails
  159. // commander's arity check with no hint; these examples are where a first
  160. // reader learns the entry points and that a one-shot task rides `-p`.
  161. .addHelpText('after', `
  162. Examples:
  163. dsh start an interactive session in this directory
  164. dsh -p "run the tests" answer one task, print the result, and exit
  165. dsh --resume <id> continue a past session
  166. `)
  167. .exitOverride()
  168. // Stop parent options at a subcommand boundary so `web --config` belongs to
  169. // Web while `--config ... web` remains a leaked default-surface option.
  170. .enablePositionalOptions()
  171. // Default surface: option-only (no positional), so `web` can be a real
  172. // subcommand without a positional collision.
  173. .option('-p, --prompt <task>', 'answer this task without the interactive UI, then exit')
  174. .option('--resume <id>', 'continue a past session by id')
  175. .option('--config <path>', 'apply this overlay of loader patches instead of the personal one')
  176. .option('--config-replace <path>', 'boot this file as the entire tree, ignoring the shipped and personal configuration')
  177. .option('--dump-config', 'print the composed config tree (base + surface + --config/personal overlay) and exit')
  178. .option('--dump-default-config', 'print the shipped config tree (base + surface overlay, no user layer) and exit')
  179. .action((options: {
  180. config?: string
  181. configReplace?: string
  182. prompt?: string
  183. resume?: string
  184. dumpConfig?: boolean
  185. dumpDefaultConfig?: boolean
  186. }) => {
  187. const dump = resolveDump('tui', options, message => program.error(message))
  188. if (dump !== undefined) {
  189. // The dump prints composition; a boot-only flag alongside it would be
  190. // silently ignored, so reject the mix loud.
  191. if (options.prompt !== undefined || options.resume !== undefined || options.configReplace !== undefined) {
  192. program.error('error: --dump-config/--dump-default-config take none of -p/--prompt, --resume, or --config-replace')
  193. }
  194. resolved = dump
  195. return
  196. }
  197. if (options.prompt !== undefined) {
  198. // A headless prompt owns the invocation; an empty task has nothing to
  199. // run, and --config/--resume are TUI inputs that must not silently
  200. // vanish from a headless run.
  201. if (options.prompt === '') program.error('error: --prompt needs a task')
  202. if (options.config !== undefined || options.configReplace !== undefined || options.resume !== undefined) {
  203. program.error('error: --prompt takes no --config, --config-replace, or --resume')
  204. }
  205. resolved = { mode: 'headless', prompt: options.prompt }
  206. return
  207. }
  208. // An empty --resume= id would silently start a fresh session downstream
  209. // (agent-loop treats '' as no-resume), so a mistyped resume must fail loud.
  210. if (options.resume === '') program.error('error: --resume needs a session id')
  211. // The two config flags are mutually exclusive: one layers over the shipped
  212. // tree, the other discards it, so accepting both would silently drop one.
  213. if (options.config !== undefined && options.configReplace !== undefined) {
  214. program.error('error: --config and --config-replace are mutually exclusive')
  215. }
  216. resolved = {
  217. mode: 'tui',
  218. ...options.config !== undefined && { config: options.config },
  219. ...options.configReplace !== undefined && { configReplace: options.configReplace },
  220. ...options.resume !== undefined && { resume: options.resume },
  221. }
  222. })
  223. // Commander parses the parent (default-surface) options on either side of a
  224. // subcommand into `program.opts()`. For a subcommand that shares none of them,
  225. // a leaked config/prompt/resume option is a mistyped invocation that must fail
  226. // loud rather than silently run and drop the input.
  227. const rejectParentOptions = (command: string): void => {
  228. const parent = program.opts<{
  229. config?: string
  230. configReplace?: string
  231. prompt?: string
  232. resume?: string
  233. dumpConfig?: boolean
  234. dumpDefaultConfig?: boolean
  235. }>()
  236. if (parent.config !== undefined || parent.configReplace !== undefined
  237. || parent.prompt !== undefined || parent.resume !== undefined
  238. || parent.dumpConfig !== undefined || parent.dumpDefaultConfig !== undefined) {
  239. program.error(`error: ${command} takes none of --config, --config-replace, -p/--prompt, --resume, --dump-config, or --dump-default-config`)
  240. }
  241. }
  242. // `meta` and `upgrade` are experimental: each runs only under its own
  243. // `--experimental` flag or an environment-wide `DSH_EXPERIMENTAL=1` opt-in,
  244. // and fails loud otherwise so the gate is never silently skipped.
  245. const requireExperimental = (command: string, flag: boolean | undefined): void => {
  246. if (flag !== true && !experimentalEnv) {
  247. program.error(`error: ${command} is experimental; pass --experimental or set DSH_EXPERIMENTAL=1`)
  248. }
  249. }
  250. // Registration order is the rendered help order, so daily use comes first
  251. // and the harness-development surfaces (`web --dev`, `meta`)
  252. // come last. `upgrade` is a guided fresh-session entry: beyond the
  253. // experimental gate it takes no options and always mints a fresh session,
  254. // so nothing is left to carry.
  255. program
  256. .command('upgrade')
  257. .description('update this dsh installation to the latest version (experimental)')
  258. .option('--experimental', 'acknowledge this subcommand is experimental')
  259. .action((options: { experimental?: boolean }) => {
  260. rejectParentOptions('upgrade')
  261. requireExperimental('upgrade', options.experimental)
  262. resolved = { mode: 'upgrade' }
  263. })
  264. // Host and port name no default: the CLI passes neither through when the flag
  265. // is absent, so the shipped Web overlay value stands and restating it here
  266. // would duplicate a fact this file does not own.
  267. const web = program.command('web').description('serve the browser UI on the configured host and port')
  268. web
  269. .option('--config <path>', 'apply this overlay of loader patches over the shipped configuration')
  270. .option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine')
  271. .option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
  272. .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)')
  273. .option('--workspace-root <path>', 'parent directory for workspaces created from the browser UI')
  274. .option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
  275. .option('--dump-config', 'print the composed config tree (base + web + --config/personal overlay) and exit')
  276. .option('--dump-default-config', 'print the shipped config tree (base + web overlay, no user layer) and exit')
  277. .action((options: WebOptions) => {
  278. rejectParentOptions('web')
  279. const dump = resolveDump('web', options, message => program.error(message))
  280. if (dump !== undefined) {
  281. resolved = dump
  282. return
  283. }
  284. resolved = resolveWeb(options)
  285. })
  286. program
  287. .command('meta')
  288. .description('work on the dsh source that runs this command, from any directory (experimental)')
  289. .option('--experimental', 'acknowledge this subcommand is experimental')
  290. .action((options: { experimental?: boolean }) => {
  291. rejectParentOptions('meta')
  292. requireExperimental('meta', options.experimental)
  293. resolved = { mode: 'meta' }
  294. })
  295. try {
  296. program.parse(argv, { from: 'user' })
  297. } catch (error) {
  298. // Commander printed help/version/the error under `exitOverride`; exit with
  299. // the code it chose (0 for help/version, 1 for a parse or domain error).
  300. /* v8 ignore next -- Commander only throws CommanderError from parse/error under exitOverride */
  301. return process.exit(error instanceof CommanderError ? error.exitCode : 1)
  302. }
  303. /* v8 ignore next -- the default action or a subcommand action always resolves, or parse throws above */
  304. if (resolved === undefined) throw new Error('dsh: no invocation resolved')
  305. return resolved
  306. }