refactor(cli): parse dsh argv through one Commander adapter

Replace the dsh CLI's three hand-rolled parsing idioms (raw argv[0]/includes
dispatch in bin.ts, per-mode node:util parseArgs in headless.ts/web.ts, and the
bespoke parseResumeArg scanner in dsh-app-boot) with a single Commander adapter
in apps/cli/src/args.ts. parseDshArgs resolves argv into a discriminated
DshInvocation union; bin.ts switches on the mode and dynamic-imports the chosen
module, which now consumes already-parsed values.

- web is a real subcommand; --host uses choices and --port an argParser range
  check, moving validation into the parser.
- --resume rejects empty and repeated forms; --prompt rejects empty; a config
  positional after --prompt and a root flag placed before web fail loud.
- adds --help/--version; removes parseResumeArg from dsh-app-boot.
- new apps/cli/tests/args.spec.ts (apps/*/tests added to vitest include,
  apps/cli/tests to tsconfig.host.json); the tui-agent keyless PTY smoke covers
  bin.ts dispatch end to end unchanged.
This commit is contained in:
Turtle
2026-07-24 19:43:59 +08:00
parent 7c0c516f60
commit 800bafda3b
17 changed files with 460 additions and 130 deletions
+1 -26
View File
@@ -6,7 +6,7 @@ import { Context } from 'cordis'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import {
addHarnessSourceSection, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
installFailLoud, loadEnv, parseResumeArg, resolveConfigPath, type FailLoudProcess,
installFailLoud, loadEnv, resolveConfigPath, type FailLoudProcess,
} from '../src/index.ts'
const NAME = 'dsh-test-bin'
@@ -30,31 +30,6 @@ describe('resolveConfigPath', () => {
})
})
describe('parseResumeArg', () => {
it('returns no resume id and passes arguments through when the flag is absent', () => {
expect(parseResumeArg([])).toEqual({ resumeSessionId: undefined, rest: [] })
expect(parseResumeArg(['custom.yml'])).toEqual({ resumeSessionId: undefined, rest: ['custom.yml'] })
})
it('parses the space form, the inline form, and leaves a positional config path in any position', () => {
expect(parseResumeArg(['--resume', 'sess-1'])).toEqual({ resumeSessionId: 'sess-1', rest: [] })
expect(parseResumeArg(['--resume=sess-2'])).toEqual({ resumeSessionId: 'sess-2', rest: [] })
expect(parseResumeArg(['--resume', 'sess-3', 'app.yml'])).toEqual({ resumeSessionId: 'sess-3', rest: ['app.yml'] })
expect(parseResumeArg(['app.yml', '--resume', 'sess-4'])).toEqual({ resumeSessionId: 'sess-4', rest: ['app.yml'] })
})
it('fails loud on a valueless, empty, or repeated flag rather than silently starting fresh', () => {
expect(() => parseResumeArg(['--resume'])).toThrow('--resume requires a session id')
expect(() => parseResumeArg(['--resume='])).toThrow('--resume requires a session id')
expect(() => parseResumeArg(['--resume', 'a', '--resume', 'b'])).toThrow('--resume may be given only once')
})
it('rejects resume syntax used as the flag value instead of resuming a session named like the flag', () => {
expect(() => parseResumeArg(['--resume', '--resume', 'sess'])).toThrow('--resume requires a session id')
expect(() => parseResumeArg(['--resume', '--resume=sess'])).toThrow('--resume requires a session id')
})
})
describe('loadEnv', () => {
it('loads variables from .env in the given dir', () => {
const dir = tmp()