args.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. /**
  2. * Commander adapter for the `dsh` command-line entry. The default command
  3. * boots a named profile (`--profile <name>`), optionally with extra `--patch`
  4. * overlays and a positional task (one-shot mode for profiles mounting the
  5. * headless runner). `web` is a hardcoded alias for `--profile web` that adds
  6. * the Web flag family; `plugin` manages a profile's plugin dependencies by
  7. * forwarding to pnpm. Commander owns help, version, and parse errors.
  8. * @module @deepseek-ai/dsh/args
  9. */
  10. import { Command, CommanderError } from 'commander'
  11. /** Boot a named profile. */
  12. interface ProfileInvocation {
  13. mode: 'profile'
  14. profile: string
  15. /** Extra patch-list overlays applied after the profile's own layer, in argv order. */
  16. patches: string[]
  17. /** Positional task text joined by spaces; non-empty only for one-shot runs. */
  18. task?: string
  19. }
  20. /** Print a composed profile tree and exit without booting. */
  21. interface DumpConfigInvocation {
  22. mode: 'dump-config'
  23. profile: string
  24. /** Omit the profile's user layer and --patch overlays; print bundle layers only. */
  25. defaultOnly: boolean
  26. patches: string[]
  27. }
  28. /**
  29. * Browser UI: `dsh web` (alias of `--profile web`). Host and port remain
  30. * unvalidated pass-throughs to the webserver schema; absent values leave the
  31. * shipped web bundle values intact.
  32. */
  33. interface WebInvocation {
  34. mode: 'web'
  35. patches: 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. /** Manage a profile's plugins: forward `args` to pnpm inside the profile directory. */
  44. interface PluginInvocation {
  45. mode: 'plugin'
  46. profile: string
  47. /** Raw pnpm arguments, verbatim. */
  48. args: string[]
  49. }
  50. /** The resolved `dsh` invocation. Help, version, and errors exit inside {@link parseDshArgs}. */
  51. export type DshInvocation = ProfileInvocation | DumpConfigInvocation | WebInvocation | PluginInvocation
  52. /** Raw web-subcommand options straight from Commander. */
  53. interface WebOptions {
  54. patch?: string[]
  55. host?: string
  56. port?: string
  57. dev?: boolean
  58. workspaceRoot?: string
  59. trustedHost?: string[]
  60. dumpConfig?: boolean
  61. dumpDefaultConfig?: boolean
  62. }
  63. /**
  64. * Repeatable single-value collector: `--patch a.yml --patch b.yml`. Never
  65. * variadic — a variadic `--patch` would swallow a following positional task.
  66. */
  67. const collect = (value: string, previous: string[] = []): string[] => [...previous, value]
  68. /**
  69. * Resolve argv into one invocation, or print and exit for help, version, or an
  70. * error.
  71. * @param argv - arguments after the Node binary and script.
  72. * @param version - version string printed by `--version`.
  73. * @returns the resolved invocation.
  74. */
  75. export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
  76. let resolved: DshInvocation | undefined
  77. const program = new Command()
  78. .name('dsh')
  79. .version(version, '-V, --version', 'output the version number')
  80. .description('dsh: boot a DeepSeek Harness profile — an ordered stack of plugin-bundle patch layers under your own overrides.')
  81. .addHelpText('after', `
  82. Examples:
  83. dsh --profile web boot the web profile (same as: dsh web)
  84. dsh --profile headless "run the tests" answer one task, print the result, and exit
  85. dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay
  86. dsh plugin --profile tui add <package> install a plugin into the tui profile
  87. dsh web --port 8080 the web alias with its flag family
  88. `)
  89. .exitOverride()
  90. .enablePositionalOptions()
  91. .argument('[task...]', 'one-shot task text for profiles mounting the headless runner')
  92. .option('--profile <name>', 'the profile under $DSH_HOME/profiles to boot')
  93. .option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
  94. .option('--dump-config', 'print the composed profile tree and exit')
  95. .option('--dump-default-config', 'print the profile tree without its user layer or --patch overlays and exit')
  96. .action((task: string[], options: {
  97. profile?: string
  98. patch?: string[]
  99. dumpConfig?: boolean
  100. dumpDefaultConfig?: boolean
  101. }) => {
  102. const profile = options.profile ?? program.error('error: --profile <name> is required')
  103. if (profile === '') program.error('error: --profile needs a name')
  104. const patches = options.patch ?? []
  105. if (patches.includes('')) program.error('error: --patch needs a path')
  106. if (options.dumpConfig === true || options.dumpDefaultConfig === true) {
  107. if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
  108. program.error('error: --dump-config and --dump-default-config are mutually exclusive')
  109. }
  110. if (task.length > 0) program.error('error: --dump-config/--dump-default-config take no task')
  111. const defaultOnly = options.dumpDefaultConfig === true
  112. if (defaultOnly && patches.length > 0) {
  113. program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
  114. }
  115. resolved = { mode: 'dump-config', profile, defaultOnly, patches }
  116. return
  117. }
  118. resolved = {
  119. mode: 'profile',
  120. profile,
  121. patches,
  122. ...task.length > 0 ? { task: task.join(' ') } : {},
  123. }
  124. })
  125. /** Reject parent options that crossed a subcommand boundary. */
  126. const rejectParentOptions = (command: string): void => {
  127. const parent = program.opts<{
  128. profile?: string
  129. patch?: string[]
  130. dumpConfig?: boolean
  131. dumpDefaultConfig?: boolean
  132. }>()
  133. if (parent.profile !== undefined || parent.patch !== undefined
  134. || parent.dumpConfig !== undefined || parent.dumpDefaultConfig !== undefined) {
  135. program.error(`error: ${command} takes none of parent --profile, --patch, --dump-config, or --dump-default-config`)
  136. }
  137. }
  138. const web = program.command('web').description('serve the browser UI (alias of --profile web) on the configured host and port')
  139. web
  140. .option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
  141. .option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine')
  142. .option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
  143. .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)')
  144. .option('--workspace-root <path>', 'parent directory for workspaces created from the browser UI')
  145. .option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
  146. .option('--dump-config', 'print the composed web-profile tree (with the user layer and any --patch) and exit')
  147. .option('--dump-default-config', 'print the web profile\'s bundle layers (no user layer) and exit')
  148. .action((options: WebOptions) => {
  149. rejectParentOptions('web')
  150. const patches = options.patch ?? []
  151. if (patches.includes('')) program.error('error: --patch needs a path')
  152. if (options.dumpConfig === true || options.dumpDefaultConfig === true) {
  153. if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
  154. program.error('error: --dump-config and --dump-default-config are mutually exclusive')
  155. }
  156. const defaultOnly = options.dumpDefaultConfig === true
  157. if (defaultOnly && patches.length > 0) {
  158. program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
  159. }
  160. // The dump is boot-free and does not derive flag patches; silently
  161. // dropping them would print a tree that differs from the same
  162. // invocation's boot.
  163. if (options.host !== undefined || options.port !== undefined || options.dev === true
  164. || options.workspaceRoot !== undefined || options.trustedHost !== undefined) {
  165. program.error('error: config dumps take no web flags (--host/--port/--dev/--workspace-root/--trusted-host)')
  166. }
  167. resolved = { mode: 'dump-config', profile: 'web', defaultOnly, patches }
  168. return
  169. }
  170. if (options.port !== undefined && !/^\d+$/.test(options.port)) {
  171. program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`)
  172. }
  173. resolved = {
  174. mode: 'web',
  175. patches,
  176. ...options.host !== undefined && { host: options.host },
  177. ...options.port !== undefined && { port: Number(options.port) },
  178. dev: options.dev === true,
  179. ...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot },
  180. ...options.trustedHost !== undefined && { trustedHosts: options.trustedHost },
  181. }
  182. })
  183. const plugin = program.command('plugin').description('manage a profile\'s plugins by forwarding the remaining arguments to pnpm in the profile directory')
  184. plugin
  185. .requiredOption('--profile <name>', 'the profile whose plugins to manage (initialized on first use)')
  186. .allowUnknownOption()
  187. .argument('[args...]', 'pnpm arguments, forwarded verbatim (add <pkg>, remove <pkg>, why <pkg>, ...)')
  188. .action((args: string[], options: { profile: string }) => {
  189. rejectParentOptions('plugin')
  190. if (options.profile === '') program.error('error: --profile needs a name')
  191. if (args.length === 0) program.error('error: plugin needs pnpm arguments to forward (e.g. add <package>)')
  192. resolved = { mode: 'plugin', profile: options.profile, args }
  193. })
  194. try {
  195. program.parse(argv, { from: 'user' })
  196. } catch (error) {
  197. return process.exit(error instanceof CommanderError ? error.exitCode : 1)
  198. }
  199. /* v8 ignore next -- an action resolves or Commander throws */
  200. if (resolved === undefined) throw new Error('dsh: no invocation resolved')
  201. return resolved
  202. }