mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
refactor(cli)!: the launcher parses only its own flags
Launcher flags come first and end at the first token dsh does not recognize; everything after reaches the booted app verbatim, so dsh --profile tui --resume <id> works with no launcher change and dsh --profile web --help prints the web app's help. A bare dsh -h, which has no app to hand the flag to, still prints the launcher's own. src/web.ts is deleted: the Web flag family, its LAN-trust sampling, and the one-shot task positional now live in their bundles, and runProfile no longer knows any row id. What the startup row decides comes back as a launcher-owned patch layer above every layer a user can edit, so a live config edit recomposes the tree without resetting a served port. dsh web and dsh --profile web finally boot through one path, which also gives --profile web the harness-source prompt section that only the alias used to add.
This commit is contained in:
@@ -1,31 +1,30 @@
|
||||
/**
|
||||
* Commander adapter for the `dsh` command-line entry. The default command
|
||||
* boots a named profile (`--profile <name>`), optionally with extra `--patch`
|
||||
* overlays. `run` owns one-shot task execution, defaulting to the headless
|
||||
* profile; `web` is a hardcoded alias for `--profile web` that adds the Web
|
||||
* flag family; `plugin` manages a profile's plugin dependencies by forwarding
|
||||
* to pnpm. Commander owns help, version, and parse errors.
|
||||
* Commander adapter for the `dsh` command line.
|
||||
*
|
||||
* The launcher parses only what it owns — which profile to boot, which extra
|
||||
* patch overlays to apply, and the config dumps — and hands **everything after
|
||||
* its own flags** to the booted tree verbatim, where the booted app's startup row
|
||||
* parses its own flag family and prints its own `--help` (see
|
||||
* `@deepseek-ai/dsh-cmdline`). Launcher flags therefore come first: the first
|
||||
* token this parser does not recognize starts the inner arguments, so
|
||||
* `dsh --profile tui --resume abc` boots the tui profile with `--resume abc`,
|
||||
* and `dsh --profile web -h` prints the web app's help, not this one's.
|
||||
*
|
||||
* `web` is a hardcoded alias for `--profile web`; `plugin` manages a profile's
|
||||
* plugin dependencies by forwarding to pnpm.
|
||||
* @module @deepseek-ai/dsh/args
|
||||
*/
|
||||
|
||||
import { Command, CommanderError } from 'commander'
|
||||
|
||||
/** Boot a named profile. */
|
||||
/** Boot a named profile and hand it the invocation's inner arguments. */
|
||||
interface ProfileInvocation {
|
||||
mode: 'profile'
|
||||
profile: string
|
||||
/** Extra patch-list overlays applied after the profile's own layer, in argv order. */
|
||||
patches: string[]
|
||||
}
|
||||
|
||||
/** Run one task through a profile mounting the headless runner. */
|
||||
interface RunInvocation {
|
||||
mode: 'run'
|
||||
profile: string
|
||||
/** Extra patch-list overlays applied after the profile's own layer, in argv order. */
|
||||
patches: string[]
|
||||
/** Non-blank task text joined from the variadic positional arguments. */
|
||||
task: string
|
||||
/** Everything after the launcher's own flags, verbatim, for the booted app's startup row. */
|
||||
args: string[]
|
||||
}
|
||||
|
||||
/** Print a composed profile tree and exit without booting. */
|
||||
@@ -37,21 +36,6 @@ interface DumpConfigInvocation {
|
||||
patches: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser UI: `dsh web` (alias of `--profile web`). Host and port remain
|
||||
* unvalidated pass-throughs to the webserver schema; absent values leave the
|
||||
* shipped web bundle values intact.
|
||||
*/
|
||||
interface WebInvocation {
|
||||
mode: 'web'
|
||||
patches: string[]
|
||||
host?: string
|
||||
port?: number
|
||||
dev: boolean
|
||||
/** Extra authorities for the /api browser-trust fence. */
|
||||
trustedHosts?: string[]
|
||||
}
|
||||
|
||||
/** Manage a profile's plugins: forward `args` to pnpm inside the profile directory. */
|
||||
interface PluginInvocation {
|
||||
mode: 'plugin'
|
||||
@@ -61,31 +45,63 @@ interface PluginInvocation {
|
||||
}
|
||||
|
||||
/** The resolved `dsh` invocation. Help, version, and errors exit inside {@link parseDshArgs}. */
|
||||
export type DshInvocation = ProfileInvocation | RunInvocation | DumpConfigInvocation | WebInvocation | PluginInvocation
|
||||
export type DshInvocation = ProfileInvocation | DumpConfigInvocation | PluginInvocation
|
||||
|
||||
/** Raw web-subcommand options straight from Commander. */
|
||||
interface WebOptions {
|
||||
/** Launcher flags shared by the default command and the `web` alias. */
|
||||
interface BootOptions {
|
||||
patch?: string[]
|
||||
host?: string
|
||||
port?: string
|
||||
dev?: boolean
|
||||
trustedHost?: string[]
|
||||
dumpConfig?: boolean
|
||||
dumpDefaultConfig?: boolean
|
||||
}
|
||||
|
||||
/** Raw run-subcommand options straight from Commander. */
|
||||
interface RunOptions {
|
||||
profile: string
|
||||
patch?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Repeatable single-value collector: `--patch a.yml --patch b.yml`. Never
|
||||
* variadic — a variadic `--patch` would swallow a following positional task.
|
||||
* variadic — a variadic `--patch` would swallow the inner arguments.
|
||||
*/
|
||||
const collect = (value: string, previous: string[] = []): string[] => [...previous, value]
|
||||
|
||||
/** The launcher's own help text; each app prints its own. */
|
||||
const HELP_EXAMPLES = `
|
||||
Examples:
|
||||
dsh --profile web boot the web profile (same as: dsh web)
|
||||
dsh --profile headless "run the tests" answer one task, print the result, and exit
|
||||
dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay
|
||||
dsh --profile tui --resume <session> arguments after the launcher flags reach the app
|
||||
dsh --profile web --help the web app's own flags and help
|
||||
dsh plugin --profile tui add <package> install a plugin into the tui profile
|
||||
`
|
||||
|
||||
/**
|
||||
* Resolve a boot or dump invocation from the launcher flags and the leftover
|
||||
* inner arguments.
|
||||
* @param program - the command whose options were parsed (the root, or the `web` alias).
|
||||
* @param profile - the profile these flags boot.
|
||||
* @param options - the launcher flags commander collected.
|
||||
* @param args - the leftover arguments, in argv order.
|
||||
* @returns the resolved invocation.
|
||||
*/
|
||||
function resolveBoot(program: Command, profile: string, options: BootOptions, args: string[]): DshInvocation {
|
||||
const patches = options.patch ?? []
|
||||
if (patches.includes('')) program.error('error: --patch needs a path')
|
||||
if (options.dumpConfig !== true && options.dumpDefaultConfig !== true) {
|
||||
return { mode: 'profile', profile, patches, args }
|
||||
}
|
||||
if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
|
||||
program.error('error: --dump-config and --dump-default-config are mutually exclusive')
|
||||
}
|
||||
// The dump is boot-free: it never runs the app's startup row, so it cannot
|
||||
// show what that app's flags would decide, and printing a tree that differs
|
||||
// from the same invocation's boot would mislead.
|
||||
if (args.length > 0) {
|
||||
program.error(`error: config dumps take no app arguments, got ${args.map(argument => JSON.stringify(argument)).join(' ')}`)
|
||||
}
|
||||
const defaultOnly = options.dumpDefaultConfig === true
|
||||
if (defaultOnly && patches.length > 0) {
|
||||
program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
|
||||
}
|
||||
return { mode: 'dump-config', profile, defaultOnly, patches }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve argv into one invocation, or print and exit for help, version, or an
|
||||
* error.
|
||||
@@ -95,121 +111,61 @@ const collect = (value: string, previous: string[] = []): string[] => [...previo
|
||||
*/
|
||||
export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
|
||||
let resolved: DshInvocation | undefined
|
||||
const program = new Command()
|
||||
// Annotated, not inferred: the actions below call back into `program`, and an
|
||||
// inferred type would be circular through its own chain.
|
||||
const program: Command = new Command()
|
||||
program
|
||||
.name('dsh')
|
||||
.version(version, '-V, --version', 'output the version number')
|
||||
.description('dsh: boot a DeepSeek Harness profile — an ordered stack of plugin-bundle patch layers under your own overrides.')
|
||||
.addHelpText('after', `
|
||||
Examples:
|
||||
dsh --profile web boot the web profile (same as: dsh web)
|
||||
dsh run "run the tests" answer one task, print the result, and exit
|
||||
dsh run --profile custom "run the tests" run one task through a custom one-shot profile
|
||||
dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay
|
||||
dsh plugin --profile tui add <package> install a plugin into the tui profile
|
||||
dsh web --port 8080 the web alias with its flag family
|
||||
`)
|
||||
.addHelpText('after', HELP_EXAMPLES)
|
||||
.exitOverride()
|
||||
// The launcher's flags come first and end at the first token it does not
|
||||
// know; everything from there on belongs to the booted app, including
|
||||
// its -h. `dsh -h` with no profile still prints this help, below.
|
||||
.helpOption(false)
|
||||
.allowUnknownOption()
|
||||
.passThroughOptions()
|
||||
.enablePositionalOptions()
|
||||
.argument('[args...]', 'arguments for the booted profile\'s app (see: dsh --profile <name> --help)')
|
||||
.option('--profile <name>', 'the profile under $DSH_HOME/profiles to boot')
|
||||
.option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
|
||||
.option('--dump-config', 'print the composed profile tree and exit')
|
||||
.option('--dump-default-config', 'print the profile tree without its user layer or --patch overlays and exit')
|
||||
.action((options: {
|
||||
profile?: string
|
||||
patch?: string[]
|
||||
dumpConfig?: boolean
|
||||
dumpDefaultConfig?: boolean
|
||||
}) => {
|
||||
const profile = options.profile ?? program.error('error: --profile <name> is required')
|
||||
if (profile === '') program.error('error: --profile needs a name')
|
||||
const patches = options.patch ?? []
|
||||
if (patches.includes('')) program.error('error: --patch needs a path')
|
||||
if (options.dumpConfig === true || options.dumpDefaultConfig === true) {
|
||||
if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
|
||||
program.error('error: --dump-config and --dump-default-config are mutually exclusive')
|
||||
}
|
||||
const defaultOnly = options.dumpDefaultConfig === true
|
||||
if (defaultOnly && patches.length > 0) {
|
||||
program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
|
||||
}
|
||||
resolved = { mode: 'dump-config', profile, defaultOnly, patches }
|
||||
return
|
||||
.action((args: string[], options: BootOptions & { profile?: string }) => {
|
||||
// With the app owning -h, the launcher's own help is what a bare
|
||||
// `dsh -h` (no profile to hand it to) must print.
|
||||
if (options.profile === undefined) {
|
||||
if (args.some(argument => argument === '-h' || argument === '--help')) program.help()
|
||||
program.error('error: --profile <name> is required')
|
||||
}
|
||||
resolved = { mode: 'profile', profile, patches }
|
||||
const profile = options.profile
|
||||
if (profile === '') program.error('error: --profile needs a name')
|
||||
resolved = resolveBoot(program, profile, options, args)
|
||||
})
|
||||
|
||||
/** Reject parent options supplied before a subcommand. */
|
||||
const rejectParentOptions = (command: string): void => {
|
||||
const parent = program.opts<{
|
||||
profile?: string
|
||||
patch?: string[]
|
||||
dumpConfig?: boolean
|
||||
dumpDefaultConfig?: boolean
|
||||
}>()
|
||||
const parent = program.opts<BootOptions & { profile?: string }>()
|
||||
if (parent.profile !== undefined || parent.patch !== undefined
|
||||
|| parent.dumpConfig !== undefined || parent.dumpDefaultConfig !== undefined) {
|
||||
program.error(`error: ${command} takes none of parent --profile, --patch, --dump-config, or --dump-default-config`)
|
||||
}
|
||||
}
|
||||
|
||||
const run = program.command('run').description('run one task through a profile mounting the headless runner')
|
||||
run
|
||||
.option('--profile <name>', 'one-shot profile under $DSH_HOME/profiles', 'headless')
|
||||
.option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
|
||||
.argument('<task...>', 'task text')
|
||||
.action((task: string[], options: RunOptions) => {
|
||||
rejectParentOptions('run')
|
||||
const profile = options.profile
|
||||
if (profile === '') program.error('error: --profile needs a name')
|
||||
const patches = options.patch ?? []
|
||||
if (patches.includes('')) program.error('error: --patch needs a path')
|
||||
const joined = task.join(' ')
|
||||
if (joined.trim() === '') program.error('error: run needs a non-blank task')
|
||||
resolved = { mode: 'run', profile, patches, task: joined }
|
||||
})
|
||||
|
||||
const web = program.command('web').description('serve the browser UI (alias of --profile web) on the configured host and port')
|
||||
const web = program.command('web').description('boot the web profile (alias of --profile web); the web app\'s own flags follow')
|
||||
web
|
||||
.helpOption(false)
|
||||
.allowUnknownOption()
|
||||
.passThroughOptions()
|
||||
.enablePositionalOptions()
|
||||
.argument('[args...]', 'arguments for the web app (see: dsh web --help)')
|
||||
.option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
|
||||
.option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine')
|
||||
.option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
|
||||
.option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)')
|
||||
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
|
||||
.option('--dump-config', 'print the composed web-profile tree (with the user layer and any --patch) and exit')
|
||||
.option('--dump-default-config', 'print the web profile\'s bundle layers (no user layer) and exit')
|
||||
.action((options: WebOptions) => {
|
||||
.action((args: string[], options: BootOptions) => {
|
||||
rejectParentOptions('web')
|
||||
const patches = options.patch ?? []
|
||||
if (patches.includes('')) program.error('error: --patch needs a path')
|
||||
if (options.dumpConfig === true || options.dumpDefaultConfig === true) {
|
||||
if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
|
||||
program.error('error: --dump-config and --dump-default-config are mutually exclusive')
|
||||
}
|
||||
const defaultOnly = options.dumpDefaultConfig === true
|
||||
if (defaultOnly && patches.length > 0) {
|
||||
program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
|
||||
}
|
||||
// The dump is boot-free and does not derive flag patches; silently
|
||||
// dropping them would print a tree that differs from the same
|
||||
// invocation's boot.
|
||||
if (options.host !== undefined || options.port !== undefined || options.dev === true
|
||||
|| options.trustedHost !== undefined) {
|
||||
program.error('error: config dumps take no web flags (--host/--port/--dev/--trusted-host)')
|
||||
}
|
||||
resolved = { mode: 'dump-config', profile: 'web', defaultOnly, patches }
|
||||
return
|
||||
}
|
||||
if (options.port !== undefined && !/^\d+$/.test(options.port)) {
|
||||
program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`)
|
||||
}
|
||||
resolved = {
|
||||
mode: 'web',
|
||||
patches,
|
||||
...options.host !== undefined && { host: options.host },
|
||||
...options.port !== undefined && { port: Number(options.port) },
|
||||
dev: options.dev === true,
|
||||
...options.trustedHost !== undefined && { trustedHosts: options.trustedHost },
|
||||
}
|
||||
resolved = resolveBoot(web, 'web', options, args)
|
||||
})
|
||||
|
||||
const plugin = program.command('plugin').description('manage a profile\'s plugins by forwarding the remaining arguments to pnpm in the profile directory')
|
||||
|
||||
Reference in New Issue
Block a user