args.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  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; `meta` and
  6. * `web` are real subcommands. Commander owns `--help`/`--version` and parse
  7. * errors — it prints and exits at the point of failure (a domain failure routes through
  8. * `command.error`), so this returns only a resolved mode.
  9. * @module @deepseek-ai/dsh/args
  10. */
  11. import { Command, CommanderError } from 'commander'
  12. /**
  13. * Interactive TUI: the default mode. `--config` applies an overlay over the
  14. * shipped composition in place of the personal one, `--config-replace` boots a
  15. * file as the whole tree instead, and `--resume <id>` rehydrates a session.
  16. */
  17. interface TuiInvocation {
  18. mode: 'tui'
  19. config?: string
  20. configReplace?: string
  21. resume?: string
  22. }
  23. /** Headless one-shot: `dsh -p "task"`. */
  24. interface HeadlessInvocation {
  25. mode: 'headless'
  26. prompt: string
  27. }
  28. /** Interactive fresh TUI over this harness checkout; accepts no default-surface options. */
  29. interface MetaInvocation {
  30. mode: 'meta'
  31. }
  32. /**
  33. * Guided fresh-session entry: `dsh upgrade` seeds the first turn with the
  34. * `dsh-upgrade` skill. It always mints a
  35. * fresh session in the invoking directory and takes no options — `--resume`,
  36. * `--config`, and `-p` are rejected as mistyped, so there is nothing to carry.
  37. */
  38. interface SkillSessionInvocation {
  39. mode: 'upgrade'
  40. }
  41. /**
  42. * Browser UI: `dsh web`. `host`/`port` are present only when the flag was
  43. * passed — pass-through overrides with no CLI default and no CLI validation:
  44. * the `dsh-host-webserver` schema (`host` a loopback/all-interfaces literal,
  45. * `port` a natural ≤ 65535) is the single source of both the default (the
  46. * shipped Web overlay value stands when a flag is absent) and validity (a bad
  47. * value fails loud at boot). `port` is `Number`-coerced only because the schema
  48. * wants a number, not a string. `dev` mounts the client HMR driver;
  49. * `workspaceRoot` is the parent directory for name-created workspaces.
  50. */
  51. interface WebInvocation {
  52. mode: 'web'
  53. /** Overlay of loader patches applied over the shipped web composition. */
  54. config?: string
  55. host?: string
  56. port?: number
  57. dev: boolean
  58. workspaceRoot?: string
  59. /** Extra authorities for the /api browser-trust fence (`host` or `host:port`); LAN IP literals are derived, not listed here. */
  60. trustedHosts?: string[]
  61. }
  62. /** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */
  63. export type DshInvocation =
  64. | TuiInvocation
  65. | HeadlessInvocation
  66. | MetaInvocation
  67. | SkillSessionInvocation
  68. | WebInvocation
  69. /** Raw web-subcommand options straight from Commander. */
  70. interface WebOptions {
  71. config?: string
  72. host?: string
  73. port?: string
  74. dev?: boolean
  75. workspaceRoot?: string
  76. trustedHost?: string[]
  77. }
  78. /**
  79. * Narrow the raw `web` options into a {@link WebInvocation}. No host/port
  80. * validation: both flow to the webserver schema, which is the sole gate. `port`
  81. * is coerced to a number (the schema rejects a string) but not range-checked
  82. * here — `NaN`/out-of-range fail loud at the schema on boot.
  83. */
  84. function resolveWeb(options: WebOptions): WebInvocation {
  85. return {
  86. mode: 'web',
  87. ...options.config !== undefined && { config: options.config },
  88. ...options.host !== undefined && { host: options.host },
  89. ...options.port !== undefined && { port: Number(options.port) },
  90. dev: options.dev === true,
  91. ...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot },
  92. ...options.trustedHost !== undefined && { trustedHosts: options.trustedHost },
  93. }
  94. }
  95. /**
  96. * Resolve the raw argv into a {@link DshInvocation}, or print and exit for
  97. * `--help`/`--version`/a parse error. The default (no subcommand) is the
  98. * TUI/headless surface; `web` is a subcommand.
  99. * @param argv - the arguments after the node binary and script (`process.argv.slice(2)`).
  100. * @param version - the version string `--version` prints; read from this app's package.json.
  101. * @returns the resolved invocation (only reached on a valid, non-help invocation).
  102. */
  103. export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
  104. let resolved: DshInvocation | undefined
  105. const program = new Command()
  106. .name('dsh')
  107. .version(version, '-V, --version', 'output the version number')
  108. .description('dsh: DeepSeek Harness — an interactive coding agent for your terminal.\nRun `dsh` with no arguments to start a session in the current directory.')
  109. // The default surface takes no positional task, so `dsh "task"` fails
  110. // commander's arity check with no hint; these examples are where a first
  111. // reader learns the entry points and that a one-shot task rides `-p`.
  112. .addHelpText('after', `
  113. Examples:
  114. dsh start an interactive session in this directory
  115. dsh -p "run the tests" answer one task, print the result, and exit
  116. dsh --resume <id> continue a past session
  117. `)
  118. .exitOverride()
  119. // Stop parent options at a subcommand boundary so `web --config` belongs to
  120. // Web while `--config ... web` remains a leaked default-surface option.
  121. .enablePositionalOptions()
  122. // Default surface: option-only (no positional), so `web` can be a real
  123. // subcommand without a positional collision.
  124. .option('-p, --prompt <task>', 'answer this task without the interactive UI, then exit')
  125. .option('--resume <id>', 'continue a past session by id')
  126. .option('--config <path>', 'apply this overlay of loader patches instead of the personal one')
  127. .option('--config-replace <path>', 'boot this file as the entire tree, ignoring the shipped and personal configuration')
  128. .action((options: { config?: string; configReplace?: string; prompt?: string; resume?: string }) => {
  129. if (options.prompt !== undefined) {
  130. // A headless prompt owns the invocation; an empty task has nothing to
  131. // run, and --config/--resume are TUI inputs that must not silently
  132. // vanish from a headless run.
  133. if (options.prompt === '') program.error('error: --prompt needs a task')
  134. if (options.config !== undefined || options.configReplace !== undefined || options.resume !== undefined) {
  135. program.error('error: --prompt takes no --config, --config-replace, or --resume')
  136. }
  137. resolved = { mode: 'headless', prompt: options.prompt }
  138. return
  139. }
  140. // An empty --resume= id would silently start a fresh session downstream
  141. // (agent-loop treats '' as no-resume), so a mistyped resume must fail loud.
  142. if (options.resume === '') program.error('error: --resume needs a session id')
  143. // The two config flags are mutually exclusive: one layers over the shipped
  144. // tree, the other discards it, so accepting both would silently drop one.
  145. if (options.config !== undefined && options.configReplace !== undefined) {
  146. program.error('error: --config and --config-replace are mutually exclusive')
  147. }
  148. resolved = {
  149. mode: 'tui',
  150. ...options.config !== undefined && { config: options.config },
  151. ...options.configReplace !== undefined && { configReplace: options.configReplace },
  152. ...options.resume !== undefined && { resume: options.resume },
  153. }
  154. })
  155. // Commander parses the parent (default-surface) options on either side of a
  156. // subcommand into `program.opts()`. For a subcommand that shares none of them,
  157. // a leaked config/prompt/resume option is a mistyped invocation that must fail
  158. // loud rather than silently run and drop the input.
  159. const rejectParentOptions = (command: string): void => {
  160. const parent = program.opts<{ config?: string; configReplace?: string; prompt?: string; resume?: string }>()
  161. if (parent.config !== undefined || parent.configReplace !== undefined
  162. || parent.prompt !== undefined || parent.resume !== undefined) {
  163. program.error(`error: ${command} takes none of --config, --config-replace, -p/--prompt, or --resume`)
  164. }
  165. }
  166. // Registration order is the rendered help order, so daily use comes first
  167. // and the harness-development surfaces (`web --dev`, `meta`) come last.
  168. // `upgrade` is a guided fresh-session entry: it takes no options and always
  169. // mints a fresh session, so nothing is left to carry.
  170. program
  171. .command('upgrade')
  172. .description('update this dsh installation to the latest version')
  173. .action(() => {
  174. rejectParentOptions('upgrade')
  175. resolved = { mode: 'upgrade' }
  176. })
  177. // Host and port name no default: the CLI passes neither through when the flag
  178. // is absent, so the shipped Web overlay value stands and restating it here
  179. // would duplicate a fact this file does not own.
  180. const web = program.command('web').description('serve the browser UI on the configured host and port')
  181. web
  182. .option('--config <path>', 'apply this overlay of loader patches over the shipped configuration')
  183. .option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine')
  184. .option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
  185. .option('--dev', 'developer mode: hot-reload the browser client')
  186. .option('--workspace-root <path>', 'parent directory for workspaces created from the browser UI')
  187. .option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
  188. .action((options: WebOptions) => {
  189. rejectParentOptions('web')
  190. resolved = resolveWeb(options)
  191. })
  192. program
  193. .command('meta')
  194. .description('work on the dsh source that runs this command, from any directory')
  195. .action(() => {
  196. rejectParentOptions('meta')
  197. resolved = { mode: 'meta' }
  198. })
  199. try {
  200. program.parse(argv, { from: 'user' })
  201. } catch (error) {
  202. // Commander printed help/version/the error under `exitOverride`; exit with
  203. // the code it chose (0 for help/version, 1 for a parse or domain error).
  204. /* v8 ignore next -- Commander only throws CommanderError from parse/error under exitOverride */
  205. return process.exit(error instanceof CommanderError ? error.exitCode : 1)
  206. }
  207. /* v8 ignore next -- the default action or a subcommand action always resolves, or parse throws above */
  208. if (resolved === undefined) throw new Error('dsh: no invocation resolved')
  209. return resolved
  210. }