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
-44
View File
@@ -36,50 +36,6 @@ export function resolveConfigPath(
return resolve(dir, replayName)
}
/** CLI flag the interactive surface accepts to resume a persisted session by id. */
const RESUME_FLAG = '--resume'
/**
* Split a leading `--resume <id>` / `--resume=<id>` flag out of a CLI argument
* vector, returning the resumed session id (when the flag is present) and the
* remaining arguments with the flag and its value removed — so a positional
* config path stays readable regardless of the flag's position. A `--resume`
* with no following id, an empty id (`--resume=`), or a repeated `--resume`
* throws: a mistyped resume must fail loud, never silently start a fresh
* session. The id is not validated here; an unknown id fails loud downstream
* when the session cannot load.
* @param argv - the CLI arguments after subcommand dispatch.
* @returns the parsed resume id (or `undefined`) and the flag-stripped arguments.
*/
export function parseResumeArg(
argv: readonly string[],
): { resumeSessionId: string | undefined; rest: string[] } {
const rest: string[] = []
let resumeSessionId: string | undefined
let skipNext = false
for (const [i, arg] of argv.entries()) {
if (skipNext) {
skipNext = false
continue
}
const inlineValue = arg.startsWith(`${RESUME_FLAG}=`)
if (arg === RESUME_FLAG || inlineValue) {
if (resumeSessionId !== undefined) throw new Error(`${RESUME_FLAG} may be given only once`)
const value = inlineValue ? arg.slice(RESUME_FLAG.length + 1) : argv[i + 1]
// A following token that is itself resume syntax (`--resume --resume x`)
// is a missing id, not a session literally named `--resume…`.
if (value === undefined || value === '' || value === RESUME_FLAG || value.startsWith(`${RESUME_FLAG}=`)) {
throw new Error(`${RESUME_FLAG} requires a session id (e.g. ${RESUME_FLAG} <session-id>)`)
}
resumeSessionId = value
skipNext = !inlineValue // the space form consumed the following token as its value
continue
}
rest.push(arg)
}
return { resumeSessionId, rest }
}
/**
* Load the optional gitignored `.env` from `dir`. Missing files fall back to the
* ambient environment; other read failures are reported through `warn`.