Prechádzať zdrojové kódy

refactor(cli): unify the arg grammar — one program, --config flag, real web subcommand

Drop the bare `dsh <config>` positional in favor of a `--config <path>` flag.
Without a root positional, `web` can be a real Commander subcommand in one
program instead of the reserved-first-token dispatch to a second parser, so
`dsh --help` lists every mode natively (no hand-pasted command text) and the
second parser + reserved-token machinery are gone.

Grammar:
  dsh                       TUI (shipped tree + ~/.dsh overlay)
  dsh --config <path>       TUI, alternate tree (demos/tests only)
  dsh --resume <id>         TUI, resume a session
  dsh -p "task"             headless one-shot
  dsh web [--host --port --dev]

`dsh` is the product front door with no positional; `--config` exists only so
demo:cordis, demo:code-mode, and the keyless PTY smokes can point the shipped
bin at an example tree. Those three sites and the /resume re-exec argv move to
`--config <path>`. The `-p` + `--config`/`--resume` mode-mixing guard and the
cordis.yml-owns-host/port-default fix are preserved.

Agent Note + Chinese pair, README, tui.ts docs updated. All 13 PTY smokes
(including code-mode via --config and the exec-replace resume handoff) green.
Turtle 1 mesiac pred
rodič
commit
fca2dda37d

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write
-2026-07-24-dsh-commander-argument-adapter.md: f90c4fb8d428eabed353176d98dce0fb9e34bf99
-2026-07-24-dsh-commander-argument-adapter.zh.md: fc0d1aa588ca6ce3b8c9d0b59343c4af698103da
+2026-07-24-dsh-commander-argument-adapter.md: e023d9ff296dd4a4024824865358964c8a66f49a
+2026-07-24-dsh-commander-argument-adapter.zh.md: 762e3e4b1609e9bc6f9f5bd5cc509c4084573833

+ 8 - 6
.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md

@@ -12,17 +12,19 @@ The `dsh` CLI entry (`apps/cli`) parsed argv in three hand-rolled idioms that di
 
 Argv is parsed once, in `apps/cli/src/args.ts`, through a Commander adapter (the same parser the SDK bins — `create-sdk`, `dsh-scripts` — already standardize on). `parseDshArgs(argv, version)` returns a discriminated `DshInvocation` union of the three real modes: `{ mode: 'tui', config?, resume? }`, `{ mode: 'headless', prompt }`, or `{ mode: 'web', host?, port?, dev }`. It does **not** model help/version/errors as data: Commander owns those, printing usage or the diagnostic and exiting at the point of failure. `exitOverride()` turns each into a thrown `CommanderError` carrying the intended code (0 for help/version, 1 for a parse or domain error), which one `try/catch` in `parseDshArgs` turns into `process.exit`.
 
-`bin.ts` calls the adapter once and switches on `mode` (closed union, `satisfies never` default), dynamic-importing only the chosen mode's module; only a valid, non-help invocation reaches the switch, so it has no help/version/error cases. Each mode module consumes already-parsed values: `runTui(config, resume)`, `runHeadless(task)`, `runWeb(host, port, dev)` — none re-reads argv. `web` is a **reserved first token**: `parseDshArgs` dispatches a leading `web` to its own Commander parser and everything else to the default TUI/headless parser, so root flags and `web` flags never share a grammar — `dsh web -p x` fails loud (`web` has no `-p`). Each parser reads Commander's `opts()`/`processedArgs` after `parse()`, then bails via `command.error(...)` (print + exit 1) on the domain checks Commander cannot express: `--prompt` selects headless and rejects an empty task or a stray config/`--resume` rather than silently dropping a TUI input; an empty `--resume=` id fails loud (agent-loop treats `''` as no-resume); when `--host`/`--port` are given, `--host` must be loopback/all-interfaces and `--port` an integer in 0–65535 (validation moved from the inline `runWeb` checks into the parser). The adapter assigns **no** default for host/port: an absent flag leaves the field undefined, `runWeb` forwards it to `AppCLIEntry` only when present, and the shipped `apps/cli/cordis.yml` `webserver` row is the single source of the host/port default (patched only by an explicit flag). `--dev` mounts the client HMR driver and bundle watch. A repeated `--resume`, or a following flag captured as a `--resume`/`--prompt` value, is Commander's standard behavior (last-wins / next-token) and is left alone; a bad id fails loud downstream when the session cannot load. `dsh --help` discloses the `web` mode through an `addHelpText` line (a real `web` subcommand would hijack the `[config]` positional). `--version` reads this app's `package.json`.
+`bin.ts` calls the adapter once and switches on `mode` (closed union, `satisfies never` default), dynamic-importing only the chosen mode's module; only a valid, non-help invocation reaches the switch, so it has no help/version/error cases. Each mode module consumes already-parsed values: `runTui(config, resume)`, `runHeadless(task)`, `runWeb(host, port, dev)` — none re-reads argv. It is **one Commander program**: the default surface (no subcommand) carries option-only flags — `--config <path>`, `-p/--prompt <task>`, `--resume <id>` — and `web` is a real `program.command('web')` subcommand. The default surface takes no positional argument, which is what lets `web` be a real subcommand without a positional collision, so `dsh --help` lists `web` natively (no hand-pasted command text). The default action and the `web` action set the resolved mode, then bail via `command.error(...)` (print + exit 1) on the domain checks Commander cannot express: `--prompt` selects headless and rejects an empty task or a `--config`/`--resume` alongside it rather than silently dropping a TUI input; an empty `--resume=` id fails loud (agent-loop treats `''` as no-resume); when `--host`/`--port` are given, `--host` must be loopback/all-interfaces and `--port` an integer in 0–65535 (validation moved from the inline `runWeb` checks into the parser). The adapter assigns **no** default for host/port: an absent flag leaves the field undefined, `runWeb` forwards it to `AppCLIEntry` only when present, and the shipped `apps/cli/cordis.yml` `webserver` row is the single source of the host/port default (patched only by an explicit flag). `--dev` mounts the client HMR driver and bundle watch. A repeated `--resume`, or a following flag captured as a `--resume`/`--prompt` value, is Commander's standard behavior (last-wins / next-token) and is left alone; a bad id fails loud downstream when the session cannot load. `--version` reads this app's `package.json`.
+
+`--config <path>` replaces an earlier positional config argument. `dsh` is the product front door with no positional; the flag exists only so the demo/test call sites (`demo:cordis`, `demo:code-mode`, the keyless PTY smokes) can point the shipped bin at an alternate example tree. A bare `dsh` boots the shipped tree plus the `~/.dsh/config.yaml` personal overlay; a real user never passes `--config`.
 
 `parseResumeArg` is deleted from `dsh-app-boot` (its export, its README row, and its unit block); the pre-release stance permits the removal. `dsh-app-boot` keeps its boot/env/config/personal-overlay helpers — only the argv scanner leaves.
 
 ## Resume without an environment variable
 
-Merging the concurrent safe-session-resume feature onto this parser retired the `RESUME_SESSION_ID` environment variable, which had been the only bridge from `--resume` into the shipped config's `resumeSessionId: !!js process.env.RESUME_SESSION_ID`. `runTui` now injects the already-parsed id through `boot`'s `prepare(ctx)` hook — `ctx.provide(RESUME_SESSION_ID_KEY, id)` (a new `dsh-app-boot` export, value `'resumeSessionId'`) — and the four tui-agent/cordis configs read it as a bare identifier: `resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`. The expression is quoted because YAML otherwise parses the `?`/`:` as a mapping; the `typeof` guard tolerates a launcher that never provides the slot. The `/resume` in-place handoff (`process.execve`) rebuilds its re-exec argv directly from the parsed values as `dsh --resume=<id> [-- <config>]` — the `--` keeps a config named `web` or starting with `-` a positional — so `replaceResumeArg` (which the merge brought in) is dropped alongside `parseResumeArg`.
+Merging the concurrent safe-session-resume feature onto this parser retired the `RESUME_SESSION_ID` environment variable, which had been the only bridge from `--resume` into the shipped config's `resumeSessionId: !!js process.env.RESUME_SESSION_ID`. `runTui` now injects the already-parsed id through `boot`'s `prepare(ctx)` hook — `ctx.provide(RESUME_SESSION_ID_KEY, id)` (a new `dsh-app-boot` export, value `'resumeSessionId'`) — and the four tui-agent/cordis configs read it as a bare identifier: `resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`. The expression is quoted because YAML otherwise parses the `?`/`:` as a mapping; the `typeof` guard tolerates a launcher that never provides the slot. The `/resume` in-place handoff (`process.execve`) rebuilds its re-exec argv directly from the parsed values as `dsh --resume=<id> [--config <path>]`, so `replaceResumeArg` (which the merge brought in) is dropped alongside `parseResumeArg`.
 
 ## One terminal front door: `dsh`
 
-The `dsh-tui-demo` package was a plugin (the TUI app bundle mounted by `dsh`'s config) plus a redundant `bin` that booted a leaf `cordis.yml` — the same job `dsh [config]` does. The bin is removed: `demo:cordis`, `demo:code-mode`, and both the tui-agent and cordis-agent keyless PTY smokes now launch through `apps/cli/src/bin.ts` with the config as the positional argument, and the package keeps only its plugin and invariant entries. The peer/dev `dsh-app-boot` dependency, the `bin`/`./bin` export, the demo's `built-bin.e2e.ts`, and the tsdown `bin` entry all leave with it. `dsh`'s own TTY guard (refuse piped stdio before booting, pointing at `dsh -p` for automation) gains a matching `apps/cli/tests/built-bin.e2e.ts` that runs the built `lib/bin.js` under plain Node with piped stdio (`apps/*/tests` added to the e2e vitest include). `cli-demo`, `acp-demo`, and `jsonrpc-demo` keep their bins because each is a distinct surface (headless, ACP, JSON-RPC) `dsh` does not provide.
+The `dsh-tui-demo` package was a plugin (the TUI app bundle mounted by `dsh`'s config) plus a redundant `bin` that booted a leaf `cordis.yml` — the same job `dsh --config <path>` does. The bin is removed: `demo:cordis`, `demo:code-mode`, and both the tui-agent and cordis-agent keyless PTY smokes now launch through `apps/cli/src/bin.ts` with `--config <path>`, and the package keeps only its plugin and invariant entries. The peer/dev `dsh-app-boot` dependency, the `bin`/`./bin` export, the demo's `built-bin.e2e.ts`, and the tsdown `bin` entry all leave with it. `dsh`'s own TTY guard (refuse piped stdio before booting, pointing at `dsh -p` for automation) gains a matching `apps/cli/tests/built-bin.e2e.ts` that runs the built `lib/bin.js` under plain Node with piped stdio (`apps/*/tests` added to the e2e vitest include). `cli-demo`, `acp-demo`, and `jsonrpc-demo` keep their bins because each is a distinct surface (headless, ACP, JSON-RPC) `dsh` does not provide.
 
 ## Package topology
 
@@ -34,17 +36,17 @@ The argument surface stays inside `apps/cli`, the assembly tier, not a `packages
 
 **Keep `parseResumeArg` as a shared helper and feed it Commander's residual args** — rejected: the whole point is to retire the bespoke scanner. Commander parses `--resume` (space and `=` forms, missing-value, position-independence) natively; keeping a parallel hand-written path for the one flag would preserve the duplication the change exists to end.
 
-**Make `web` a Commander subcommand of one root program** — rejected: a single program mixing a root `-p`/`--resume` grammar with a `web` subcommand leaks the root options onto `web` unless `enablePositionalOptions()` plus a parent-option guard are bolted on, which is exactly the kind of special-case machinery this change removes. Dispatching `web` as a reserved first token to a second parser is smaller and keeps the two grammars fully independent.
+**Keep the bare `dsh <config>` positional (and the reserved-`web`-token dispatch it forced)** — rejected: a root positional and a real `web` subcommand cannot coexist in one Commander program (the subcommand claims the first positional), which is why an earlier revision dispatched a reserved leading `web` token to a second parser and hand-pasted a `web` line into `--help`. The positional existed only so the demo/test sites could boot an alternate tree through the shipped bin. Replacing it with a `--config` flag frees the default surface of any positional, so `web` becomes a normal subcommand in one program with native `--help` — deleting the reserved-token dispatch, the second parser, and the pasted help text. `dsh` loses nothing a user wanted; the demos gain an explicit flag.
 
 **Make the argument surface a `packages/*` seam** — rejected: nothing outside `dsh` consumes it, and capability seams are not split preemptively. The Commander adapter is `apps/cli`'s own concern.
 
 **Keep `RESUME_SESSION_ID` as the resume bridge** — rejected: with `--resume` parsed into a value the bin already holds, threading it through an environment variable the config re-reads is indirection with no benefit, and it left the demo bin a second, env-only resume path. Providing the id on the boot context is the same channel `boot`'s `prepare` hook already uses for `tuiResumeHost`.
 
-**Keep the `dsh-tui-demo` bin** — rejected: it duplicated `dsh [config]` exactly, and keeping it forced the demo-only `RESUME_SESSION_ID` fallback to stay alive. Its plugin is what the configs actually mount; only the front-door bin was redundant, and `dsh` is the one terminal entry point.
+**Keep the `dsh-tui-demo` bin** — rejected: it duplicated `dsh --config <path>` exactly, and keeping it forced the demo-only `RESUME_SESSION_ID` fallback to stay alive. Its plugin is what the configs actually mount; only the front-door bin was redundant, and `dsh` is the one terminal entry point.
 
 ## Testing
 
-`apps/cli/tests/args.spec.ts` (new; `apps/*/tests` added to the vitest include and `apps/cli/tests` to `tsconfig.host.json`) covers the adapter at the level that matters: mode routing by shape (including `web --dev`), and the exit-code behavior for the fail-loud checks (empty resume/prompt, bad host/port, `--prompt` mixed with a config, unknown option) and `--help`/`--version`, captured through a `process.exit` spy. Both PTY smoke groups in `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` now drive the real `apps/cli/src/bin.ts`: the `tui-agent` group boots the config as a positional, and the `dsh CLI` group covers default boot, personal overlay, invalid config, the `--resume` config intake, the `process.execve` in-place resume handoff, and the source-path prompt. `examples/cordis-agent/tests/keyless-smoke.e2e.ts` likewise launches through `dsh`. `packages/ui/app-boot/tests/app-boot.spec.ts` drops its `parseResumeArg`/`replaceResumeArg` blocks; the TUI unit and snapshot fixtures use the `dsh --resume {session}` resume command.
+`apps/cli/tests/args.spec.ts` (new; `apps/*/tests` added to the vitest include and `apps/cli/tests` to `tsconfig.host.json`) covers the adapter at the level that matters: mode routing by shape (including `web --dev`), and the exit-code behavior for the fail-loud checks (empty resume/prompt, bad host/port, `--prompt` mixed with a config, unknown option) and `--help`/`--version`, captured through a `process.exit` spy. Both PTY smoke groups in `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` now drive the real `apps/cli/src/bin.ts`: the `tui-agent` group boots an example tree through `--config`, and the `dsh CLI` group covers default boot, personal overlay, invalid config, the `--resume` config intake, the `process.execve` in-place resume handoff, and the source-path prompt. `examples/cordis-agent/tests/keyless-smoke.e2e.ts` likewise launches through `dsh`. `packages/ui/app-boot/tests/app-boot.spec.ts` drops its `parseResumeArg`/`replaceResumeArg` blocks; the TUI unit and snapshot fixtures use the `dsh --resume {session}` resume command.
 
 ## Consequences
 

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md


+ 3 - 3
apps/cli/README.md

@@ -1,12 +1,12 @@
 # `@deepseek-ai/dsh`
 
-The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh [config.yml]` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI.
+The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI.
 
-Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)) that resolves the invocation into a single mode; `src/bin.ts` switches on that mode and dynamic-imports only the chosen mode's module. `dsh --help` and `dsh web --help` render usage, `dsh --version` prints this app's version, and an unknown option or an invalid `--host`/`--port`/`--resume` value fails loud (stderr, exit 1) instead of misrouting.
+Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or an invalid `--host`/`--port`/`--resume` value fails loud (stderr, exit 1) instead of misrouting.
 
 The TUI surface:
 
-- boots the shipped default config (`examples/tui-agent/cordis.yml`) or an explicit config argument, through [`dsh-app-boot`](../../packages/ui/app-boot/README.md);
+- boots the shipped default config (`examples/tui-agent/cordis.yml`), or the tree named by `--config <path>` (the demo/test escape for booting an alternate example tree), through [`dsh-app-boot`](../../packages/ui/app-boot/README.md);
 - resumes a persisted session with `dsh --resume <session-id>` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized `dsh --resume <id>`; runtimes without process replacement keep the displayed command fallback. The flag provides the id on the boot context under `RESUME_SESSION_ID_KEY` (no environment variable), which the shipped config reads through `!!js`, and a missing or unreadable id fails loud instead of creating a fresh session;
 - treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd;
 - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;

+ 69 - 60
apps/cli/src/args.ts

@@ -1,10 +1,11 @@
 /**
  * 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. 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.
- * The `web` subcommand is a reserved first token dispatched to its own parser.
+ * 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
+ * `command.error`), so this returns only a resolved mode.
  * @module @deepseek-ai/dsh/args
  */
 
@@ -15,7 +16,7 @@ export const LOOPBACK_HOST = '127.0.0.1'
 /** The all-interfaces host `dsh web` accepts to expose the UI on the LAN. */
 export const ALL_INTERFACES_HOST = '0.0.0.0'
 
-/** Interactive TUI: the default mode. Optional positional config and `--resume <id>`. */
+/** Interactive TUI: the default mode. `--config` swaps the tree; `--resume <id>` rehydrates a session. */
 interface TuiInvocation {
   mode: 'tui'
   config?: string
@@ -44,83 +45,91 @@ interface WebInvocation {
 /** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */
 export type DshInvocation = TuiInvocation | HeadlessInvocation | WebInvocation
 
-/** A `Command` under `exitOverride`, so {@link parseDshArgs} owns the exit, named for its usage line. */
-function program(name: string, version: string): Command {
-  return new Command().name(name).version(version, '-V, --version', 'output the version number').exitOverride()
+/** Raw web-subcommand options before validation. */
+interface WebOptions {
+  host?: string
+  port?: string
+  dev?: boolean
 }
 
-/** Parse `dsh web` arguments (everything after the `web` token). */
-function parseWeb(argv: readonly string[], version: string): WebInvocation {
-  // No Commander `default`: an absent flag leaves the option undefined so the
-  // shipped cordis.yml value stands (the single source of the host/port default).
-  const web = program('dsh web', version)
-    .description('serve the browser UI (host/port default to the shipped config)')
-    .option('--host <host>', `bind host (${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST})`)
-    .option('--port <port>', 'listen port (0 requests an OS-assigned port)')
-    .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds')
-  web.parse(argv, { from: 'user' })
-  const { host, port, dev } = web.opts<{ host?: string; port?: string; dev?: boolean }>()
-  if (host !== undefined && host !== LOOPBACK_HOST && host !== ALL_INTERFACES_HOST) {
-    web.error(`error: --host must be ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}`)
+/** Validate and narrow the raw `web` options; a bad value fails loud via `command.error`. */
+function resolveWeb(command: Command, options: WebOptions): WebInvocation {
+  if (options.host !== undefined && options.host !== LOOPBACK_HOST && options.host !== ALL_INTERFACES_HOST) {
+    command.error(`error: --host must be ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}`)
   }
-  let portNumber: number | undefined
-  if (port !== undefined) {
-    portNumber = Number(port)
-    if (!/^\d+$/.test(port) || !Number.isInteger(portNumber) || portNumber > 65535) {
-      web.error('error: --port must be an integer in 0-65535')
+  let port: number | undefined
+  if (options.port !== undefined) {
+    port = Number(options.port)
+    if (!/^\d+$/.test(options.port) || !Number.isInteger(port) || port > 65535) {
+      command.error('error: --port must be an integer in 0-65535')
     }
   }
   return {
     mode: 'web',
-    ...host !== undefined && { host },
-    ...portNumber !== undefined && { port: portNumber },
-    dev: dev === true,
+    ...options.host !== undefined && { host: options.host },
+    ...port !== undefined && { port },
+    dev: options.dev === true,
   }
 }
 
-/** Parse the default (TUI / headless) arguments: `[config]`, `-p/--prompt`, `--resume`. */
-function parseRoot(argv: readonly string[], version: string): DshInvocation {
-  const root = program('dsh', version)
-    .description('dsh: interactive TUI, headless task, and browser UI')
-    .argument('[config]', 'config to boot instead of the shipped default (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)')
-    // Disclose the web mode in `dsh --help`; a real `web` subcommand would
-    // hijack the `[config]` positional. `parseDshArgs` intercepts `web` first.
-    .addHelpText('after', '\nCommands:\n  web            serve the browser UI (run `dsh web --help`)')
-  root.parse(argv, { from: 'user' })
-  const { prompt, resume } = root.opts<{ prompt?: string; resume?: string }>()
-  const config = root.processedArgs[0] as string | undefined
-
-  if (prompt !== undefined) {
-    // A headless prompt owns the invocation; an empty task has nothing to run,
-    // and a config or --resume alongside it is a TUI input that must not
-    // silently vanish from the run.
-    if (prompt === '') root.error('error: --prompt needs a task')
-    if (config !== undefined || resume !== undefined) root.error('error: --prompt takes no config or --resume')
-    return { mode: 'headless', prompt }
-  }
-  // 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 (resume === '') root.error('error: --resume needs a session id')
-  return { mode: 'tui', ...config !== undefined && { config }, ...resume !== undefined && { resume } }
-}
-
 /**
  * Resolve the raw argv into a {@link DshInvocation}, or print and exit for
- * `--help`/`--version`/a parse error. A leading `web` token dispatches to the
- * web parser; everything else is the default TUI/headless grammar.
+ * `--help`/`--version`/a parse error. The default (no subcommand) is the
+ * TUI/headless surface; `web` is a subcommand.
  * @param argv - the arguments after the node binary and script (`process.argv.slice(2)`).
  * @param version - the version string `--version` prints; read from this app's package.json.
  * @returns the resolved invocation (only reached on a valid, non-help invocation).
  */
 export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
+  let resolved: DshInvocation | undefined
+  const program = new Command()
+    .name('dsh')
+    .version(version, '-V, --version', 'output the version number')
+    .description('dsh: interactive TUI (default), headless task, and browser UI')
+    .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 }) => {
+      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')
+        }
+        resolved = { mode: 'headless', prompt: options.prompt }
+        return
+      }
+      // 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')
+      resolved = {
+        mode: 'tui',
+        ...options.config !== undefined && { config: options.config },
+        ...options.resume !== undefined && { resume: options.resume },
+      }
+    })
+
+  const web = program.command('web').description('serve the browser UI (host/port default to the shipped config)')
+  web
+    .option('--host <host>', `bind host (${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST})`)
+    .option('--port <port>', 'listen port (0 requests an OS-assigned port)')
+    .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds')
+    .action((options: WebOptions) => { resolved = resolveWeb(web, options) })
+
   try {
-    return argv[0] === 'web' ? parseWeb(argv.slice(1), version) : parseRoot(argv, version)
+    program.parse(argv, { from: 'user' })
   } catch (error) {
     // Commander printed help/version/the error under `exitOverride`; exit with
     // the code it chose (0 for help/version, 1 for a parse or domain error).
     /* v8 ignore next -- Commander only throws CommanderError from parse/error under exitOverride */
     return process.exit(error instanceof CommanderError ? error.exitCode : 1)
   }
+  /* v8 ignore next -- the default action or a subcommand action always resolves, or parse throws above */
+  if (resolved === undefined) throw new Error('dsh: no invocation resolved')
+  return resolved
 }

+ 4 - 5
apps/cli/src/tui.ts

@@ -1,6 +1,6 @@
 /**
  * `dsh` default surface — the interactive TUI coding agent. Boots the shipped
- * tui-agent config (or an explicit config argument) with the personal overlay
+ * tui-agent config (or the `--config` override) with the personal overlay
  * from the Harness home (`~/.dsh`): its `.env` fills environment gaps (precedence:
  * ambient environment, then the invoking directory's `.env`, then the personal one)
  * and its `config.yaml` patches the booted tree. The workspace is the invoking
@@ -42,7 +42,7 @@ const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
 /**
  * Run the interactive TUI from the invoking directory.
  * @param config - a config path to boot instead of the shipped default, or
- * `undefined` for the default; already parsed from the optional positional.
+ * `undefined` for the default; already parsed from `--config`.
  * @param resumeSessionId - a persisted session id to resume, or `undefined`;
  * already parsed and non-empty-validated from `--resume`. It is provided on the
  * boot context under {@link RESUME_SESSION_ID_KEY}, which the shipped config
@@ -73,14 +73,13 @@ export async function runTui(config: string | undefined, resumeSessionId: string
       const current = app.current
       if (current === undefined) throw new Error(`${NAME}: app boot has not completed`)
       // Rebuild argv from the parsed config plus the selected id: TUI mode's
-      // only arguments are the optional config positional and `--resume <id>`.
-      // The `--` guard keeps a config named like a flag or `web` a positional.
+      // only arguments are `--config <path>` and `--resume <id>`.
       const nextArgv = [
         process.execPath,
         ...process.execArgv,
         entry,
         `--resume=${sessionId}`,
-        ...config !== undefined ? ['--', config] : [],
+        ...config !== undefined ? ['--config', config] : [],
       ]
       try {
         await current.fiber.dispose()

+ 5 - 3
apps/cli/tests/args.spec.ts

@@ -26,8 +26,8 @@ afterEach(() => { vi.restoreAllMocks() })
 describe('parseDshArgs', () => {
   it('routes each mode by its shape: default TUI, -p headless, web subcommand', () => {
     expect(parse([])).toEqual({ mode: 'tui' })
-    expect(parse(['custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' })
-    expect(parse(['--resume', 'sess', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' })
+    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' })
     // Bare `web` carries no host/port: the shipped cordis.yml owns the default.
     expect(parse(['web'])).toEqual({ mode: 'web', dev: false })
@@ -43,8 +43,10 @@ describe('parseDshArgs', () => {
     expect(exitCode(['web', '--host', '10.0.0.1'])).toBe(1)
     expect(exitCode(['web', '--port', 'abc'])).toBe(1)
     expect(exitCode(['web', '--port='])).toBe(1)
-    expect(exitCode(['config.yml', '-p', 'x'])).toBe(1)
+    expect(exitCode(['-p', 'x', '--config', 'c.yml'])).toBe(1)
+    expect(exitCode(['-p', 'x', '--resume', 's'])).toBe(1)
     expect(exitCode(['--bogus'])).toBe(1)
+    expect(exitCode(['bogus-positional'])).toBe(1)
   })
 
   it('exits 0 for --help (disclosing web) and --version', () => {

+ 1 - 2
docs/module-graph.md

@@ -765,7 +765,6 @@ flowchart TD
   pkg_tui_demo --> pkg_agent
   pkg_tui_demo --> pkg_agent_loop
   pkg_tui_demo --> pkg_agent_spine_demo
-  pkg_tui_demo --> pkg_app_boot
   pkg_tui_demo --> pkg_command_goal
   pkg_tui_demo --> pkg_commands
   pkg_tui_demo --> pkg_invariants
@@ -919,4 +918,4 @@ flowchart TD
 | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
 | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
 | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
-| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`session-reference`](../packages/context/session-reference), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) |
+| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`session-reference`](../packages/context/session-reference), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) |

+ 3 - 1
examples/tui-agent/tests/pty-harness.ts

@@ -192,10 +192,12 @@ export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise<strin
     await options.prepare?.(cwd)
     const launch = resolveExampleLaunch({
       srcBin: options.binScript,
+      // `configPath` is the dsh `--config <path>` tree override; `configArgs`
+      // is the raw-args escape (e.g. `['--resume', <id>]`) for other flags.
       configArgs: options.configArgs !== undefined
         ? [...options.configArgs]
         /* v8 ignore next -- every caller passes configPath or configArgs; the fallback keeps the type total */
-        : [options.configPath ?? './cordis.yml'],
+        : options.configPath !== undefined ? ['--config', options.configPath] : [],
       tsconfigPath: options.tsconfigPath,
       env: {
         DSH_HOME: join(cwd, '.dsh'),

+ 2 - 2
examples/tui-agent/tests/tui-keyless-smoke.e2e.ts

@@ -253,7 +253,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
       label: 'dsh in-place resume',
       tempDirPrefix: 'dsh-in-place-resume-',
       binScript: dshBinScript,
-      configArgs: [scriptedConfigPath],
+      configPath: scriptedConfigPath,
       prepare: seedResumeSession,
       actions: [
         { waitFor: 'scripted TUI ready.', send: '/resume\r' },
@@ -350,7 +350,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
       label: 'dsh source-path prompt',
       tempDirPrefix: 'dsh-source-path-',
       binScript: dshBinScript,
-      configArgs: [scriptedConfigPath],
+      configPath: scriptedConfigPath,
       actions: [
         ...SELECT_PRO_MODEL,
         { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: 'exercise the TUI\r' },

+ 1 - 1
package.json

@@ -93,7 +93,7 @@
     "demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml",
     "demo:tui": "node --import tsx apps/cli/src/bin.ts",
     "demo:code-mode": "node scripts/demo-code-mode.mjs",
-    "demo:cordis": "node --import tsx apps/cli/src/bin.ts examples/cordis-agent/cordis.yml",
+    "demo:cordis": "node --import tsx apps/cli/src/bin.ts --config examples/cordis-agent/cordis.yml",
     "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml",
     "demo:web": "npm run build && npm run build:web && node --import tsx apps/cli/src/bin.ts web",
     "dev:web": "tsx scripts/dev-web.ts --poll",

+ 1 - 1
scripts/demo-code-mode.mjs

@@ -7,7 +7,7 @@ import { spawn } from 'node:child_process'
 
 // Each UI's node invocation matches its base demo script plus the overlay config.
 const UIS = new Map([
-  ['tui', ['--import', 'tsx', 'apps/cli/src/bin.ts', 'examples/tui-agent/code-mode.cordis.yml']],
+  ['tui', ['--import', 'tsx', 'apps/cli/src/bin.ts', '--config', 'examples/tui-agent/code-mode.cordis.yml']],
   ['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']],
 ])
 

+ 3 - 1
vitest.e2e.config.ts

@@ -38,7 +38,9 @@ export default defineConfig({
   plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })],
   test: {
     setupFiles: ['./scripts/test-invariants.ts'],
-    include: ['packages/*/*/tests/**/*.e2e.ts', 'apps/*/tests/**/*.e2e.ts', 'examples/*/tests/**/*.e2e.ts'],
+    // apps/cli only, not apps/*: apps/web/tests/*.e2e.ts needs the built
+    // frontend dist and runs under vitest.web.config.ts (the test:web job).
+    include: ['packages/*/*/tests/**/*.e2e.ts', 'apps/cli/tests/**/*.e2e.ts', 'examples/*/tests/**/*.e2e.ts'],
     // Real model calls: generous timeouts, and retries for transient flakes
     // (the shared internal key hits concurrency quotas). No coverage — the
     // unit suites own the coverage gate.

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov