diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index b929dc73f2..5cdf9d20d4 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -2,19 +2,24 @@ * Commander adapter for the `dsh` command-line entry: the one place argv is * parsed and routed to a mode. `bin.ts` switches on the returned discriminant * and dynamic-imports that mode's module. One program: the default (no - * subcommand) is the TUI/headless surface with option-only flags; `web` is a - * real subcommand. Commander owns `--help`/`--version` and parse errors — it - * prints and exits at the point of failure (a domain failure routes through + * subcommand) is the TUI/headless surface with option-only flags; `meta` and + * `web` are real subcommands. Commander owns `--help`/`--version` and parse + * errors — it prints and exits at the point of failure (a domain failure routes through * `command.error`), so this returns only a resolved mode. * @module @deepseek-ai/dsh/args */ import { Command, CommanderError } from 'commander' -/** Interactive TUI: the default mode. `--config` swaps the tree; `--resume ` rehydrates a session. */ +/** + * Interactive TUI: the default mode. `--config` applies an overlay over the + * shipped composition in place of the personal one, `--config-replace` boots a + * file as the whole tree instead, and `--resume ` rehydrates a session. + */ interface TuiInvocation { mode: 'tui' config?: string + configReplace?: string resume?: string } @@ -24,6 +29,27 @@ interface HeadlessInvocation { prompt: string } +/** + * Interactive TUI over this harness checkout: `dsh meta`. Identical to + * {@link TuiInvocation} except the workspace is the launcher's own source tree + * rather than the invoking directory. No `--config`: booting a foreign tree + * against the harness workspace is the `--config` case, not this one. + */ +interface MetaInvocation { + mode: 'meta' + resume?: string +} + +/** + * Guided fresh-session entries: `dsh migrate` seeds the first turn with the + * `dsh-migrate` skill, `dsh upgrade` with `dsh-upgrade`. Each always mints a + * fresh session in the invoking directory and takes no options — `--resume`, + * `--config`, and `-p` are rejected as mistyped, so there is nothing to carry. + */ +interface SkillSessionInvocation { + mode: 'migrate' | 'upgrade' +} + /** * Browser UI: `dsh web`. `host`/`port` are present only when the flag was * passed — pass-through overrides with no CLI default and no CLI validation: @@ -36,6 +62,8 @@ interface HeadlessInvocation { */ interface WebInvocation { mode: 'web' + /** Overlay of loader patches applied over the shipped web composition. */ + config?: string host?: string port?: number dev: boolean @@ -45,10 +73,16 @@ interface WebInvocation { } /** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */ -export type DshInvocation = TuiInvocation | HeadlessInvocation | WebInvocation +export type DshInvocation = + | TuiInvocation + | HeadlessInvocation + | MetaInvocation + | SkillSessionInvocation + | WebInvocation /** Raw web-subcommand options straight from Commander. */ interface WebOptions { + config?: string host?: string port?: string dev?: boolean @@ -65,6 +99,7 @@ interface WebOptions { function resolveWeb(options: WebOptions): WebInvocation { return { mode: 'web', + ...options.config !== undefined && { config: options.config }, ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, dev: options.dev === true, @@ -86,21 +121,31 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc const program = new Command() .name('dsh') .version(version, '-V, --version', 'output the version number') - .description('dsh: interactive TUI (default), headless task, and browser UI') + .description('dsh: DeepSeek Harness — an interactive coding agent for your terminal.\nRun `dsh` with no arguments to start a session in the current directory.') + // The default surface takes no positional task, so `dsh "task"` fails + // commander's arity check with no hint; these examples are where a first + // reader learns the entry points and that a one-shot task rides `-p`. + .addHelpText('after', ` +Examples: + dsh start an interactive session in this directory + dsh -p "run the tests" answer one task, print the result, and exit + dsh --resume continue a past session +`) .exitOverride() // Default surface: option-only (no positional), so `web` can be a real // subcommand without a positional collision. - .option('--config ', 'boot an alternate cordis.yml instead of the shipped tree (TUI mode)') - .option('-p, --prompt ', 'run one headless turn for this task, print the result, and exit') - .option('--resume ', 'resume the persisted session with this id (TUI mode)') - .action((options: { config?: string; prompt?: string; resume?: string }) => { + .option('-p, --prompt ', 'answer this task without the interactive UI, then exit') + .option('--resume ', 'continue a past session by id') + .option('--config ', 'apply this overlay of loader patches instead of the personal one') + .option('--config-replace ', 'boot this file as the entire tree, ignoring the shipped and personal configuration') + .action((options: { config?: string; configReplace?: string; prompt?: string; resume?: string }) => { if (options.prompt !== undefined) { // A headless prompt owns the invocation; an empty task has nothing to // run, and --config/--resume are TUI inputs that must not silently // vanish from a headless run. if (options.prompt === '') program.error('error: --prompt needs a task') - if (options.config !== undefined || options.resume !== undefined) { - program.error('error: --prompt takes no --config or --resume') + if (options.config !== undefined || options.configReplace !== undefined || options.resume !== undefined) { + program.error('error: --prompt takes no --config, --config-replace, or --resume') } resolved = { mode: 'headless', prompt: options.prompt } return @@ -108,32 +153,89 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc // An empty --resume= id would silently start a fresh session downstream // (agent-loop treats '' as no-resume), so a mistyped resume must fail loud. if (options.resume === '') program.error('error: --resume needs a session id') + // The two config flags are mutually exclusive: one layers over the shipped + // tree, the other discards it, so accepting both would silently drop one. + if (options.config !== undefined && options.configReplace !== undefined) { + program.error('error: --config and --config-replace are mutually exclusive') + } resolved = { mode: 'tui', ...options.config !== undefined && { config: options.config }, + ...options.configReplace !== undefined && { configReplace: options.configReplace }, ...options.resume !== undefined && { resume: options.resume }, } }) - const web = program.command('web').description('serve the browser UI (host/port default to the shipped config)') + // Commander parses the parent (default-surface) options on either side of a + // subcommand into `program.opts()`. For a subcommand that shares none of them, + // a leaked `--config`/`-p`/`--resume` is a mistyped invocation that must fail + // loud rather than silently run and drop the input. + const rejectParentOptions = (command: string): void => { + const parent = program.opts<{ config?: string; configReplace?: string; prompt?: string; resume?: string }>() + if (parent.config !== undefined || parent.configReplace !== undefined + || parent.prompt !== undefined || parent.resume !== undefined) { + program.error(`error: ${command} takes none of --config, -p/--prompt, or --resume`) + } + } + + // Registration order is the rendered help order, so daily use comes first + // and the harness-development surfaces (`web --dev`, `meta`) come last. + // `migrate` and `upgrade` are guided fresh-session entries: they take no + // options and always mint a fresh session, so nothing is left to carry. Each + // description names the outcome, not the skill the first turn invokes. + const guided = { + migrate: 'import settings from another coding agent (Claude Code, Codex, opencode)', + upgrade: 'update this dsh installation to the latest version', + } as const + for (const mode of ['migrate', 'upgrade'] as const) { + program + .command(mode) + .description(guided[mode]) + .action(() => { + rejectParentOptions(mode) + resolved = { mode } + }) + } + + // Host and port name no default: the CLI passes neither through when the flag + // is absent, so the shipped `cordis.yml` value stands and restating it here + // would duplicate a fact this file does not own. + const web = program.command('web').description('serve the browser UI on the configured host and port') web - .option('--host ', 'override the config bind host (127.0.0.1 or 0.0.0.0)') - .option('--port ', 'override the config listen port (0 requests an OS-assigned port)') - .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds') - .option('--workspace-root ', 'parent directory for name-created workspaces') + .option('--config ', 'apply this overlay of loader patches over the shipped configuration') + .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') + .option('--port ', 'listen port; pass 0 to let the OS pick a free one') + .option('--dev', 'developer mode: hot-reload the browser client') + .option('--workspace-root ', 'parent directory for workspaces created from the browser UI') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .action((options: WebOptions) => { - // Commander parses the parent (default-surface) options on either side of - // the subcommand into `program.opts()`. `web` shares none of them, so a - // leaked `--config`/`-p`/`--resume` is a mistyped invocation that must - // fail loud rather than silently start the web server and drop it. - const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>() - if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) { - program.error('error: web takes none of --config, -p/--prompt, or --resume') - } + rejectParentOptions('web') resolved = resolveWeb(options) }) + // `--resume` is NOT redeclared here: an option a subcommand shares with its + // parent parses into `program.opts()` and leaves the subcommand's own options + // empty, so redeclaring it would silently drop the id. Commander therefore + // omits it from this subcommand's option list, hence the trailing help text. + program + .command('meta') + .description('work on the dsh source that runs this command, from any directory') + .addHelpText('after', '\nAccepts --resume to resume a persisted session from this checkout.\n') + .action(() => { + // Commander parses the parent (default-surface) options on either side of + // the subcommand into `program.opts()`. `meta` accepts only `--resume`, so + // a leaked `--config`/`-p` is a mistyped invocation that must fail loud + // rather than silently be dropped. + const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>() + if (parent.config !== undefined || parent.prompt !== undefined) { + program.error('error: meta takes neither --config nor -p/--prompt') + } + // Same reason as the default surface: an empty id would start a fresh + // session downstream instead of failing the mistyped resume. + if (parent.resume === '') program.error('error: --resume needs a session id') + resolved = { mode: 'meta', ...parent.resume !== undefined && { resume: parent.resume } } + }) + try { program.parse(argv, { from: 'user' }) } catch (error) { diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 88dbece55a..c5003cf795 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -30,7 +30,7 @@ const invocation = parseDshArgs(process.argv.slice(2), readVersion()) switch (invocation.mode) { case 'web': { const { runWeb } = await import('./web.ts') - await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts) + await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts, invocation.config) break } case 'headless': { @@ -40,7 +40,18 @@ switch (invocation.mode) { } case 'tui': { const { runTui } = await import('./tui.ts') - await runTui(invocation.config, invocation.resume) + await runTui(invocation.config, invocation.resume, undefined, undefined, invocation.configReplace) + break + } + case 'meta': { + const { runMeta } = await import('./tui.ts') + await runMeta(invocation.resume) + break + } + case 'migrate': + case 'upgrade': { + const { runSkillSession } = await import('./tui.ts') + await runSkillSession(`dsh-${invocation.mode}`) break } default: diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index d7588ccae4..8007b30d44 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -75,7 +75,8 @@ async function consumeUntilTurnEnd(frames: AsyncIterable>, export async function runHeadless(task: string): Promise { // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). const entry = new AppCLIEntry({ - configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), + configPath: fileURLToPath(new URL('../base.cordis.yml', import.meta.url)), + overlayPath: fileURLToPath(new URL('../web.cordis.yml', import.meta.url)), dev: false, port: 0, }) diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 69e79ab5d9..85f79d5fc4 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -7,9 +7,12 @@ */ import { fileURLToPath } from 'node:url' +import { resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import { AppCLIEntry } from './app-cli-entry.ts' -const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) +// The shared core every `dsh` surface mounts, plus this surface's overlay over it. +const BASE_CONFIG = fileURLToPath(new URL('../base.cordis.yml', import.meta.url)) +const WEB_OVERLAY = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) // Display-only mirror of the webserver schema's loopback host: the address the // local URL always prints. Not a source of truth — the schema is. @@ -23,6 +26,8 @@ const LOOPBACK_HOST = '127.0.0.1' * @param dev - mount the client HMR driver and watch plugin bundles for rebuilds. * @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback. * @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone. + * @param config - an overlay of loader patches applied over the shipped web + * composition, or `undefined` for none; already parsed from `--config`. */ export async function runWeb( host: string | undefined, @@ -30,9 +35,12 @@ export async function runWeb( dev: boolean, workspaceRoot: string | undefined, trustedHosts: string[] | undefined, + config?: string, ): Promise { const entry = new AppCLIEntry({ - configPath: CONFIG_PATH, + configPath: BASE_CONFIG, + overlayPath: WEB_OVERLAY, + ...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) }, dev, ...host !== undefined && { host }, ...port !== undefined && { port }, diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 45830eee30..831a969d1f 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -24,17 +24,28 @@ function exitCode(argv: string[]): number { afterEach(() => { vi.restoreAllMocks() }) describe('parseDshArgs', () => { - it('routes each mode by its shape: default TUI, -p headless, web subcommand', () => { + it('routes each mode by its shape: default TUI, -p headless, meta and web subcommands', () => { expect(parse([])).toEqual({ mode: 'tui' }) expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' }) expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) + // `meta` accepts `--resume` but does not redeclare it: a shared option parses + // into program.opts() on either side of the subcommand, and redeclaring it + // would leave the subcommand's own options empty and drop the id. + expect(parse(['meta'])).toEqual({ mode: 'meta' }) + expect(parse(['meta', '--resume', 'sess'])).toEqual({ mode: 'meta', resume: 'sess' }) + expect(parse(['--resume', 'sess', 'meta'])).toEqual({ mode: 'meta', resume: 'sess' }) + // Credential setup is option-free: it writes the Harness-home .env, so + // there is nothing for a flag to select. // Bare `web` carries no host/port: the shipped cordis.yml owns the default. expect(parse(['web'])).toEqual({ mode: 'web', dev: false }) // Host/port are unvalidated pass-throughs (the webserver schema gates them // at boot); the adapter only coerces the port string to a number. expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' }) + // Guided fresh-session entries carry nothing: bare mode discriminant only. + expect(parse(['migrate'])).toEqual({ mode: 'migrate' }) + expect(parse(['upgrade'])).toEqual({ mode: 'upgrade' }) // --trusted-host is variadic and repeatable; authorities pass through unvalidated. expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) .toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) @@ -55,6 +66,21 @@ describe('parseDshArgs', () => { expect(exitCode(['web', '-p', 'task'])).toBe(1) expect(exitCode(['web', '--resume', 's'])).toBe(1) expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1) + // Same rule for credential setup: it shares no option with the default + // surface, so a leaked flag is a typo, not something to ignore. + // `meta` fixes its own config tree and is interactive, so --config/-p are + // rejected; an empty id is swallowed downstream exactly as above. + expect(exitCode(['meta', '--resume='])).toBe(1) + expect(exitCode(['meta', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['meta', '-p', 'task'])).toBe(1) + // `migrate`/`upgrade` take no options: any leaked default-surface flag is a + // mistyped invocation, not a silently-dropped input. + expect(exitCode(['migrate', '--resume', 's'])).toBe(1) + expect(exitCode(['migrate', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['migrate', '-p', 'task'])).toBe(1) + expect(exitCode(['upgrade', '--resume', 's'])).toBe(1) + expect(exitCode(['upgrade', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['-p', 'task', 'upgrade'])).toBe(1) }) it('exits 0 for --help (disclosing web) and --version', () => {