Kaynağa Gözat

fix(cli): preserve overlays without session registration

Turtle 2 ay önce
ebeveyn
işleme
c1324ee896

+ 125 - 23
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 <id>` 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 <id>` 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 <id>       continue a past session
+`)
     .exitOverride()
     // Default surface: option-only (no positional), so `web` can be a real
     // subcommand without a positional collision.
-    .option('--config <path>', 'boot an alternate cordis.yml instead of the shipped tree (TUI mode)')
-    .option('-p, --prompt <task>', 'run one headless turn for this task, print the result, and exit')
-    .option('--resume <id>', 'resume the persisted session with this id (TUI mode)')
-    .action((options: { config?: string; prompt?: string; resume?: string }) => {
+    .option('-p, --prompt <task>', 'answer this task without the interactive UI, then exit')
+    .option('--resume <id>', 'continue a past session by id')
+    .option('--config <path>', 'apply this overlay of loader patches instead of the personal one')
+    .option('--config-replace <path>', '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,30 +153,87 @@ 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 <host>', 'override the config bind host (127.0.0.1 or 0.0.0.0)')
-    .option('--port <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 <path>', 'parent directory for name-created workspaces')
+    .option('--config <path>', 'apply this overlay of loader patches over the shipped configuration')
+    .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', 'developer mode: hot-reload the browser client')
+    .option('--workspace-root <path>', 'parent directory for workspaces created from the browser UI')
     .option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
     .action((options: WebOptions) => {
+      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 <id> 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()`. `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.
+      // 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 || parent.resume !== undefined) {
-        program.error('error: web takes none of --config, -p/--prompt, or --resume')
+      if (parent.config !== undefined || parent.prompt !== undefined) {
+        program.error('error: meta takes neither --config nor -p/--prompt')
       }
-      resolved = resolveWeb(options)
+      // 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 {

+ 13 - 2
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:

+ 2 - 1
apps/cli/src/headless.ts

@@ -75,7 +75,8 @@ async function consumeUntilTurnEnd(frames: AsyncIterable<RpcRequest<MuxFrame>>,
 export async function runHeadless(task: string): Promise<void> {
   // 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,
   })

+ 10 - 2
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<void> {
   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 },

+ 27 - 1
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', () => {