Main Chain A · Step 01
args.ts:解析与分发
apps/cli/src/args.ts(211 行)用 commander 把 argv 变成结构化的 DshInvocation。它的核心设计只有一条:launcher 只解析自己的 flags,第一个不认识的东西起全归应用。
示例本次示例:dsh web 的 argv 分解
# 用户输入:argv = ['web']
# args.ts:201-203:通用前缀展开(0.1.6-alpha.2 改)
# first = 'web',不是选项、也不是 'plugin'
# → expanded = ['--profile', 'web']
# commander 解析后 action 回调(args.ts:173-184)
# resolved = resolveBoot(program, 'web', options, [])
# options = { patch: undefined, dumpConfig: undefined, dumpDefaultConfig: undefined,
# dumpConfigSchema: undefined }
# 115-118 行:不是 dump → 返回 { mode:'profile', profile:'web', patches:[], args:[] }
#
# 回到 bin.ts:22-32: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 打印的(161 行禁用了 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
25 fromDefaultProfile?: string | undefined // 0.1.5 新增--from-default-profile
27 patches: string[]
29 args: string[] // 内部参数:原样交给 booted 插件树
30}
33interface DumpConfigInvocation {
34 mode: 'dump-config'
35 profile: string
37 fromDefaultProfile?: string | undefined
39 defaultOnly: boolean // true = 只打印 bundle 层(跳过用户层与 --patch)
40 patches: string[]
41}
44interface DumpConfigSchemaInvocation { // 0.1.7-rc.1 新增第四个成员
45 mode: 'dump-config-schema'
46 profile: string
48 fromDefaultProfile?: string | undefined
49 patches: string[] // 与 dump-config 不同:这里允许 --patch
50}
53interface PluginInvocation {
54 mode: 'plugin'
55 profile: string
57 args: string[] // pnpm 参数,原样转发
58}
61export type DshInvocation = ProfileInvocation | DumpConfigInvocation | DumpConfigSchemaInvocation | PluginInvocation四个成员
ProfileInvocation:主模式。profile 是名字,patches 是 --patch overlay 列表(按 argv 顺序),args 是「内部参数」(launcher 不认识的所有东西,原样进插件树)。
0.1.5 新增 fromDefaultProfile:可选的模板名。它不是「启动哪个 profile」,而是「若目标 profile 还不存在,用哪个随包模板把它初始化出来」(apps/cli/src/profile-boot.ts:100 的 initializeProfileFromDefault)。所以它是 ? 可选的——只在首次创建时有意义。
DumpConfigInvocation:打印配置树。注意它没有 args 字段——dump 是 boot-free 的(见 §3 的守卫逻辑)。但它有 fromDefaultProfile(37 行):预览树时必须和真实 boot 用同一套初始化规则,否则预览就失去意义。
0.1.7-rc.1 新增 DumpConfigSchemaInvocation:打印 JSON Schema(供编辑器补全/外部校验配置用),而不是配置树本身。它与 DumpConfigInvocation 的关键差别是没有 defaultOnly,却保留 patches——因为 schema 档要反映「合上 --patch 之后」的字段全集,而 bundle-only 那个降级模式对它没有意义。
PluginInvocation:把剩余参数转发给 pnpm。与主链无关,但它说明了 args 字段在两种模式里语义不同(应用参数 vs pnpm 参数)。
判别联合:mode 就是判别标签,现在是四个成员。这就是 00 页 bin.ts switch 的穷尽性来源。
§2parseDshArgs:commander 装配
145export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {0.1.6-alpha.2:这里多一行
146 const first = argv[0] // 只看第一个 token,决定是否展开前缀新增
147 let resolved: DshInvocation | undefined
150 const program: Command = new Command()
151 program
152 .name('dsh')
153 .version(version, '-V, --version', 'output the version number')
154 .usage('[--profile] <name> [options] [app-args...]\n dsh plugin --profile <name> <pnpm-args...>')新增:显式声明两种形态
155 .description('dsh: boot a DeepSeek Harness profile — an ordered stack of plugin-bundle patch layers under your own overrides.')
156 .addHelpText('after', HELP_EXAMPLES)
157 .exitOverride() // 不让 commander 直接 process.exit,改抛 CommanderError
161 .helpOption(false) // launcher 自己禁用 -h:它属于应用
162 .helpCommand(false) // 0.1.6-alpha.2 新增:也不接受 help 子命令保证 dsh help 交给应用
163 .allowUnknownOption()
164 .passThroughOptions()
165 .enablePositionalOptions()
166 .argument('[args...]', 'arguments for the booted profile\'s app ...')
167 .option('--profile <name>', 'the profile under $DSH_HOME/profiles to boot', selectProfile)0.1.6-alpha.2:加了去重校验器
168 .option('--from-default-profile <name>', ...)
169 .option('--patch <path>', ..., collect)
170 .option('--dump-config', ...)
171 .option('--dump-config-schema', ...) // 0.1.7-rc.1 新增第三个 dump 开关
172 .option('--dump-default-config', ...)
173 .action((args: string[], options: BootOptions & { profile?: string }) => {
176 if (options.profile === undefined) {
177 if (args.some(argument => argument === '-h' || argument === '--help')) program.help()
178 program.error('error: --profile <name> is required')
179 }
180 const profile = options.profile
181 if (profile === '') program.error('error: --profile needs a name')
182 rejectElectronProfile(program, profile)
183 resolved = resolveBoot(program, profile, options, args)
184 })
0.1.6-alpha.2 新增 first:提前取出第一个 token,供 201-203 行的前缀展开判断用(那时 commander 还没介入)。
版本号从 bin.ts 传进来(0.1.7-rc.1 起是 getDshRuntimeVersion() 的返回值,旧的 readVersion() 已删除)——launcher 自己不读 package.json,依赖注入。
新增 .usage():显式声明两种调用形态([--profile] <name> … 与 dsh plugin …)。以前靠 web 子命令自己的帮助文本体现,现在两种形态是同一个根命令的两种参数写法,必须在 usage 里说清。
exitOverride():把 commander 的「打印错误并 process.exit」改成「抛 CommanderError」。这样 205-207 行的 catch 能统一处理退出码(见 §3)。
四连 + 一个是关键设计:禁用 launcher 的 -h(它属于应用)、禁止 help 子命令(0.1.6-alpha.2 新增——否则 dsh help 会被 launcher 吃掉而不是交给应用)、允许未知选项、透传选项、启用位置参数。合起来实现「launcher 的 flags 在第一个不认识的 token 处结束,之后全归应用」。
selectProfile 校验器(78-81 行,0.1.6-alpha.2 新增):--profile 出现第二次就抛 InvalidArgumentError。这条规则在只有 web 子命令的时代不需要(子命令天然只能选一次),但前缀展开之后 dsh web --profile acp 这种矛盾写法变得可能,必须显式拒绝。
launcher 自己的其余 flag,0.1.7-rc.1 起 dump 系列有三个:--dump-config(组合树)、--dump-config-schema(JSON Schema)、--dump-default-config(只打 bundle 层)。注意 --patch 用 collect(76 行)——可重复单值收集器,绝不 variadic:注释明确说 variadic 的 --patch 会吞掉内部参数。
action 回调:无 profile 时的兜底——若 args 里带 -h 则打印 launcher 帮助(因为没人接收它了),否则报「--profile required」。空字符串 profile 也要报错,再拒掉 desktop(182 行,它归 Electron 独占)。最终委托 resolveBoot 组装结果。
§3resolveBoot:dump 的守卫
111function resolveBoot(program: Command, profile: string, options: BootOptions, args: string[]): DshInvocation {
112 const patches = options.patch ?? []
113 if (patches.includes('')) program.error('error: --patch needs a path')
114 if (options.fromDefaultProfile === '') program.error('error: --from-default-profile needs a name')
115 const dumps = [options.dumpConfig, options.dumpDefaultConfig, options.dumpConfigSchema].filter(Boolean)0.1.7-rc.1:三个开关一起数
116 if (dumps.length === 0) {
117 return { mode: 'profile', profile, fromDefaultProfile: options.fromDefaultProfile, patches, args }主路径
118 }
119 if (dumps.length > 1) {
120 program.error('error: --dump-config, --dump-default-config, and --dump-config-schema are mutually exclusive')
121 }
125 if (args.length > 0) {
126 program.error(`error: config dumps take no app arguments, got ${args...}`)
127 }
128 if (options.dumpConfigSchema === true) { // 0.1.7-rc.1 新增分支schema 先分流
129 return { mode: 'dump-config-schema', profile, fromDefaultProfile: options.fromDefaultProfile, patches }
130 }
131 const defaultOnly = options.dumpDefaultConfig === true
132 if (defaultOnly && patches.length > 0) {
133 program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
134 }
135 return { mode: 'dump-config', profile, fromDefaultProfile: options.fromDefaultProfile, defaultOnly, patches }
136}
201 const expanded = first !== undefined && !first.startsWith('-') && first !== 'plugin'0.1.6-alpha.2:通用前缀展开
202 ? ['--profile', ...argv]
203 : argv
204 program.parse(expanded, { from: 'user' })
205 } catch (error) {
206 return process.exit(error instanceof CommanderError ? error.exitCode : 1)
207 }
209 if (resolved === undefined) throw new Error('dsh: no invocation resolved')
210 return resolved
211}
三个 flag 的合法性校验:空 --patch 路径、空 --from-default-profile 名字都要报错。
0.1.7-rc.1 的写法:先把三个 dump 开关收进 dumps 数组再数个数——不是 dump 就直接返回 profile 调用(主路径在第一个 if 就返回),dump 的检查是守卫。fromDefaultProfile 在这里被原样透传进 invocation。互斥判断也从「两两比较」简化成 dumps.length > 1(119-121 行),错误文案相应变成三个开关并列。
dump 是 boot-free 的,所以不允许带应用参数——注释解释得透彻:dump 不跑应用的命令行 provider,无法显示那些 flags 会决定什么;打印一棵与真实 boot 不同的树会误导人。所以直接报错。
0.1.7-rc.1 新增分支:--dump-config-schema 在这里先于 dump-config 分流——因为它保留 patches,不能落在下面那条「只打 bundle 层」的路径上。
--dump-default-config 只打印 bundle 层(无用户层、无 --patch),所以带 --patch 也报错。
0.1.6-alpha.2 的核心改动:通用前缀展开。三个条件——first 存在、不是选项(不以 - 开头)、不是 plugin——满足就把 ['--profile', ...argv] 交给 commander。于是 dsh web、dsh tui、dsh rescue 全都等价于 dsh --profile <name>,不再需要为每个 profile 注册子命令。旧版只硬编码了 web 一个别名,`dsh tui` 得写全 --profile tui。
配合 157 行的 exitOverride():commander 的错误到这里统一 process.exit。帮助、版本、解析错误都在这个 catch 里退出——所以 bin.ts 的 switch 只会收到合法的 invocation。
防御性断言:action 一定会 resolve(要么 Commander 抛错)。这行是类型系统的兜底——注释标了 v8 ignore next,实际不可达。
为什么删除 web 子命令是进步?子命令方案有个隐藏成本:每个有别名需求的 profile 都要在 args.ts 里加一段注册代码(连同它自己的 .option 链和 rejectParentOptions 守卫)。前缀展开是零边际成本的——任何合法 profile 名自动获得简写形式。代价是失去了「子命令专属选项」的能力(旧版 web 子命令重复注册了 --patch/--dump-config),但那些选项本来就该是全局的。
§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 名、--from-default-profile、--patch,以及 0.1.7-rc.1 起的三个 dump 开关),其余一切——包括 --resume、-h——都归「被启动的应用」自己解析。这是 CLI 分层的干净边界:launcher 是协议的,应用是具体的。
0.1.5 新增的 --from-default-profile 正好检验这条边界:它是 launcher 层的概念(「用哪个随包模板把缺失的 profile 建出来」),而不是应用能自己决定的事——所以它必须在 launcher 注册。
0.1.6-alpha.2 起这条边界的实现方式变了:旧版靠 rejectParentOptions 把父级选项挡在子命令之外;现在没有 profile 子命令了,取而代之的是两个更简单的守卫——selectProfile(78-81 行)拒绝重复的 --profile,rejectElectronProfile(83-87 行)拒绝 desktop。而这正是 dsh rescue --from-default-profile web 能工作的原因:rescue 被展开成 --profile rescue,与 --from-default-profile web 并列在同一个命令上,互不冲突。