feat(cli)!: complete --config on every surface and delete the personal overlay

$DSH_HOME/config.yaml was an implicit composition layer: if the file existed,
every launch applied an arbitrary Loader patch graph over the shipped tree,
kept live by a dedicated HMR watcher. Three costs came from the implicitness,
not the capability. A patch replaces its target row's whole config, so a file
written months ago pins that row to the field set it knew and every default
the shipped tree later adds silently stops applying. It competed with the
typed settings namespaces llm-deepseek and llm-pi-ai already register, so
which one wins was a function of layer order rather than meaning. And the
explicit escape hatch it was supposedly redundant with did not exist on every
surface: dsh -p, dsh meta, and dsh upgrade all rejected --config, so for them
the implicit file was the only composition route at all.

Complete the explicit layer first: --config and --config-replace now work on
every booting surface. A headless --config-replace tree must still mount a
webserver row, because that surface reaches its own agent over the same HTTP
gateway the browser uses; AppCLIEntry names that contract in the failure
instead of reporting a bare missing service.

Then delete the implicit one. PERSONAL_CONFIG_FILENAME, loadPersonalPatches,
watchPersonalPatches, and the config-only HMR row mounted for it are gone; a
file left at that path is inert, and --dump-config no longer reads the Harness
home. --config therefore stops *replacing* the personal overlay and simply
*is* the user overlay.

No migration: a user who wants the old behavior names the same file
(dsh --config ~/.dsh/config.yaml), which a shell alias makes permanent.
This commit is contained in:
Yichen Jiang
2026-08-04 15:25:04 +08:00
parent 03b534de16
commit 8ddc53f7a0
39 changed files with 416 additions and 650 deletions
+17 -9
View File
@@ -30,6 +30,17 @@ describe('parseDshArgs', () => {
expect(parse(['--config-replace', 'tree.yml'])).toEqual({ mode: 'tui', configReplace: 'tree.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' })
// Every booting surface takes the composition flags: with the personal
// overlay gone, naming a tree is the only way to compose one, so a
// surface that could not name one would have no composition path at all.
expect(parse(['-p', 'task', '--config', 'c.yml']))
.toEqual({ mode: 'headless', prompt: 'task', config: 'c.yml' })
expect(parse(['-p', 'task', '--config-replace', 'tree.yml']))
.toEqual({ mode: 'headless', prompt: 'task', configReplace: 'tree.yml' })
expect(parse(['meta', '--experimental', '--config', 'c.yml']))
.toEqual({ mode: 'meta', config: 'c.yml' })
expect(parse(['upgrade', '--experimental', '--config-replace', 'tree.yml']))
.toEqual({ mode: 'upgrade', configReplace: 'tree.yml' })
// Experimental subcommands run under the per-invocation flag or the env opt-in.
expect(parse(['meta', '--experimental'])).toEqual({ mode: 'meta' })
expect(parse(['meta'], true)).toEqual({ mode: 'meta' })
@@ -77,9 +88,8 @@ describe('parseDshArgs', () => {
// schema at boot, not here.)
expect(exitCode(['--resume='])).toBe(1)
expect(exitCode(['-p', ''])).toBe(1)
expect(exitCode(['-p', 'x', '--config', 'c.yml'])).toBe(1)
expect(exitCode(['-p', 'x', '--config-replace', 'tree.yml'])).toBe(1)
expect(exitCode(['--config', 'c.yml', '--config-replace', 'tree.yml'])).toBe(1)
expect(exitCode(['-p', 'x', '--config', 'c.yml', '--config-replace', 'tree.yml'])).toBe(1)
expect(exitCode(['-p', 'x', '--resume', 's'])).toBe(1)
expect(exitCode(['--bogus'])).toBe(1)
expect(exitCode(['bogus-positional'])).toBe(1)
@@ -91,16 +101,14 @@ describe('parseDshArgs', () => {
expect(exitCode(['--config-replace', 'tree.yml', 'web'])).toBe(1)
// Same rule for each subcommand that 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 always starts fresh,
// so every default-surface option is rejected.
// `meta` always starts fresh, so the session options are rejected; the
// composition flags are its own and only their combination is rejected.
expect(exitCode(['meta', '--experimental', '--resume', 's'])).toBe(1)
expect(exitCode(['meta', '--experimental', '--config', 'c.yml'])).toBe(1)
expect(exitCode(['meta', '--experimental', '--config-replace', 'tree.yml'])).toBe(1)
expect(exitCode(['meta', '--experimental', '-p', 'task'])).toBe(1)
// `upgrade` takes no options beyond the gate: any leaked default-surface
// flag is a mistyped invocation, not a silently-dropped input.
expect(exitCode(['meta', '--experimental', '--config', 'c.yml', '--config-replace', 't.yml'])).toBe(1)
// `upgrade` always mints a fresh session, so `--resume` and a leaked
// parent flag are mistyped invocations; its own composition flags are not.
expect(exitCode(['upgrade', '--experimental', '--resume', 's'])).toBe(1)
expect(exitCode(['upgrade', '--experimental', '--config', 'c.yml'])).toBe(1)
expect(exitCode(['-p', 'task', 'upgrade', '--experimental'])).toBe(1)
// The pre-release command names have no compatibility aliases.
expect(exitCode(['experimental-meta'])).toBe(1)
+10 -6
View File
@@ -107,8 +107,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
expect(stdout).toContain('# == tui.cordis.yml')
}, 30_000)
it('layers the personal overlay in --dump-config and reports an unmatched patch on stderr', async () => {
writeFileSync(join(home, 'config.yaml'), [
it('layers a --config overlay in --dump-config and reports an unmatched patch on stderr', async () => {
const overlay = join(home, 'overlay.yml')
writeFileSync(overlay, [
'- id: agent-loop',
' config:',
' agents:',
@@ -120,16 +121,19 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
' value: 1',
'',
].join('\n'))
const { stdout, code, stderr } = await runBuiltBin(['--dump-config'], { DSH_HOME: home })
const { stdout, code, stderr } = await runBuiltBin(['--dump-config', '--config', overlay], { DSH_HOME: home })
expect(code).toBe(0)
expect(stdout).toContain('provider: custom-provider')
expect(stdout).not.toContain('model: deepseek-v4-pro')
// The personal layer appears in the patched row's provenance and the
// The named layer appears in the patched row's provenance and the
// skipped-patch warning carries its label.
expect(stdout).toContain(`patched by tui.cordis.yml, ${join(home, 'config.yaml')}`)
expect(stdout).toContain(`patched by tui.cordis.yml, ${overlay}`)
expect(stderr).toContain('patch: entry "only-on-web" not found')
// The shipped view ignores the personal overlay entirely.
// An unnamed dump composes the shipped tree only: a file sitting in the
// Harness home is not a layer any more.
const unnamed = await runBuiltBin(['--dump-config'], { DSH_HOME: home })
expect(unnamed.stdout).not.toContain('custom-provider')
const shipped = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home })
expect(shipped.stdout).not.toContain('custom-provider')
expect(shipped.stdout).toContain('model: deepseek-v4-pro')
+26 -26
View File
@@ -40,7 +40,7 @@ const PTY_SMOKE_TEST_TIMEOUT_MS = process.env.DSH_EXAMPLE_MODE === 'lib'
: LOADER_SMOKE_TEST_TIMEOUT_MS
/**
* Seed the isolated process workspace: ordinary files land in `cwd`, personal
* Seed the isolated process workspace: ordinary files land in `cwd`, harness
* files in the Harness home (`.dsh`), and skill bundles under the agents
* home's `skills/` root — the same trees `$DSH_HOME` /
* `$DSH_AGENTS_HOME` point the child at.
@@ -48,7 +48,7 @@ const PTY_SMOKE_TEST_TIMEOUT_MS = process.env.DSH_EXAMPLE_MODE === 'lib'
function seedWorkspace(
files: {
workspace?: Record<string, string>
personal?: Record<string, string>
harnessHome?: Record<string, string>
skills?: Record<string, string>
},
): (cwd: string) => Promise<void> {
@@ -58,7 +58,7 @@ function seedWorkspace(
await mkdir(dirname(file), { recursive: true })
await writeFile(file, content)
}
for (const [name, content] of Object.entries(files.personal ?? {})) {
for (const [name, content] of Object.entries(files.harnessHome ?? {})) {
const file = join(cwd, '.dsh', name)
await mkdir(dirname(file), { recursive: true })
await writeFile(file, content)
@@ -652,7 +652,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
expect(output).toContain('Preserve restored state')
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it('boots the shipped default config with no arguments and no personal overlay', async () => {
it('boots the shipped default config with no arguments and no overlay', async () => {
const output = await smoke({
label: 'dsh default boot',
tempDirPrefix: 'dsh-default-boot-',
@@ -667,9 +667,9 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
expect(output).toContain('\u001B[?2004l')
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it('applies the personal overlay: config.yaml patches an overlay-inserted row, and both .env layers feed its !!js with the project one winning', async () => {
// The whole personal-config chain in one boot, plus the environment
// layering underneath it. config.yaml patches the `tui` row — a row the
it('applies a --config overlay: it patches an overlay-inserted row, and both .env layers feed its !!js with the project one winning', async () => {
// The whole explicit-overlay chain in one boot, plus the environment
// layering underneath it. The named file patches the `tui` row — a row the
// SURFACE OVERLAY inserted, not one the base declares — proving a later
// patch list reaches a row an earlier one inserted. The `!!js` expression
// renders both halves of the layering in one line: `DSH_LAYER_WELCOME` is
@@ -678,13 +678,13 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
// arrive. Credentials are not part of this: they live in
// `.credentials.yaml`, which is never hoisted into `process.env`.
const output = await smoke({
label: 'dsh personal overlay',
tempDirPrefix: 'dsh-personal-overlay-',
label: 'dsh explicit overlay',
tempDirPrefix: 'dsh-explicit-overlay-',
binScript: dshBinScript,
configArgs: [],
configArgs: ['--config', '.dsh/config.yaml'],
prepare: seedWorkspace({
workspace: { '.env': 'DSH_LAYER_WELCOME=PROJECT WINS.\n' },
personal: {
harnessHome: {
'.env': 'DSH_LAYER_WELCOME=USER LAYER LOST.\nDSH_USER_ONLY=USER LAYER LOADED.\n',
'config.yaml': [
'- id: workspace-context',
@@ -705,7 +705,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
expect(output).toContain('\u001B[?2004l')
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it('loads a cached repository Plugin from personal config alone', async () => {
it('loads a cached repository Plugin from a --config overlay alone', async () => {
const source = 'github:fixture/repository#fixed-ref'
const specifier = `${source}&path:/.dsh-plugin`
const key = createHash('sha256').update(specifier).digest('hex')
@@ -717,12 +717,12 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
// deliberate external pin of the durable on-disk format.
const wrapper = await generatePreparedWrapper('config-only-fixture')
const output = await smoke({
label: 'dsh personal repository Plugin',
tempDirPrefix: 'dsh-personal-repository-plugin-',
label: 'dsh overlay repository Plugin',
tempDirPrefix: 'dsh-overlay-repository-plugin-',
binScript: dshBinScript,
configArgs: [],
configArgs: ['--config', '.dsh/config.yaml'],
prepare: seedWorkspace({
personal: {
harnessHome: {
'config.yaml': [
'- id: repository-plugins',
" name: '@deepseek-ai/dsh-repository-plugin'",
@@ -753,13 +753,13 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('fails loud instead of booting when the personal config.yaml is invalid', async () => {
it('fails loud instead of booting when a named --config overlay is invalid', async () => {
const output = await smoke({
label: 'dsh invalid personal config',
tempDirPrefix: 'dsh-invalid-personal-',
label: 'dsh invalid overlay',
tempDirPrefix: 'dsh-invalid-overlay-',
binScript: dshBinScript,
configArgs: [],
prepare: seedWorkspace({ personal: { 'config.yaml': 'id: not-a-list\n' } }),
configArgs: ['--config', '.dsh/config.yaml'],
prepare: seedWorkspace({ harnessHome: { 'config.yaml': 'id: not-a-list\n' } }),
expectedExitCode: 1,
})
expect(output).toContain('must be a top-level YAML array of loader patch entries')
@@ -793,18 +793,18 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36} --config/)
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it('keeps resume working when the personal overlay replaces the whole agent-loop config', async () => {
// Loader patches replace a targeted `config` key wholesale, so a personal
// overlay repointing the model route drops every identity key the shipped
it('keeps resume working when a --config overlay replaces the whole agent-loop config', async () => {
// Loader patches replace a targeted `config` key wholesale, so an overlay
// repointing the model route drops every identity key the shipped
// row declared. Launcher-owned identity makes that unreachable: agent-loop
// applies the launcher's id over whatever route survives.
const output = await smoke({
label: 'dsh overlay keeps resume',
tempDirPrefix: 'dsh-overlay-resume-',
binScript: dshBinScript,
configArgs: [],
configArgs: ['--config', '.dsh/config.yaml'],
prepare: seedWorkspace({
personal: {
harnessHome: {
'config.yaml': [
'- id: workspace-context',
' disabled: true',