6.5 KiB
Agent Note: The dsh CLI and personal config overlays from the Harness home
Status: implemented
English | 中文
Problem
A developer's own preferences — which provider and model the TUI uses, personal credentials, a private adapter route — had nowhere to live except edits to committed files. Pointing the TUI demo at a personal Anthropic-proxy Opus route meant patching examples/tui-agent/cordis.yml and .env in the working tree, which risks committing secrets and repeats per checkout. There was also no installable command: running the agent in an arbitrary project directory required invoking the repo's demo script from the repo root. Loader metadata is static, so "conditional composition uses overlays" (AGENTS.md) — but overlays only existed as committed sibling files, not as a machine-level layer.
Decision
Two coupled pieces, aligned with the apps/ assembly tier proposed by the dsh web PR (#443):
The dsh CLI (apps/cli, npm name @deepseek-ai/dsh). apps/* joins the workspaces as the product-assembly tier over packages/* libraries. The bin's dispatch reserves web and -p/--prompt for PR #443 (they exit with a pointer) so the two branches merge as a near-union; everything else runs the default surface: the interactive TUI, booting the shipped examples/tui-agent/cordis.yml (or an explicit config argument) with the invoking directory as the workspace. The committed bin/dsh launcher resolves the checkout through its own real path and runs the bin from source through Node's native TypeScript transform plus the app-owned tsconfig-paths loader, so ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh installs a command that always executes the current working tree. pnpm run demo:tui runs the same entry.
Personal config (dsh-app-boot). The personal overlay lives in the Harness home — $DSH_HOME, else ~/.dsh — resolved by the shared resolveDshHome (@deepseek-ai/dsh-paths), the same single root skills and AGENTS.md resolve against. The official dsh surfaces consume its two optional files; the demo bins boot their committed trees verbatim:
.env— loaded after the invoking directory's.env;process.loadEnvFilenever overrides, so precedence is ambient > project.env> personal.env.config.yaml— a top-level YAML array of@cordisjs/plugin-includePatchOptions, parsed with the include's own!!jsdialect (loadPersonalPatches) and passed toboot(), which forwards it as the root include'spatches. Patch semantics match the shipped surface overlays: an id-targeted patch replaces the named entry's wholeconfig,insertappends entries, and an unmatched id is a silent no-op.- A missing file means no overlay; a present-but-unreadable, unparsable, or non-array file throws at boot (misconfiguration fails loud, never a silent skip).
The PTY smoke's launcher isolates $DSH_HOME to a per-test directory, exactly as it already isolates DSH_AGENTS_HOME, so a developer's real personal overlay cannot leak into fixtures; only the dsh CLI reads personal config, so no other test launcher needed changes.
Hot-reload interplay: the include re-applies its patches on every config re-read (the config hot-reload resilience Agent Note), so a live cordis.yml edit keeps the personal overlay applied.
Alternatives considered
A standalone bin/dsh wrapper owning the dsh name. Rejected after reading PR #443: that PR establishes apps/cli as the dsh CLI with subcommand dispatch (web, -p) and leaves the default slot unclaimed. Two competing dsh entrypoints would collide in $PATH and in product identity; claiming the default slot inside the same package shape confines the eventual merge conflict to the small dispatch chain.
A pi-style typed settings file (defaultProvider/defaultModel/providers). Rejected by the user in favor of patch semantics: the personal file is a cordis overlay over the shipped default config, not a second config vocabulary to own and translate.
A personal full cordis.yml that includes the requested config. Rejected: the personal file would have to name the leaf config's path, which varies per checkout; patches invert the dependency so the bin keeps choosing the tree and the personal layer only amends it.
Deep-merging personal patches into entry configs. Rejected: it would fork the patch semantics from the committed overlays and the vendored include; whole-config replacement is already the documented contract.
Opt-in via env flag instead of presence. Rejected: personal config that is off by default never gets used; presence plus explicit per-test isolation gives live runs the overlay and tests hermeticity.
Consequences
dshfrom any directory (andpnpm run demo:tui) boots the personal provider/model with zero repo changes; verified end-to-end against a personal Anthropic proxy with Opus 4.8, including a bash tool round trip.- Because an id-targeted patch replaces the whole
config, a personal override restates the base fields it keeps and can drift when the base entry changes shape; the loader's entry-not-found/name-mismatch warnings are the only diagnostics. - Personal patches resolve ids against the booted file's own tree, so nested-include overlays (Code Mode) are not personalized; live-run parity for those leaves is deferred.
dsh-app-bootdepends onjs-yaml(plus a load-only copy of the include's!!jsYAML type) and, likeapps/cli, on@deepseek-ai/dsh-pathsforresolveDshHome.- When PR #443 lands,
apps/cli/src/bin.ts's dispatch chain andapps/cli/package.json's dependency list conflict textually; both resolve as unions (theirweb/-pbranches plus our default-TUI branch).
Testing
packages/ui/app-boot/tests/personal-config.spec.ts pins !!js preservation and end-to-end interpolation through a booted tree, insert entries, the default directory resolving from $DSH_HOME, the absent/empty no-op paths, and the three fail-loud shapes (unreadable, unparsable, non-array). examples/tui-agent/tests/tui-keyless-smoke.e2e.ts boots the dsh bin in a PTY three ways: default config with no overlay, a personal .env + config.yaml chain whose patched welcome renders in the banner, and an invalid personal file failing the boot loudly. The pre-existing smokes and snapshot suites pass on a machine whose real ~/.dsh overlay would change the booted model — the isolation, not luck.