Main Chain A · Step 01

args.ts:解析与分发

apps/cli/src/args.ts(211 行)用 commander 把 argv 变成结构化的 DshInvocation。它的核心设计只有一条:launcher 只解析自己的 flags,第一个不认识的东西起全归应用。

bin.ts:20 → args.ts:145 parseDshArgs → (下一步) profile-boot.ts → 02 loadProfile

示例本次示例:dsh web 的 argv 分解

示例轨迹 01-1 · dsh web 在 args.ts 里的实际路径
# 用户输入: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(...)
来源:apps/cli/src/args.ts:201-203, 145-184(真实行号)
示例轨迹 01-2 · dsh web -h 的真实帮助输出(本机执行)
$ 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。
来源:本机执行的真实输出 · args.ts:161-165

下一站:02 loadProfile——现在 invocation 是 {mode:'profile', profile:'web', patches:[], args:[]}。

§1判别联合:四种 invocation

apps/cli/src/args.ts四种调用的类型定义21-61
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四个成员
21-30

ProfileInvocation:主模式。profile 是名字,patches 是 --patch overlay 列表(按 argv 顺序),args 是「内部参数」(launcher 不认识的所有东西,原样进插件树)。

25

0.1.5 新增 fromDefaultProfile:可选的模板名。它不是「启动哪个 profile」,而是「若目标 profile 还不存在,用哪个随包模板把它初始化出来」(apps/cli/src/profile-boot.ts:100 的 initializeProfileFromDefault)。所以它是 ? 可选的——只在首次创建时有意义。

33-41

DumpConfigInvocation:打印配置树。注意它没有 args 字段——dump 是 boot-free 的(见 §3 的守卫逻辑)。但它有 fromDefaultProfile(37 行):预览树时必须和真实 boot 用同一套初始化规则,否则预览就失去意义。

44-50

0.1.7-rc.1 新增 DumpConfigSchemaInvocation:打印 JSON Schema(供编辑器补全/外部校验配置用),而不是配置树本身。它与 DumpConfigInvocation 的关键差别是没有 defaultOnly,却保留 patches——因为 schema 档要反映「合上 --patch 之后」的字段全集,而 bundle-only 那个降级模式对它没有意义。

53-58

PluginInvocation:把剩余参数转发给 pnpm。与主链无关,但它说明了 args 字段在两种模式里语义不同(应用参数 vs pnpm 参数)。

61

判别联合:mode 就是判别标签,现在是四个成员。这就是 00 页 bin.ts switch 的穷尽性来源。

§2parseDshArgs:commander 装配

apps/cli/src/args.tsparseDshArgs 主体145-184
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    })
145-146

0.1.6-alpha.2 新增 first:提前取出第一个 token,供 201-203 行的前缀展开判断用(那时 commander 还没介入)。

153

版本号从 bin.ts 传进来(0.1.7-rc.1 起是 getDshRuntimeVersion() 的返回值,旧的 readVersion() 已删除)——launcher 自己不读 package.json,依赖注入。

154

新增 .usage():显式声明两种调用形态([--profile] <name> … 与 dsh plugin …)。以前靠 web 子命令自己的帮助文本体现,现在两种形态是同一个根命令的两种参数写法,必须在 usage 里说清。

157

exitOverride():把 commander 的「打印错误并 process.exit」改成「抛 CommanderError」。这样 205-207 行的 catch 能统一处理退出码(见 §3)。

161-165

四连 + 一个是关键设计:禁用 launcher 的 -h(它属于应用)、禁止 help 子命令(0.1.6-alpha.2 新增——否则 dsh help 会被 launcher 吃掉而不是交给应用)、允许未知选项、透传选项、启用位置参数。合起来实现「launcher 的 flags 在第一个不认识的 token 处结束,之后全归应用」。

167

selectProfile 校验器(78-81 行,0.1.6-alpha.2 新增):--profile 出现第二次就抛 InvalidArgumentError。这条规则在只有 web 子命令的时代不需要(子命令天然只能选一次),但前缀展开之后 dsh web --profile acp 这种矛盾写法变得可能,必须显式拒绝。

168-172

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 会吞掉内部参数。

173-184

action 回调:无 profile 时的兜底——若 args 里带 -h 则打印 launcher 帮助(因为没人接收它了),否则报「--profile required」。空字符串 profile 也要报错,再拒掉 desktop(182 行,它归 Electron 独占)。最终委托 resolveBoot 组装结果。

§3resolveBoot:dump 的守卫

apps/cli/src/args.tsresolveBoot + parse 收尾111-136, 200-211
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}
112-114

三个 flag 的合法性校验:空 --patch 路径、空 --from-default-profile 名字都要报错。

115-118

0.1.7-rc.1 的写法:先把三个 dump 开关收进 dumps 数组再数个数——不是 dump 就直接返回 profile 调用(主路径在第一个 if 就返回),dump 的检查是守卫。fromDefaultProfile 在这里被原样透传进 invocation。互斥判断也从「两两比较」简化成 dumps.length > 1(119-121 行),错误文案相应变成三个开关并列。

122-127

dump 是 boot-free 的,所以不允许带应用参数——注释解释得透彻:dump 不跑应用的命令行 provider,无法显示那些 flags 会决定什么;打印一棵与真实 boot 不同的树会误导人。所以直接报错。

128-130

0.1.7-rc.1 新增分支:--dump-config-schema 在这里先于 dump-config 分流——因为它保留 patches,不能落在下面那条「只打 bundle 层」的路径上。

131-134

--dump-default-config 只打印 bundle 层(无用户层、无 --patch),所以带 --patch 也报错。

201-203

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。

204-207

配合 157 行的 exitOverride():commander 的错误到这里统一 process.exit。帮助、版本、解析错误都在这个 catch 里退出——所以 bin.ts 的 switch 只会收到合法的 invocation。

209

防御性断言: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 并列在同一个命令上,互不冲突。