args.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. /**
  2. * Commander adapter for the `dsh` command-line entry. The default command
  3. * boots one required `--config` overlay over the shipped base; `-p` selects
  4. * the one-shot headless path and `web` selects the browser application.
  5. * Commander owns help, version, and parse errors.
  6. * @module @deepseek-ai/dsh/args
  7. */
  8. import { Command, CommanderError } from 'commander'
  9. /** Boot a caller-selected overlay over the shipped base config. */
  10. interface ConfigInvocation {
  11. mode: 'config'
  12. config: string
  13. }
  14. /** Print a composed config tree and exit without booting. */
  15. interface DumpConfigInvocation {
  16. mode: 'dump-config'
  17. surface: 'config' | 'web'
  18. /** Omit every caller or personal layer and print the shipped tree. */
  19. defaultOnly: boolean
  20. /** Explicit overlay to compose over the base or Web surface. */
  21. config?: string
  22. }
  23. /** Headless one-shot: `dsh -p "task"`. */
  24. interface HeadlessInvocation {
  25. mode: 'headless'
  26. prompt: string
  27. }
  28. /**
  29. * Browser UI: `dsh web`. Host and port remain unvalidated pass-throughs to
  30. * the webserver schema; absent values leave the shipped Web overlay intact.
  31. */
  32. interface WebInvocation {
  33. mode: 'web'
  34. /** Overlay applied over the shipped Web composition instead of the personal one. */
  35. config?: string
  36. host?: string
  37. port?: number
  38. dev: boolean
  39. workspaceRoot?: string
  40. /** Extra authorities for the /api browser-trust fence. */
  41. trustedHosts?: string[]
  42. }
  43. /** The resolved `dsh` invocation. Help, version, and errors exit inside {@link parseDshArgs}. */
  44. export type DshInvocation = ConfigInvocation | DumpConfigInvocation | HeadlessInvocation | WebInvocation
  45. /** Raw web-subcommand options straight from Commander. */
  46. interface WebOptions {
  47. config?: string
  48. host?: string
  49. port?: string
  50. dev?: boolean
  51. workspaceRoot?: string
  52. trustedHost?: string[]
  53. dumpConfig?: boolean
  54. dumpDefaultConfig?: boolean
  55. }
  56. /** Resolve config-dump flags for one command shape. */
  57. function resolveDump(
  58. surface: 'config' | 'web',
  59. options: { config?: string; dumpConfig?: boolean; dumpDefaultConfig?: boolean },
  60. error: (message: string) => never,
  61. ): DumpConfigInvocation | undefined {
  62. if (options.dumpConfig !== true && options.dumpDefaultConfig !== true) return undefined
  63. if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
  64. error('error: --dump-config and --dump-default-config are mutually exclusive')
  65. }
  66. const defaultOnly = options.dumpDefaultConfig === true
  67. if (defaultOnly && options.config !== undefined) {
  68. error('error: --dump-default-config prints the shipped tree and takes no --config')
  69. }
  70. if (surface === 'config' && !defaultOnly && options.config === undefined) {
  71. error('error: --dump-config requires --config <path>')
  72. }
  73. return {
  74. mode: 'dump-config',
  75. surface,
  76. defaultOnly,
  77. ...options.config !== undefined && { config: options.config },
  78. }
  79. }
  80. /** Narrow raw `web` options into a {@link WebInvocation}. */
  81. function resolveWeb(options: WebOptions): WebInvocation {
  82. return {
  83. mode: 'web',
  84. ...options.config !== undefined && { config: options.config },
  85. ...options.host !== undefined && { host: options.host },
  86. ...options.port !== undefined && { port: Number(options.port) },
  87. dev: options.dev === true,
  88. ...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot },
  89. ...options.trustedHost !== undefined && { trustedHosts: options.trustedHost },
  90. }
  91. }
  92. /**
  93. * Resolve argv into one invocation, or print and exit for help, version, or an
  94. * error.
  95. * @param argv - arguments after the Node binary and script.
  96. * @param version - version string printed by `--version`.
  97. * @returns the resolved invocation.
  98. */
  99. export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
  100. let resolved: DshInvocation | undefined
  101. const program = new Command()
  102. .name('dsh')
  103. .version(version, '-V, --version', 'output the version number')
  104. .description('dsh: boot a DeepSeek Harness config overlay over the shipped base configuration.')
  105. .addHelpText('after', `
  106. Examples:
  107. dsh --config ./app.cordis.yml boot an overlay over the shipped base
  108. dsh -p "run the tests" answer one task, print the result, and exit
  109. dsh web serve the browser UI
  110. `)
  111. .exitOverride()
  112. .enablePositionalOptions()
  113. .option('-p, --prompt <task>', 'answer this task without an interactive UI, then exit')
  114. .option('--config <path>', 'overlay of loader patches to apply over the shipped base')
  115. .option('--dump-config', 'print the base plus --config overlay and exit')
  116. .option('--dump-default-config', 'print the shipped base config and exit')
  117. .action((options: {
  118. config?: string
  119. prompt?: string
  120. dumpConfig?: boolean
  121. dumpDefaultConfig?: boolean
  122. }) => {
  123. if (options.config === '') program.error('error: --config needs a path')
  124. const dump = resolveDump('config', options, message => program.error(message))
  125. if (dump !== undefined) {
  126. if (options.prompt !== undefined) {
  127. program.error('error: --dump-config/--dump-default-config take no -p/--prompt')
  128. }
  129. resolved = dump
  130. return
  131. }
  132. if (options.prompt !== undefined) {
  133. if (options.prompt === '') program.error('error: --prompt needs a task')
  134. if (options.config !== undefined) program.error('error: --prompt takes no --config')
  135. resolved = { mode: 'headless', prompt: options.prompt }
  136. return
  137. }
  138. const config = options.config ?? program.error('error: --config <path> is required')
  139. resolved = { mode: 'config', config }
  140. })
  141. /** Reject parent options that crossed a subcommand boundary. */
  142. const rejectParentOptions = (command: string): void => {
  143. const parent = program.opts<{
  144. config?: string
  145. prompt?: string
  146. dumpConfig?: boolean
  147. dumpDefaultConfig?: boolean
  148. }>()
  149. if (parent.config !== undefined || parent.prompt !== undefined
  150. || parent.dumpConfig !== undefined || parent.dumpDefaultConfig !== undefined) {
  151. program.error(`error: ${command} takes none of parent --config, -p/--prompt, --dump-config, or --dump-default-config`)
  152. }
  153. }
  154. const web = program.command('web').description('serve the browser UI on the configured host and port')
  155. web
  156. .option('--config <path>', 'apply this overlay of loader patches over the shipped Web configuration')
  157. .option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine')
  158. .option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
  159. .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)')
  160. .option('--workspace-root <path>', 'parent directory for workspaces created from the browser UI')
  161. .option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
  162. .option('--dump-config', 'print the composed config tree (base + web + --config/personal overlay) and exit')
  163. .option('--dump-default-config', 'print the shipped config tree (base + web overlay, no user layer) and exit')
  164. .action((options: WebOptions) => {
  165. rejectParentOptions('web')
  166. if (options.config === '') program.error('error: --config needs a path')
  167. const dump = resolveDump('web', options, message => program.error(message))
  168. if (dump !== undefined) {
  169. resolved = dump
  170. return
  171. }
  172. resolved = resolveWeb(options)
  173. })
  174. try {
  175. program.parse(argv, { from: 'user' })
  176. } catch (error) {
  177. return process.exit(error instanceof CommanderError ? error.exitCode : 1)
  178. }
  179. /* v8 ignore next -- an action resolves or Commander throws */
  180. if (resolved === undefined) throw new Error('dsh: no invocation resolved')
  181. return resolved
  182. }