args.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. /**
  2. * Commander adapter for the `dsh` command line.
  3. *
  4. * The launcher parses only what it owns — which profile to boot, which extra
  5. * patch overlays to apply, and the config dumps — and hands **everything after
  6. * its own flags** to the booted tree verbatim, where injected app plugins parse
  7. * their own flag families and print their own `--help` (see
  8. * `@deepseek-ai/dsh-cmdline`). Launcher flags therefore come first: the first
  9. * token this parser does not recognize starts the inner arguments, so
  10. * `dsh --profile tui --resume abc` boots the tui profile with `--resume abc`,
  11. * and `dsh --profile web -h` prints the web app's help, not this one's.
  12. *
  13. * `web` is a hardcoded alias for `--profile web`; `plugin` manages a profile's
  14. * plugin dependencies by forwarding to pnpm.
  15. * @module @deepseek-ai/dsh/args
  16. */
  17. import { Command, CommanderError } from 'commander'
  18. /** Boot a named profile and hand it the invocation's inner arguments. */
  19. interface ProfileInvocation {
  20. mode: 'profile'
  21. profile: string
  22. /** Shipped template used once to initialize a missing profile. */
  23. fromDefaultProfile?: string | undefined
  24. /** Extra patch-list overlays applied after the profile's own layer, in argv order. */
  25. patches: string[]
  26. /** Everything after the launcher's own flags, verbatim, for injected app plugins. */
  27. args: string[]
  28. }
  29. /** Print a composed profile tree and exit without booting. */
  30. interface DumpConfigInvocation {
  31. mode: 'dump-config'
  32. profile: string
  33. /** Shipped template used once to initialize a missing profile. */
  34. fromDefaultProfile?: string | undefined
  35. /** Omit the profile's user layer and --patch overlays; print bundle layers only. */
  36. defaultOnly: boolean
  37. patches: string[]
  38. }
  39. /** Manage a profile's plugins: forward `args` to pnpm inside the profile directory. */
  40. interface PluginInvocation {
  41. mode: 'plugin'
  42. profile: string
  43. /** Raw pnpm arguments, verbatim. */
  44. args: string[]
  45. }
  46. /** The resolved `dsh` invocation. Help, version, and errors exit inside {@link parseDshArgs}. */
  47. export type DshInvocation = ProfileInvocation | DumpConfigInvocation | PluginInvocation
  48. /** Launcher flags shared by the default command and the `web` alias. */
  49. interface BootOptions {
  50. patch?: string[]
  51. dumpConfig?: boolean
  52. dumpDefaultConfig?: boolean
  53. fromDefaultProfile?: string
  54. }
  55. /**
  56. * Repeatable single-value collector: `--patch a.yml --patch b.yml`. Never
  57. * variadic — a variadic `--patch` would swallow the inner arguments.
  58. */
  59. const collect = (value: string, previous: string[] = []): string[] => [...previous, value]
  60. function rejectElectronProfile(program: Command, profile: string): void {
  61. if (profile.toLowerCase() === 'desktop') {
  62. program.error('error: profile "desktop" is managed exclusively by the Electron application')
  63. }
  64. }
  65. /** The launcher's own help text; each app prints its own. */
  66. const HELP_EXAMPLES = `
  67. Examples:
  68. dsh --profile web boot the web profile (same as: dsh web)
  69. dsh --profile rescue --from-default-profile web
  70. create rescue from the shipped web template, then boot it
  71. dsh --profile headless "run the tests" answer one task, print the result, and exit
  72. dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay
  73. dsh --profile tui --resume <session> arguments after the launcher flags reach the app
  74. dsh --profile web --help the web app's own flags and help
  75. dsh plugin --profile tui add <package> install a plugin into the tui profile
  76. `
  77. /**
  78. * Resolve a boot or dump invocation from the launcher flags and the leftover
  79. * inner arguments.
  80. * @param program - the command whose options were parsed (the root, or the `web` alias).
  81. * @param profile - the profile these flags boot.
  82. * @param options - the launcher flags commander collected.
  83. * @param args - the leftover arguments, in argv order.
  84. * @returns the resolved invocation.
  85. */
  86. function resolveBoot(program: Command, profile: string, options: BootOptions, args: string[]): DshInvocation {
  87. const patches = options.patch ?? []
  88. if (patches.includes('')) program.error('error: --patch needs a path')
  89. if (options.fromDefaultProfile === '') program.error('error: --from-default-profile needs a name')
  90. if (options.dumpConfig !== true && options.dumpDefaultConfig !== true) {
  91. return { mode: 'profile', profile, fromDefaultProfile: options.fromDefaultProfile, patches, args }
  92. }
  93. if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
  94. program.error('error: --dump-config and --dump-default-config are mutually exclusive')
  95. }
  96. // The dump is boot-free: it never runs app command-line providers, so it
  97. // cannot show what those flags would decide, and printing a tree that differs
  98. // from the same invocation's boot would mislead.
  99. if (args.length > 0) {
  100. program.error(`error: config dumps take no app arguments, got ${args.map(argument => JSON.stringify(argument)).join(' ')}`)
  101. }
  102. const defaultOnly = options.dumpDefaultConfig === true
  103. if (defaultOnly && patches.length > 0) {
  104. program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
  105. }
  106. return { mode: 'dump-config', profile, fromDefaultProfile: options.fromDefaultProfile, defaultOnly, patches }
  107. }
  108. /**
  109. * Resolve argv into one invocation, or print and exit for help, version, or an
  110. * error.
  111. * @param argv - arguments after the Node binary and script.
  112. * @param version - version string printed by `--version`.
  113. * @returns the resolved invocation.
  114. */
  115. export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
  116. let resolved: DshInvocation | undefined
  117. // Annotated, not inferred: the actions below call back into `program`, and an
  118. // inferred type would be circular through its own chain.
  119. const program: Command = new Command()
  120. program
  121. .name('dsh')
  122. .version(version, '-V, --version', 'output the version number')
  123. .description('dsh: boot a DeepSeek Harness profile — an ordered stack of plugin-bundle patch layers under your own overrides.')
  124. .addHelpText('after', HELP_EXAMPLES)
  125. .exitOverride()
  126. // The launcher's flags come first and end at the first token it does not
  127. // know; everything from there on belongs to the booted app, including
  128. // its -h. `dsh -h` with no profile still prints this help, below.
  129. .helpOption(false)
  130. .allowUnknownOption()
  131. .passThroughOptions()
  132. .enablePositionalOptions()
  133. .argument('[args...]', 'arguments for the booted profile\'s app (see: dsh --profile <name> --help)')
  134. .option('--profile <name>', 'the profile under $DSH_HOME/profiles to boot')
  135. .option('--from-default-profile <name>', 'initialize a new custom profile from a shipped profile template')
  136. .option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
  137. .option('--dump-config', 'print the composed profile tree and exit')
  138. .option('--dump-default-config', 'print the profile tree without its user layer or --patch overlays and exit')
  139. .action((args: string[], options: BootOptions & { profile?: string }) => {
  140. // With the app owning -h, the launcher's own help is what a bare
  141. // `dsh -h` (no profile to hand it to) must print.
  142. if (options.profile === undefined) {
  143. if (args.some(argument => argument === '-h' || argument === '--help')) program.help()
  144. program.error('error: --profile <name> is required')
  145. }
  146. const profile = options.profile
  147. if (profile === '') program.error('error: --profile needs a name')
  148. rejectElectronProfile(program, profile)
  149. resolved = resolveBoot(program, profile, options, args)
  150. })
  151. /** Reject parent options supplied before a subcommand. */
  152. const rejectParentOptions = (command: string): void => {
  153. const parent = program.opts<BootOptions & { profile?: string }>()
  154. if (parent.profile !== undefined || parent.patch !== undefined
  155. || parent.dumpConfig !== undefined || parent.dumpDefaultConfig !== undefined
  156. || parent.fromDefaultProfile !== undefined) {
  157. program.error(
  158. `error: ${command} takes none of parent --profile, --from-default-profile, --patch, --dump-config, or --dump-default-config`,
  159. )
  160. }
  161. }
  162. const web = program.command('web').description('boot the web profile (alias of --profile web); the web app\'s own flags follow')
  163. web
  164. .helpOption(false)
  165. .allowUnknownOption()
  166. .passThroughOptions()
  167. .enablePositionalOptions()
  168. .argument('[args...]', 'arguments for the web app (see: dsh web --help)')
  169. .option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
  170. .option('--dump-config', 'print the composed web-profile tree (with the user layer and any --patch) and exit')
  171. .option('--dump-default-config', 'print the web profile\'s bundle layers (no user layer) and exit')
  172. .action((args: string[], options: BootOptions) => {
  173. rejectParentOptions('web')
  174. resolved = resolveBoot(web, 'web', options, args)
  175. })
  176. const plugin = program.command('plugin').description('manage a profile\'s plugins by forwarding the remaining arguments to pnpm in the profile directory')
  177. plugin
  178. .requiredOption('--profile <name>', 'the profile whose plugins to manage (initialized on first use)')
  179. .allowUnknownOption()
  180. .argument('[args...]', 'pnpm arguments, forwarded verbatim (add <pkg>, remove <pkg>, why <pkg>, ...)')
  181. .action((args: string[], options: { profile: string }) => {
  182. rejectParentOptions('plugin')
  183. if (options.profile === '') program.error('error: --profile needs a name')
  184. rejectElectronProfile(plugin, options.profile)
  185. if (args.length === 0) program.error('error: plugin needs pnpm arguments to forward (e.g. add <package>)')
  186. resolved = { mode: 'plugin', profile: options.profile, args }
  187. })
  188. try {
  189. program.parse(argv, { from: 'user' })
  190. } catch (error) {
  191. return process.exit(error instanceof CommanderError ? error.exitCode : 1)
  192. }
  193. /* v8 ignore next -- an action resolves or Commander throws */
  194. if (resolved === undefined) throw new Error('dsh: no invocation resolved')
  195. return resolved
  196. }