args.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  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; `web` is a
  6. * real subcommand. Commander owns `--help`/`--version` and parse errors — it
  7. * 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. /** Interactive TUI: the default mode. `--config` swaps the tree; `--resume <id>` rehydrates a session. */
  13. interface TuiInvocation {
  14. mode: 'tui'
  15. config?: string
  16. resume?: string
  17. }
  18. /** Headless one-shot: `dsh -p "task"`. */
  19. interface HeadlessInvocation {
  20. mode: 'headless'
  21. prompt: string
  22. }
  23. /**
  24. * Browser UI: `dsh web`. `host`/`port` are present only when the flag was
  25. * passed — pass-through overrides with no CLI default and no CLI validation:
  26. * the `dsh-host-webserver` schema (`host` a loopback/all-interfaces literal,
  27. * `port` a natural ≤ 65535) is the single source of both the default (the
  28. * shipped `cordis.yml` value stands when a flag is absent) and validity (a bad
  29. * value fails loud at boot). `port` is `Number`-coerced only because the schema
  30. * wants a number, not a string. `dev` mounts the client HMR driver;
  31. * `workspaceRoot` is the parent directory for name-created workspaces.
  32. */
  33. interface WebInvocation {
  34. mode: 'web'
  35. host?: string
  36. port?: number
  37. dev: boolean
  38. workspaceRoot?: string
  39. /** Extra authorities for the /api browser-trust fence (`host` or `host:port`); LAN IP literals are derived, not listed here. */
  40. trustedHosts?: string[]
  41. }
  42. /** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */
  43. export type DshInvocation = TuiInvocation | HeadlessInvocation | WebInvocation
  44. /** Raw web-subcommand options straight from Commander. */
  45. interface WebOptions {
  46. host?: string
  47. port?: string
  48. dev?: boolean
  49. workspaceRoot?: string
  50. trustedHost?: string[]
  51. }
  52. /**
  53. * Narrow the raw `web` options into a {@link WebInvocation}. No host/port
  54. * validation: both flow to the webserver schema, which is the sole gate. `port`
  55. * is coerced to a number (the schema rejects a string) but not range-checked
  56. * here — `NaN`/out-of-range fail loud at the schema on boot.
  57. */
  58. function resolveWeb(options: WebOptions): WebInvocation {
  59. return {
  60. mode: 'web',
  61. ...options.host !== undefined && { host: options.host },
  62. ...options.port !== undefined && { port: Number(options.port) },
  63. dev: options.dev === true,
  64. ...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot },
  65. ...options.trustedHost !== undefined && { trustedHosts: options.trustedHost },
  66. }
  67. }
  68. /**
  69. * Resolve the raw argv into a {@link DshInvocation}, or print and exit for
  70. * `--help`/`--version`/a parse error. The default (no subcommand) is the
  71. * TUI/headless surface; `web` is a subcommand.
  72. * @param argv - the arguments after the node binary and script (`process.argv.slice(2)`).
  73. * @param version - the version string `--version` prints; read from this app's package.json.
  74. * @returns the resolved invocation (only reached on a valid, non-help invocation).
  75. */
  76. export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
  77. let resolved: DshInvocation | undefined
  78. const program = new Command()
  79. .name('dsh')
  80. .version(version, '-V, --version', 'output the version number')
  81. .description('dsh: interactive TUI (default), headless task, and browser UI')
  82. .exitOverride()
  83. // Default surface: option-only (no positional), so `web` can be a real
  84. // subcommand without a positional collision.
  85. .option('--config <path>', 'boot an alternate cordis.yml instead of the shipped tree (TUI mode)')
  86. .option('-p, --prompt <task>', 'run one headless turn for this task, print the result, and exit')
  87. .option('--resume <id>', 'resume the persisted session with this id (TUI mode)')
  88. .action((options: { config?: string; prompt?: string; resume?: string }) => {
  89. if (options.prompt !== undefined) {
  90. // A headless prompt owns the invocation; an empty task has nothing to
  91. // run, and --config/--resume are TUI inputs that must not silently
  92. // vanish from a headless run.
  93. if (options.prompt === '') program.error('error: --prompt needs a task')
  94. if (options.config !== undefined || options.resume !== undefined) {
  95. program.error('error: --prompt takes no --config or --resume')
  96. }
  97. resolved = { mode: 'headless', prompt: options.prompt }
  98. return
  99. }
  100. // An empty --resume= id would silently start a fresh session downstream
  101. // (agent-loop treats '' as no-resume), so a mistyped resume must fail loud.
  102. if (options.resume === '') program.error('error: --resume needs a session id')
  103. resolved = {
  104. mode: 'tui',
  105. ...options.config !== undefined && { config: options.config },
  106. ...options.resume !== undefined && { resume: options.resume },
  107. }
  108. })
  109. const web = program.command('web').description('serve the browser UI (host/port default to the shipped config)')
  110. web
  111. .option('--host <host>', 'override the config bind host (127.0.0.1 or 0.0.0.0)')
  112. .option('--port <port>', 'override the config listen port (0 requests an OS-assigned port)')
  113. .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds')
  114. .option('--workspace-root <path>', 'parent directory for name-created workspaces')
  115. .option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
  116. .action((options: WebOptions) => {
  117. // Commander parses the parent (default-surface) options on either side of
  118. // the subcommand into `program.opts()`. `web` shares none of them, so a
  119. // leaked `--config`/`-p`/`--resume` is a mistyped invocation that must
  120. // fail loud rather than silently start the web server and drop it.
  121. const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>()
  122. if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) {
  123. program.error('error: web takes none of --config, -p/--prompt, or --resume')
  124. }
  125. resolved = resolveWeb(options)
  126. })
  127. try {
  128. program.parse(argv, { from: 'user' })
  129. } catch (error) {
  130. // Commander printed help/version/the error under `exitOverride`; exit with
  131. // the code it chose (0 for help/version, 1 for a parse or domain error).
  132. /* v8 ignore next -- Commander only throws CommanderError from parse/error under exitOverride */
  133. return process.exit(error instanceof CommanderError ? error.exitCode : 1)
  134. }
  135. /* v8 ignore next -- the default action or a subcommand action always resolves, or parse throws above */
  136. if (resolved === undefined) throw new Error('dsh: no invocation resolved')
  137. return resolved
  138. }