args.ts 11 KB

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