Merge pull request #2310 from deepseek-harness/refactor/cmdline-plan-trim
refactor(cmdline): run the program's own commander action instead of a plan callback
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md
|
||||
2026-08-06-app-owned-command-line.md: 2480775f654fd5c2fecebc8d59e311acee878920
|
||||
2026-08-06-app-owned-command-line.zh.md: d754c125d5bc683156f5ac3f285e2cd711e6773b
|
||||
2026-08-06-app-owned-command-line.md: 6d84ba457564ef250e1acfbcc71fcc91b1d49aee
|
||||
2026-08-06-app-owned-command-line.zh.md: f964f7a7de7aae7e97b52fbc572443352dc5ae26
|
||||
|
||||
@@ -12,7 +12,7 @@ After profiles, compositions were installable but their command lines were not.
|
||||
|
||||
The launcher parses only what it owns — `--profile`, `--patch`, the config dumps — and hands **everything after its own flags** to the booted tree verbatim. The split is positional: the first token the launcher does not recognize starts the app's arguments (commander's `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`). A bare `dsh -h`, which has no app to hand the flag to, still prints the launcher's own help.
|
||||
|
||||
The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`) and `ctx.appExit`. Any ordinary app plugin may inject `cmdlineArgs`, call `parseCmdline(ctx, program, plan)` with its own commander program, and provide the returned value as an app-owned service. Its Loader row carries no launcher marker or special kind, and the launcher does not inspect the composition for an owner. Multiple plugins may read the same immutable snapshot; a profile with no reader ignores its app arguments. Rows configured from a provider inject its service and read direct lazy config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row.
|
||||
The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`) and `ctx.appExit`. Any ordinary app plugin may inject `cmdlineArgs`, call `parseCmdline(ctx, program)` with its own commander program, and provide the resolved value as an app-owned service from the program's action. Its Loader row carries no launcher marker or special kind, and the launcher does not inspect the composition for an owner. Multiple plugins may read the same immutable snapshot; a profile with no reader ignores its app arguments. Rows configured from a provider inject its service and read direct lazy config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row.
|
||||
|
||||
The boot mounts the composition once. Cordis holds each row until its injections are active; Loader then interpolates that row's `!!js` against the injection-ready plugin context immediately before activation. Include keeps nested row expressions raw until their target row reaches this point. `--help` leaves the provider's service absent, so dependent rows never activate, and a live patch reload interpolates again against the service that remains active, so a served port cannot be silently reset.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ profile 落地之后,组合可以安装,命令行却不能。`apps/cli` 仍
|
||||
|
||||
启动器只解析属于自己的部分(`--profile`、`--patch`、配置 dump),并把**自己 flag 之后的一切**原样交给引导起来的配置树。切分按位置进行:启动器不认识的第一个 token 就是应用参数的起点(依靠 commander 的 `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`)。裸的 `dsh -h` 没有可交付的应用,仍然打印启动器自己的 help。
|
||||
|
||||
新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)与 `ctx.appExit`。任何普通应用插件都可以注入 `cmdlineArgs`,用自己的 commander program 调用 `parseCmdline(ctx, program, plan)`,再把返回值作为应用自有服务提供出去。它的 Loader 行不携带启动器标记或特殊类型,启动器也不会检查组合中的所有者。多个插件可以读取同一份不可变快照;没有读取方的 profile 会忽略自己的应用参数。由提供方配置的行注入其服务,并在惰性配置表达式中直接读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。
|
||||
新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)与 `ctx.appExit`。任何普通应用插件都可以注入 `cmdlineArgs`,用自己的 commander program 调用 `parseCmdline(ctx, program)`,再在 program 自己的 action 中把解析出的取值作为应用自有服务提供出去。它的 Loader 行不携带启动器标记或特殊类型,启动器也不会检查组合中的所有者。多个插件可以读取同一份不可变快照;没有读取方的 profile 会忽略自己的应用参数。由提供方配置的行注入其服务,并在惰性配置表达式中直接读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。
|
||||
|
||||
boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 `!!js`。Include 会保留嵌套的行表达式,直到目标行到达这一时点。`--help` 会让提供方服务保持缺失,因此依赖行永不激活;活动 patch 重载会针对仍然在线的服务再次插值,所以已经服务中的端口不会被悄悄重置。
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.md
|
||||
2026-08-11-cmdline-program-action.md: 40c4dae1d3461f25ac7f34dee7c166434e6cd24d
|
||||
2026-08-11-cmdline-program-action.zh.md: 91036f1c52b60d28055935813d6698205f045422
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: parseCmdline runs the program's own commander action
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-11-cmdline-program-action.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh-cmdline`'s ([app-owned command line](../architecture/2026-08-06-app-owned-command-line.md)) `parseCmdline` carried a bespoke callback: `CmdlinePlan<T> = (program, ctx) => T`, invoked after a successful parse inside the helper's catch so a plan's `program.error(...)` shared the help/parse-error exit path, with a type-unsound `(() => ({}) as T)` default only tests used and a `ctx` argument no plan read. The whole seam duplicated a slot commander already defines: a command's action handler runs inside `parse`, and `program.error(...)` thrown from it obeys `exitOverride` exactly like a grammar rejection.
|
||||
|
||||
## Decision
|
||||
|
||||
`parseCmdline(ctx, program): void` only adapts commander control flow to the launcher: it parses the immutable `cmdlineArgs` snapshot and turns help, version, parse errors, and action rejections into a `ctx.appExit` request. App code — validation commander's grammar cannot express and the `ctx.provide` of the app-owned service — lives in the program's own synchronous `.action()`, which commander runs on a successful parse and never runs on help or rejection. The `CmdlinePlan` export, its `ctx` parameter, the default plan, and the `T | undefined` return are deleted; both bundle providers publish from their action. Because the `Command` type cannot express the action precondition, `parseCmdline` reads the handler structurally (as `isCommanderError` reads commander's control-flow errors) and refuses at load a program in which no command declares an action — without the guard, a provider that forgot its action (or a stale caller still passing the deleted third argument) parses successfully, publishes nothing, and surfaces only as dependent rows pending on the absent service at settlement. The helper configures `exitOverride` and output on the whole command tree, not the root alone: commander copies those settings into a subcommand only at registration, so a root-only override would let a pre-registered subcommand's rejection call `process.exit` past `ctx.appExit`. An action must reject before it publishes; statements before its `program.error(...)` have already run.
|
||||
|
||||
Verified on commander 15 before shipping: an action runs inside `parse` and its `program.error(...)` throws a `CommanderError` through `exitOverride`; help and version short-circuit before the action; excess-argument handling is identical with and without an action.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keeping a bespoke `resolve`/plan callback**: it existed only so app rejection could share the helper's catch, which commander's action slot already provides; a second callback seam for the same moment in the parse lifecycle is duplication.
|
||||
- **Returning the parsed `Command` for the caller to read**: a post-parse `program.error(...)` in the caller escapes the helper's catch as an uncaught `CommanderError`, turning a usage rejection into a plugin load failure; every app with validation would rebuild the try/catch the helper owns.
|
||||
- **Moving all validation into commander option/argument parsers**: `InvalidArgumentError` covers per-value checks, but the headless bundle rejects a joined variadic ("task must be non-blank") with its own usage message, which per-argument parsers cannot express.
|
||||
- **Accepting an action-less program and relying on the settlement diagnostic**: the assembled launcher does fail loud (`pending (waiting for service: …)`), but that error names the consumers, not the misconfigured provider, and an embedding host without the settlement assertion would hang silently; the load-time guard reports the culprit program directly.
|
||||
- **Replacing the `CmdlineArgs` accessor with a bare frozen `readonly string[]` service**: the maintainer keeps the accessor object as the service's named interface.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `parseCmdline` loses its generic, callback parameter, and `undefined` sentinel; callers lose the `if (values !== undefined)` publish guard.
|
||||
- An app's command is self-contained — flags, help text, validation, and the publishing effect travel together on the `Command`.
|
||||
- Actions must be synchronous: the helper calls `parse`, not `parseAsync`, so a returned promise would escape the catch unobserved.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: parseCmdline 运行 program 自己的 commander action
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-11-cmdline-program-action.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh-cmdline`([应用自有命令行](../architecture/2026-08-06-app-owned-command-line.md))的 `parseCmdline` 曾带着一个自造的回调:`CmdlinePlan<T> = (program, ctx) => T`,在解析成功后于该适配器的 catch 之内调用,使 plan 的 `program.error(...)` 与 help/解析错误共用同一条退出路径;它还带有只被测试使用、类型不健全的默认值 `(() => ({}) as T)`,以及没有任何 plan 读取的 `ctx` 参数。这整条接缝复制了 commander 本就定义的席位:命令的 action 处理器在 `parse` 内部运行,从中抛出的 `program.error(...)` 与语法拒绝一样遵循 `exitOverride`。
|
||||
|
||||
## Decision
|
||||
|
||||
`parseCmdline(ctx, program): void` 只把 commander 的控制流适配到启动器:它解析不可变的 `cmdlineArgs` 快照,并把 help、version、解析错误与 action 的拒绝转换为一次 `ctx.appExit` 请求。应用代码——commander 语法表达不了的校验,以及应用自有服务的 `ctx.provide`——放在 program 自己的同步 `.action()` 里,commander 在解析成功时运行它,在 help 或拒绝时绝不运行。`CmdlinePlan` 导出、其 `ctx` 参数、默认 plan 与 `T | undefined` 返回值全部删除;两个组合包提供方都在各自的 action 中发布。由于 `Command` 类型无法表达 action 前置条件,`parseCmdline` 按结构读取处理器(如同 `isCommanderError` 按结构识别 commander 的控制流错误),在加载时拒绝整棵命令树中没有任何命令声明 action 的 program 并点名它——若无此守卫,漏写 action 的提供方(或仍在传已删除第三参数的陈旧调用方)会解析成功、什么也不发布,只在 settlement 时以依赖行 pending 等待缺席服务的形式浮现。该适配器在整棵命令树而非仅根命令上配置 `exitOverride` 与输出:commander 只在注册时把这些设置复制进子命令,只配置根命令会让已注册子命令的拒绝绕过 `ctx.appExit` 直接调用 `process.exit`。action 必须先拒绝后发布;写在 `program.error(...)` 之前的语句已经执行。
|
||||
|
||||
交付前已在 commander 15 上验证:action 在 `parse` 内部运行,其 `program.error(...)` 经 `exitOverride` 抛出 `CommanderError`;help 与 version 在 action 之前短路;有无 action 时的多余参数处理完全一致。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **保留自造的 `resolve`/plan 回调**:它存在的唯一理由是让应用侧的拒绝共用适配器的 catch,而 commander 的 action 席位本就提供这一点;为解析生命周期的同一时刻再造第二条回调接缝属于重复。
|
||||
- **返回解析后的 `Command` 交调用方读取**:调用方在解析之后调用 `program.error(...)` 会以未捕获的 `CommanderError` 逃出适配器的 catch,把一次用法拒绝变成插件加载失败;每个带校验的应用都得重建适配器持有的那套 try/catch。
|
||||
- **把全部校验移进 commander 的 option/argument 解析器**:`InvalidArgumentError` 覆盖逐值检查,但 headless 组合包用自己的用法信息拒绝拼接后的可变参数("任务不得为空白"),逐参数解析器表达不了。
|
||||
- **接受没有 action 的 program,依赖 settlement 诊断**:组装好的启动器确实会大声失败(`pending (waiting for service: …)`),但那个错误点名的是消费者而非配置错误的提供方,且没有 settlement 断言的嵌入宿主会静默挂起;加载时守卫直接报出肇事的 program。
|
||||
- **用裸的冻结 `readonly string[]` 服务替换 `CmdlineArgs` 访问器**:维护者保留该访问器对象作为服务的具名接口。
|
||||
|
||||
## Consequences
|
||||
|
||||
- `parseCmdline` 失去泛型、回调参数与 `undefined` 哨兵值;调用方不再需要 `if (values !== undefined)` 的发布守卫。
|
||||
- 应用的命令是自包含的——flag、help 文本、校验与发布效果一起挂在 `Command` 上。
|
||||
- action 必须是同步的:适配器调用的是 `parse` 而非 `parseAsync`,返回的 promise 会在无人观察的情况下逃出 catch。
|
||||
Reference in New Issue
Block a user