Main Chain A · Step 01
args.ts:解析与分发
apps/cli/src/args.ts(191 行)用 commander 把 argv 变成结构化的 DshInvocation。它的核心设计只有一条:launcher 只解析自己的 flags,第一个不认识的东西起全归应用。
示例本次示例:dsh web 的 argv 分解
# 用户输入:argv = ['web']
# args.ts:156 注册了名为 web 的子命令(硬编码别名,等价 --profile web)
# 匹配 subcommand 'web' → action 回调(args.ts:166-169)
# rejectParentOptions('web') ← web 子命令不接受父级 --profile 等选项
# resolved = resolveBoot(web, 'web', options, [])
# options = { patch: undefined, dumpConfig: undefined, dumpDefaultConfig: undefined }
# 86 行:不是 dump → 返回 { mode: 'profile', profile: 'web', patches: [], args: [] }
#
# 回到 bin.ts:28-38:mode === 'profile' → 动态 import profile-boot.ts → runProfile(...)
$ node --import tsx/esm apps/cli/src/bin.ts web --help Usage: dsh --profile web [options] Serve the DeepSeek Harness browser UI. Options: --host <host> bind host --no-open do not open the Web UI in the default browser --port <port> listen port; pass 0 to let the OS pick a free one --trusted-host <authority...> extra authority the /api browser-trust fence accepts -h, --help show this help # 注意:这个帮助不是 launcher 打印的(126 行禁用了 launcher 的 -h), # 而是 web 应用自己打印的——'--host/--port' 全是 launcher 不认识的应用 flags。
下一站:02 loadProfile——现在 invocation 是 {mode:'profile', profile:'web', patches:[], args:[]}。
§1判别联合:三种 invocation
21interface ProfileInvocation {
22 mode: 'profile'
23 profile: string
24 patches: string[]
26 args: string[] // 内部参数:原样交给 booted 插件树
27}
30interface DumpConfigInvocation {
31 mode: 'dump-config'
32 profile: string
34 defaultOnly: boolean // true = 只打印 bundle 层(跳过用户层与 --patch)
35 patches: string[]
36}
39interface PluginInvocation {
40 mode: 'plugin'
41 profile: string
43 args: string[] // pnpm 参数,原样转发
44}
48export type DshInvocation = ProfileInvocation | DumpConfigInvocation | PluginInvocation
ProfileInvocation:主模式。四个字段各司其职——profile 是名字,patches 是 --patch overlay 列表(按 argv 顺序),args 是「内部参数」(launcher 不认识的所有东西,原样进插件树)。
DumpConfigInvocation:打印配置树。注意它没有 args 字段——dump 是 boot-free 的(见 §3 的守卫逻辑)。
PluginInvocation:把剩余参数转发给 pnpm。与主链无关,但它说明了 args 字段在两种模式里语义不同(应用参数 vs pnpm 参数)。
判别联合:mode 就是判别标签。这就是 00 页 bin.ts switch 的穷尽性来源。
§2parseDshArgs:commander 装配
112export function parseDshArgs(argv, version): DshInvocation {
116 const program: Command = new Command()
117 program
118 .name('dsh')
119 .version(version, '-V, --version', 'output the version number')
121 .addHelpText('after', HELP_EXAMPLES)
122 .exitOverride() // 不让 commander 直接 process.exit,改抛 CommanderError
126 .helpOption(false) // launcher 自己禁用 -h:它属于应用
127 .allowUnknownOption()
128 .passThroughOptions()
129 .enablePositionalOptions()
130 .argument('[args...]', 'arguments for the booted profile\'s app ...')
131 .option('--profile <name>', ...)
132 .option('--patch <path>', ..., collect)
133 .option('--dump-config', ...)
134 .option('--dump-default-config', ...)
135 .action((args, options) => {
138 if (options.profile === undefined) {
139 if (args.some(argument => argument === '-h' || argument === '--help')) program.help()
140 program.error('error: --profile <name> is required')
141 }
142 const profile = options.profile
143 if (profile === '') program.error('error: --profile needs a name')
144 resolved = resolveBoot(program, profile, options, args)
145 })
版本号从 bin.ts 的 readVersion() 传进来——launcher 自己不读 package.json,依赖注入。
exitOverride():把 commander 的「打印错误并 process.exit」改成「抛 CommanderError」。这样 183-187 行的 catch 能统一处理退出码(见 §3)。
四连是关键设计:禁用 launcher 的 -h(它属于应用)、允许未知选项、透传选项、启用位置参数——合起来实现「launcher 的 flags 在第一个不认识的 token 处结束,之后全归应用」。于是 dsh --profile web -h 打印的是 web 应用的帮助,不是 launcher 的。
launcher 自己的四个 flag。注意 --patch 用 collect(61 行)——可重复单值收集器,绝不 variadic:注释明确说 variadic 的 --patch 会吞掉内部参数。
action 回调:无 profile 时的兜底——若 args 里带 -h 则打印 launcher 帮助(因为没人接收它了),否则报「--profile required」。空字符串 profile 也要报错。最终委托 resolveBoot 组装结果。
§3resolveBoot:dump 的守卫
83function resolveBoot(program, profile, options, args): DshInvocation {
84 const patches = options.patch ?? []
85 if (patches.includes('')) program.error('error: --patch needs a path')
86 if (options.dumpConfig !== true && options.dumpDefaultConfig !== true) {
87 return { mode: 'profile', profile, patches, args }
88 }
89 if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
90 program.error('error: --dump-config and --dump-default-config are mutually exclusive')
91 }
95 if (args.length > 0) {
96 program.error(`error: config dumps take no app arguments, got ${args...}`)
97 }
98 const defaultOnly = options.dumpDefaultConfig === true
99 if (defaultOnly && patches.length > 0) {
100 program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
101 }
102 return { mode: 'dump-config', profile, defaultOnly, patches }
103}
183 try {
184 program.parse(argv, { from: 'user' })
185 } catch (error) {
186 return process.exit(error instanceof CommanderError ? error.exitCode : 1)
187 }
189 if (resolved === undefined) throw new Error('dsh: no invocation resolved')
190 return resolved
不是 dump 就直接返回 profile 调用——主路径在第一个 if 就返回,dump 的检查是守卫。
dump 是 boot-free 的,所以不允许带应用参数——注释解释得透彻:dump 不跑应用的命令行 provider,无法显示那些 flags 会决定什么;打印一棵与真实 boot 不同的树会误导人。所以直接报错。
--dump-default-config 只打印 bundle 层(无用户层、无 --patch),所以带 --patch 也报错。
配合 122 行的 exitOverride():commander 的错误到这里统一 process.exit。帮助、版本、解析错误都在这个 catch 里退出——所以 bin.ts 的 switch 只会收到合法的 invocation。
防御性断言:action 一定会 resolve(要么 Commander 抛错)。这行是类型系统的兜底——注释标了 v8 ignore next,实际不可达。
§4边界设计:flags 归属
这一页最值得记住的设计,是文件头 JSDoc 里那句:
The launcher parses only what it owns — which profile to boot, which extra patch overlays to apply, and the config dumps — and hands everything after its own flags to the booted tree verbatim.
launcher 只拥有四件事(profile 名、--patch、两个 dump 开关),其余一切——包括 --resume、-h——都归「被启动的应用」自己解析。这是 CLI 分层的干净边界:launcher 是协议的,应用是具体的。