args.ts 10 KB

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