Files
deepseek-harness/apps/cli/README.md
T
Turtle f290a8b851 refactor(cli)!: one shared base config with per-surface overlays
`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml
composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml
whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a
twenty-key pass-through Config. Neither file was what its location claimed —
apps/cli hardcoded the "example" as the product default and the "demo" bundle
was the application — and every capability change had to be made twice.

- apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and
  web.cordis.yml are patch lists stating only what differs per surface
- overlays apply as SIBLING patch lists at one include level, because include
  patches never cross an include boundary. Precedence: base < surface <
  (--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches
- `--config` now applies an overlay INSTEAD OF the personal one, so a demo or
  test tree never inherits the user's route; new `--config-replace` boots a file
  as the entire tree (the old `--config` behaviour). Both survive /resume
- vendor/include: index each `insert`ed row as it is added so a later patch can
  configure or disable it. Upstream built the id index once before the patch
  loop, leaving every surface-only row — the whole TUI front door — silently
  unpatchable from user config. Logged as local modification 8
- session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY;
  dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it)
- delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo;
  TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests,
  examples/code-mode survives as an overlay leaf
- `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay

Three latent defects surfaced and are fixed here: the TUI captured the optional
sessionQuery service once at construction and could permanently disable /resume
when it won the mount race; the session-store root silently reverted to a
project-local ./.sessions; --config-replace was dropped by the resume handoff.

Verified by booting each tree through the real Loader (TUI 55 entries, web 75,
zero unsettled) rather than reading YAML. All eight terminal snapshots replay
byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene
and lint clean.
2026-07-29 21:15:42 +08:00

40 lines
9.1 KiB
Markdown

# `@deepseek-ai/dsh`
English | [中文](README.zh.md)
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, `dsh meta` boots that same TUI over this harness checkout, `dsh migrate` and `dsh upgrade` boot a fresh guided TUI session whose first turn invokes a bundled skill, `dsh list-sessions` lists the sessions running right now, 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)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`), whose `meta` subcommand is the same TUI over this checkout, whose `migrate`/`upgrade` subcommands are option-less guided-session entries, whose `list-sessions` subcommand (alias `ps`) lists live sessions, 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 a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `migrate`, `upgrade`, `list-sessions`, `web` — rejects a leaked `--config`/`-p`/`--resume` rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags.
The TUI surface:
- boots the shipped default config (`apps/cli/base.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 resume invocation; runtimes without process replacement leave the session running and say so. This CLI owns session identity and the exit line rather than the config: it mints or selects the `main` session id and provides it, plus the exact command that reproduces this invocation, on the boot context ([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) and `TUI_GOODBYE_MESSAGE_KEY`). No `cordis.yml` key can drop resume, 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 (`dsh meta` is the sole exception, below);
- 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;
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree.
`dsh meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after both `.env` layers are loaded, so environment precedence is unchanged while the session cwd, the `./.sessions` persistence root, and the HMR watch root all move together. It accepts only `--resume <id>`; `--config` (which would boot a foreign tree) and `-p` (which is not interactive) fail loud. Because meta sessions live under the checkout, `--resume` here sees only other meta sessions, and both the in-place handoff and the printed exit line reproduce the mode as `dsh meta --resume <id>`, so a copied command resumes the right session from any directory.
`dsh migrate` and `dsh upgrade` are guided fresh-session entries over the default TUI surface: each mints a fresh session in the invoking directory and seeds its first turn with a bundled skill (`dsh-migrate` for migrating from another coding agent — opencode, pi, Claude Code, Codex; `dsh-upgrade` for upgrading this checkout), exactly as if the user typed `/skill:<name>`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume <id>` of the session is an ordinary TUI session with no re-injection.
`dsh list-sessions` lists the sessions running right now: session id, pid, uptime, workspace, and title, newest first. It is read-only and boots no agent tree — it mounts the [session registry](../../packages/session-registry/session-registry/README.md) alone, so listing is fast and cannot start model work as a side effect. Every surface publishes its sessions into that registry through [`dsh-session-registry-live`](../../packages/session-registry/session-registry-live/README.md), and records whose process is gone are pruned on read, so a crashed session disappears without cleanup. `--json` emits the same records as a machine-readable array; an empty listing prints one line and exits 0. There is no workspace filter: the listing is always every live session, whatever directory it runs in. Only top-level surfaces appear — subagents share or spawn other processes and are deliberately invisible.
The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment.
`DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode).
## Install (developer machine)
Symlink the source-running launcher onto your PATH; it resolves the checkout through its own real path, so code changes apply on the next launch with no build step:
```sh
ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
```
Source launches run `apps/cli/src/bin.ts` through tsx's ESM-only hook (`node --import tsx/esm`), which transforms TypeScript and projects the root tsconfig `paths` map into module resolution. Node's native TypeScript modes are not used: Node 26 removed `--experimental-transform-types`, and strip-only mode rejects syntax the source graph relies on (vendored parameter properties, decorators, runtime enums/namespaces). The CJS hook stays off because the source graph is ESM-only and the CJS resolver adds ~0.4s of startup. `bin/dsh` pins `TSX_TSCONFIG_PATH` to the checkout's root tsconfig so resolution is cwd-independent, and the `dsh-source-launch-smoke` node-compat gate runs this exact launch vector on every supported Node line. tsx applies the `paths` map without checking dependency declarations, so declaration completeness rests on the static gates: the TUI configs resolve bare plugins through `examples/package.json`, the Web/headless `cordis.yml` through this package's `dependencies`, and `verify-cordis-config` requires every configured bare plugin to be declared, while allowing unrelated dependencies.
`pnpm run dsh` runs the same entry from the repo root and forwards arguments directly, for example `pnpm run dsh -p "task"`. The built form (`lib/bin.js`, via `pnpm run build`) boots the same config under plain Node.