Просмотр исходного кода

feat(cli): support profile command shorthand

Turtle 1 месяц назад
Родитель
Сommit
1d6534a810

+ 6 - 0
.agents/notes/implemented/feature/2026-09-15-profile-command-shorthand.i18n.yaml

@@ -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/feature/2026-09-15-profile-command-shorthand.md
+2026-09-15-profile-command-shorthand.md: bcc17ca92292c1e79b360192fa0a02c58868eb5a
+2026-09-15-profile-command-shorthand.zh.md: d7fbdd7d39dcef901cd453d97269c4cf0421caef

+ 22 - 0
.agents/notes/implemented/feature/2026-09-15-profile-command-shorthand.md

@@ -0,0 +1,22 @@
+# Agent Note: Profile command shorthand
+
+Status: implemented
+
+English | [中文](2026-09-15-profile-command-shorthand.zh.md)
+
+## Problem
+
+Profile launch needs a concise spelling that works for custom names without making plugin management depend on the contents of the Harness home.
+
+## Decision
+
+The CLI expands a leading non-option argument other than `plugin` into `--profile <name>` before parsing. Both spellings use the same launcher flags, app-argument forwarding, and profile validation. `plugin` retains command priority; `dsh --profile plugin` selects the same-named profile explicitly.
+
+## Alternatives considered
+
+- Registering profiles as commands requires filesystem discovery and makes parsing depend on installed profiles.
+- Giving profiles priority over built-in commands makes installing a profile change the meaning of plugin-management invocations.
+
+## Consequences
+
+Custom profiles and shipped profiles share one shorthand without adding public types. Names must immediately follow `dsh`; an unknown name reaches the existing missing-profile diagnostic. Removing the dedicated `web` command also lets an already selected profile receive `web` as an app argument. Parser equivalence tests, built-bin acceptance, and the keyless headless tool round trip cover the shared launch path.

+ 22 - 0
.agents/notes/implemented/feature/2026-09-15-profile-command-shorthand.zh.md

@@ -0,0 +1,22 @@
+# Agent Note: Profile 命令简写
+
+Status: implemented
+
+[English](2026-09-15-profile-command-shorthand.md) | 中文
+
+## Problem
+
+Profile 启动需要一种适用于自定义名称的简洁写法,同时不能让插件管理依赖 Harness home 中的内容。
+
+## Decision
+
+CLI 在解析前,将开头非选项且非 `plugin` 的参数展开为 `--profile <name>`。两种写法使用相同的启动器 flag、应用参数透传和 profile 校验。`plugin` 保持命令优先级;`dsh --profile plugin` 显式选择同名 profile。
+
+## Alternatives considered
+
+- 将 profile 注册为命令需要扫描文件系统,并使解析依赖已安装的 profile。
+- 让 profile 优先于内置命令,会使安装 profile 改变插件管理调用的含义。
+
+## Consequences
+
+自定义和内置 profile 共用一种简写,无需新增公开类型。名称必须紧跟 `dsh`;未知名称会触发现有的 profile 缺失诊断。移除专用的 `web` 命令后,已选定的 profile 也能将 `web` 作为应用参数接收。解析等价性测试、构建产物验收和无密钥 headless 工具往返场景覆盖共用的启动路径。

+ 2 - 2
apps/cli/README.i18n.yaml

@@ -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 apps/cli/README.md
-README.md: 92bd282b1148217a41ce12bbb4d227df088b13a0
-README.zh.md: b64ed8f81fb28acb70d96b1b0c116ea03dbd5fbb
+README.md: a06de79613fec842005245f2fefe6a2cd420ba72
+README.zh.md: e4037a9b194e3e51d142604feb5eb7400faedf79

+ 1 - 1
apps/cli/README.md

@@ -8,7 +8,7 @@ The `dsh` command is the sole supported Node application launcher: profiles are
 
 | Command | Purpose |
 |---|---|
-| `dsh --profile <name>` | Boot the named profile under `$DSH_HOME/profiles/<name>`. |
+| `dsh <name>` / `dsh --profile <name>` | Boot the named profile under `$DSH_HOME/profiles/<name>`. |
 | `dsh --profile <name> --from-default-profile <template>` | Create a new custom profile from a shipped template, then boot it. |
 | `dsh --profile acp` | Serve automation clients over ACP stdio until disconnect. |
 | `dsh --profile headless "job"` | Run one fresh persisted session, print the final answer, and exit. |

+ 1 - 1
apps/cli/README.zh.md

@@ -8,7 +8,7 @@
 
 | 命令 | 用途 |
 |---|---|
-| `dsh --profile <name>` | 启动位于 `$DSH_HOME/profiles/<name>` 的指定 profile。 |
+| `dsh <name>` / `dsh --profile <name>` | 启动位于 `$DSH_HOME/profiles/<name>` 的指定 profile。 |
 | `dsh --profile <name> --from-default-profile <template>` | 从随附模板创建新的自定义 profile,然后启动它。 |
 | `dsh --profile acp` | 通过 ACP stdio 为自动化客户端提供服务,直至断开连接。 |
 | `dsh --profile headless "job"` | 运行一个全新的持久化会话,打印最终答案并退出。 |

+ 2 - 2
apps/cli/reference/README.i18n.yaml

@@ -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 apps/cli/reference/README.md
-README.md: dcf9f6e3ad2304bf13a5497b9252638fdef2638f
-README.zh.md: c1138bbed8a884757d97c445061bb1686f47b44c
+README.md: ef7cdadb8001254a01763a333b5ac71ebafedabc
+README.zh.md: 1d4a7caf3139aa23fe5d503a354dea8f3fea04ab

+ 7 - 7
apps/cli/reference/README.md

@@ -2,11 +2,11 @@
 
 English | [中文](README.zh.md)
 
-This reference defines the profile, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner.
+This reference defines the profile, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner.
 
 ## Profile boot
 
-`dsh --profile <name>` boots the profile at `$DSH_HOME/profiles/<name>`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch <path>` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. The final YAML composition controls whether `dsh-hmr` watches configuration; without HMR, changes require restart. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit.
+`dsh <name>` abbreviates `dsh --profile <name>` and boots the profile at `$DSH_HOME/profiles/<name>`. The shorthand name must immediately follow `dsh`; `plugin` remains the plugin-management command, so boot a profile with that name using `dsh --profile plugin`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch <path>` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. The final YAML composition controls whether `dsh-hmr` watches configuration; without HMR, changes require restart. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit.
 
 Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-sdk-minimal`, `@deepseek-ai/dsh-acp-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. Before mounting rows, the launcher traverses the installation and selected bundles in that order and materializes the resulting fallback links. The internal runtime and dual modes consume the same immutable generation in tests without changing the CLI's link-mode behavior. Profile-installed packages keep native priority in every mode.
 
@@ -17,17 +17,17 @@ The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize
 An existing profile rejects `--from-default-profile` without changing or booting it; omit the option to use it. A residual target directory is also preserved and requires a different profile name. An unknown template or a shipped target name fails before creating the target. Unknown-template diagnostics name the valid templates. Initialization is committed before bundle resolution and application boot, so a later failure leaves the new profile on disk and the retry omits the creation option. `--dump-config` and `--dump-default-config` accept the option, initialize the target, print the requested tree, and do not boot it.
 
 ```sh
-dsh --profile rescue --from-default-profile web
-dsh --profile rescue
+dsh rescue --from-default-profile web
+dsh rescue
 ```
 
 ### App arguments
 
-The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where any injected app plugin may parse it ([`dsh-cmdline`](../../../packages/boot/cmdline/README.md)). `dsh --profile rescue --from-default-profile web --no-open` therefore initializes before handing `--no-open` to Web, `dsh --profile web --port 8080` reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary.
+The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where any injected app plugin may parse it ([`dsh-cmdline`](../../../packages/boot/cmdline/README.md)). `dsh rescue --from-default-profile web --no-open` therefore initializes before handing `--no-open` to Web, `dsh --profile web --port 8080` reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary.
 
 A composition mounts once. An ordinary plugin injects `cmdlineArgs`, parses this app's arguments, and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the provider's service. With HMR enabled, a patch-file edit re-evaluates expressions against services that are still up, so it cannot reset a served port.
 
-Launcher flags must come before app arguments, and the launcher's parser consumes one `--`: an app argument that must arrive as a literal `--` needs `-- --`. A first app argument equal to `web` or `plugin` selects that subcommand instead. `ctx.cmdlineArgs.get()` is a shared immutable read: multiple plugins may parse the same snapshot, while a profile with no reader ignores its app arguments.
+Launcher flags must come before app arguments, and the launcher's parser consumes one `--`: an app argument that must arrive as a literal `--` needs `-- --`. A first app argument equal to `plugin` selects that subcommand instead; `web` is an ordinary app argument after a profile has been selected. `ctx.cmdlineArgs.get()` is a shared immutable read: multiple plugins may parse the same snapshot, while a profile with no reader ignores its app arguments.
 
 The shipped apps own these command lines:
 
@@ -76,7 +76,7 @@ Git-hosted plugins that ship sources build during install through their `prepare
 
 ## Web alias
 
-`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until a separate `pnpm run dev:web` watcher rebuilds client bundles.
+`dsh web` uses the profile shorthand. Launcher flags are parsed first; the remaining flags belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until a separate `pnpm run dev:web` watcher rebuilds client bundles.
 
 ```sh
 dsh web

+ 7 - 7
apps/cli/reference/README.zh.md

@@ -2,13 +2,13 @@
 
 [English](README.md) | 中文
 
-本参考定义 profile 启动、web 别名、插件管理和配置 dump 等命令模式。argv 由 [`src/args.ts`](../src/args.ts) 统一解析一次,[`src/bin.ts`](../src/bin.ts) 只会动态导入选中的运行器。
+本参考定义 profile 启动、插件管理和配置 dump 等命令模式。argv 由 [`src/args.ts`](../src/args.ts) 统一解析一次,[`src/bin.ts`](../src/bin.ts) 只会动态导入选中的运行器。
 
 <a id="profile-boot"></a>
 
 ## Profile 启动
 
-`dsh --profile <name>` 启动位于 `$DSH_HOME/profiles/<name>` 的 profile。生效配置树以空根节点为起点,依次叠加 profile manifest(元数据清单)的 `dsh.profile.bundles` 列表中指定的各组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(这是各 profile 共享的机器本地偏好,因此优先于逐 profile 配置层),以及按 argv 顺序指定的各个 `--patch <path>` 覆盖层。对同一配置行,后应用的层优先。patch 会替换目标行的整个 `config` 值,而不是深度合并其中的键;patch 也可以插入新行。最终 YAML 组合决定是否由 `dsh-hmr` 监视配置;未启用 HMR 时,更改需要重启。配置解析、schema 校验、模块解析或插件启动失败时,系统会报告错误并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。
+`dsh <name>` 是 `dsh --profile <name>` 的简写,启动位于 `$DSH_HOME/profiles/<name>` 的 profile。简写中的名称必须紧跟 `dsh`;`plugin` 仍为插件管理命令,因此启动同名 profile 时须使用 `dsh --profile plugin`。生效配置树以空根节点为起点,依次叠加 profile manifest(元数据清单)的 `dsh.profile.bundles` 列表中指定的各组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(这是各 profile 共享的机器本地偏好,因此优先于逐 profile 配置层),以及按 argv 顺序指定的各个 `--patch <path>` 覆盖层。对同一配置行,后应用的层优先。patch 会替换目标行的整个 `config` 值,而不是深度合并其中的键;patch 也可以插入新行。最终 YAML 组合决定是否由 `dsh-hmr` 监视配置;未启用 HMR 时,更改需要重启。配置解析、schema 校验、模块解析或插件启动失败时,系统会报告错误并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。
 
 组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-sdk-minimal`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包来自 profile 中由 pnpm 管理的 `node_modules`。挂载配置行前,launcher 会按此顺序遍历安装与所选 bundle,并物化计算出的 fallback 链接。内部 runtime 与 dual 模式会在测试中消费同一份不可变 generation,但不改变 CLI 的 link 模式行为。所有模式都保留 profile 已安装包的原生优先级。
 
@@ -19,17 +19,17 @@
 profile 已经存在时,`--from-default-profile` 会被拒绝,且不会修改或启动它;去掉该选项即可使用它。残留的目标目录同样会被原样保留,此时必须改用另一个 profile 名称。未知模板或随附目标名称会在创建目标之前失败;未知模板的诊断会列出有效模板。初始化在组合包解析和应用启动之前提交,因此后续失败仍会把新 profile 留在磁盘上,重试时需要去掉创建选项。`--dump-config` 和 `--dump-default-config` 接受该选项:它们初始化目标并打印所请求的配置树,但不启动应用。
 
 ```sh
-dsh --profile rescue --from-default-profile web
-dsh --profile rescue
+dsh rescue --from-default-profile web
+dsh rescue
 ```
 
 ### 应用参数
 
-启动器自身的 flag 必须写在最前面,并在遇到第一个无法识别的 token 时结束;从该 token 开始的所有内容都会通过 `ctx.cmdlineArgs` 原样交给已启动的 profile,注入该 profile 的任意应用插件都可以解析这些内容([`dsh-cmdline`](../../../packages/boot/cmdline/README.zh.md))。因此,`dsh --profile rescue --from-default-profile web --no-open` 会先初始化,再把 `--no-open` 交给 Web;`dsh --profile web --port 8080` 会将 `--port` 交给 web 应用;`dsh --profile web --help` 只打印该应用的帮助信息,不启动应用;`dsh --help` 没有可供交付参数的 profile,因此会打印启动器自身的帮助信息。`-V`/`--version` 位于应用参数边界之前时,会打印启动器的版本。
+启动器自身的 flag 必须写在最前面,并在遇到第一个无法识别的 token 时结束;从该 token 开始的所有内容都会通过 `ctx.cmdlineArgs` 原样交给已启动的 profile,注入该 profile 的任意应用插件都可以解析这些内容([`dsh-cmdline`](../../../packages/boot/cmdline/README.zh.md))。因此,`dsh rescue --from-default-profile web --no-open` 会先初始化,再把 `--no-open` 交给 Web;`dsh --profile web --port 8080` 会将 `--port` 交给 web 应用;`dsh --profile web --help` 只打印该应用的帮助信息,不启动应用;`dsh --help` 没有可供交付参数的 profile,因此会打印启动器自身的帮助信息。`-V`/`--version` 位于应用参数边界之前时,会打印启动器的版本。
 
 每套组合只会挂载一次。普通插件注入 `cmdlineArgs`,解析所属应用的参数,并将解析结果作为服务提供。每个从 flag 取值的配置行都会注入该服务;Loader 会等到服务激活后,再对该行的配置求值(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 的优先级高于配置行中写明的值。要维持这一优先级,配置行必须保留该表达式;如果用户 patch 用字面量替换整个 `config`,也会随之移除运行时读取。帮助参数和被拒绝的参数都会请求退出:参数被拒绝时以非零状态退出,显示帮助时以 0 退出;依赖该提供方服务的配置行不会激活。启用 HMR 时,编辑 patch 文件会根据仍在运行的服务重新计算表达式,因此不会重置当前正在使用的端口。
 
-启动器的 flag 必须写在应用参数之前,且启动器的解析器会消耗掉一个 `--`:必须以字面量 `--` 送达应用的参数需要写成 `-- --`。如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令。`ctx.cmdlineArgs.get()` 是共享的不可变读取:多个插件可以解析同一份快照,没有读取方的 profile 则会忽略自己的应用参数。
+启动器的 flag 必须写在应用参数之前,且启动器的解析器会消耗掉一个 `--`:必须以字面量 `--` 送达应用的参数需要写成 `-- --`。如果应用的第一个参数恰好等于 `plugin`,会选择该子命令;选定 profile 后,`web` 是普通应用参数。`ctx.cmdlineArgs.get()` 是共享的不可变读取:多个插件可以解析同一份快照,没有读取方的 profile 则会忽略自己的应用参数。
 
 随附的应用接受以下命令行参数:
 
@@ -78,7 +78,7 @@ dsh --profile tui
 
 ## Web 别名
 
-`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--no-open` 则只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在单独运行的 `pnpm run dev:web` watcher 重建客户端 bundle 之前保持空闲。
+`dsh web` 使用 profile 简写。启动器先解析自身的 flag,其余 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--no-open` 则只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在单独运行的 `pnpm run dev:web` watcher 重建客户端 bundle 之前保持空闲。
 
 ```sh
 dsh web

+ 17 - 27
apps/cli/src/args.ts

@@ -10,7 +10,7 @@
  * `dsh --profile tui --resume abc` boots the tui profile with `--resume abc`,
  * and `dsh --profile web -h` prints the web app's help, not this one's.
  *
- * `web` is a hardcoded alias for `--profile web`; `plugin` manages a profile's
+ * `dsh <name>` abbreviates `dsh --profile <name>`; `plugin` manages a profile's
  * plugin dependencies by forwarding to pnpm.
  * @module @deepseek-ai/dsh/args
  */
@@ -51,7 +51,7 @@ interface PluginInvocation {
 /** The resolved `dsh` invocation. Help, version, and errors exit inside {@link parseDshArgs}. */
 export type DshInvocation = ProfileInvocation | DumpConfigInvocation | PluginInvocation
 
-/** Launcher flags shared by the default command and the `web` alias. */
+/** Launcher flags for profile boot and configuration dumps. */
 interface BootOptions {
   patch?: string[]
   dumpConfig?: boolean
@@ -74,20 +74,20 @@ function rejectElectronProfile(program: Command, profile: string): void {
 /** The launcher's own help text; each app prints its own. */
 const HELP_EXAMPLES = `
 Examples:
-  dsh --profile web                          boot the web profile (same as: dsh web)
-  dsh --profile rescue --from-default-profile web
-                                             create rescue from the shipped web template, then boot it
-  dsh --profile headless "run the tests"     answer one task, print the result, and exit
-  dsh --profile tui --patch ./extra.yml      boot a custom profile with one extra overlay
-  dsh --profile tui --resume <session>       arguments after the launcher flags reach the app
-  dsh --profile web --help                   the web app's own flags and help
-  dsh plugin --profile tui add <package>     install a plugin into the tui profile
+  dsh web                                   boot the web profile (same as: dsh --profile web)
+  dsh rescue --from-default-profile web
+                                            create rescue from the shipped web template, then boot it
+  dsh headless "run the tests"               answer one task, print the result, and exit
+  dsh tui --patch ./extra.yml                boot a custom profile with one extra overlay
+  dsh tui --resume <session>                 arguments after the launcher flags reach the app
+  dsh web --help                             the web app's own flags and help
+  dsh plugin --profile tui add <package>      install a plugin into the tui profile
 `
 
 /**
  * Resolve a boot or dump invocation from the launcher flags and the leftover
  * inner arguments.
- * @param program - the command whose options were parsed (the root, or the `web` alias).
+ * @param program - the command whose options were parsed.
  * @param profile - the profile these flags boot.
  * @param options - the launcher flags commander collected.
  * @param args - the leftover arguments, in argv order.
@@ -131,6 +131,7 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
   program
     .name('dsh')
     .version(version, '-V, --version', 'output the version number')
+    .usage('[--profile] <name> [options] [app-args...]')
     .description('dsh: boot a DeepSeek Harness profile — an ordered stack of plugin-bundle patch layers under your own overrides.')
     .addHelpText('after', HELP_EXAMPLES)
     .exitOverride()
@@ -172,21 +173,6 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
     }
   }
 
-  const web = program.command('web').description('boot the web profile (alias of --profile web); the web app\'s own flags follow')
-  web
-    .helpOption(false)
-    .allowUnknownOption()
-    .passThroughOptions()
-    .enablePositionalOptions()
-    .argument('[args...]', 'arguments for the web app (see: dsh web --help)')
-    .option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
-    .option('--dump-config', 'print the composed web-profile tree (with the user layer and any --patch) and exit')
-    .option('--dump-default-config', 'print the web profile\'s bundle layers (no user layer) and exit')
-    .action((args: string[], options: BootOptions) => {
-      rejectParentOptions('web')
-      resolved = resolveBoot(web, 'web', options, args)
-    })
-
   const plugin = program.command('plugin').description('manage a profile\'s plugins by forwarding the remaining arguments to pnpm in the profile directory')
   plugin
     .requiredOption('--profile <name>', 'the profile whose plugins to manage (initialized on first use)')
@@ -201,7 +187,11 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
     })
 
   try {
-    program.parse(argv, { from: 'user' })
+    const first = argv[0]
+    const expanded = first !== undefined && !first.startsWith('-') && first !== 'plugin'
+      ? ['--profile', ...argv]
+      : argv
+    program.parse(expanded, { from: 'user' })
   } catch (error) {
     return process.exit(error instanceof CommanderError ? error.exitCode : 1)
   }

+ 38 - 5
apps/cli/tests/args.spec.ts

@@ -21,7 +21,7 @@ function exitCode(argv: string[]): number {
 afterEach(() => { vi.restoreAllMocks() })
 
 describe('parseDshArgs', () => {
-  it('routes profile boots and the web alias, handing the rest to the app', () => {
+  it('routes profile boots and shorthand, handing the rest to the app', () => {
     expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [], args: [] })
     expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--patch', 'b.yml']))
       .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'], args: [] })
@@ -53,7 +53,43 @@ describe('parseDshArgs', () => {
         args: ['--resume', 'abc', '--from-default-profile', 'web'],
       })
     expect(parse(['web', '--from-default-profile', 'web']))
-      .toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['--from-default-profile', 'web'] })
+      .toEqual({ mode: 'profile', profile: 'web', fromDefaultProfile: 'web', patches: [], args: [] })
+  })
+
+  it.each(['web', 'headless', 'sdk', 'sdk-minimal', 'acp', 'tui', 'custom', 'run', 'help'])('expands %s without looking up profiles', (profile) => {
+    for (const args of [
+      [], ['task', 'words'], ['--help'], ['-h'], ['web'],
+      ['--patch', 'a.yml', '--patch', 'b.yml'],
+      ['--from-default-profile', 'web', '--help'],
+      ['--dump-config'], ['--dump-default-config'],
+      ['--patch', 'a.yml', '--resume', 'id', '--patch', 'late.yml'],
+      ['--', '--help'], ['--', '--', 'task'],
+      ['--profile', 'other'],
+    ]) {
+      expect(parse([profile, ...args])).toEqual(parse(['--profile', profile, ...args]))
+    }
+  })
+
+  it('keeps plugin management reserved and forwards web as an app argument', () => {
+    expect(parse(['--profile', 'plugin'])).toMatchObject({ mode: 'profile', profile: 'plugin' })
+    expect(parse(['headless', 'web'])).toMatchObject({ profile: 'headless', args: ['web'] })
+    expect(exitCode(['--patch', 'a.yml', 'tui'])).toBe(1)
+    expect(exitCode(['--', 'tui'])).toBe(1)
+  })
+
+  it.each([
+    [''], ['desktop'], ['Desktop'], ['DESKTOP'],
+    ['custom', '--patch='], ['custom', '--from-default-profile='],
+    ['custom', '--dump-config', '--dump-default-config'],
+    ['custom', '--dump-default-config', '--patch', 'a.yml'],
+    ['custom', '--dump-config', 'task'],
+  ])('rejects invalid shorthand %j', (...argv: string[]) => {
+    expect(exitCode(argv)).toBe(1)
+  })
+
+  it.each(['-V', '--version'])('prints the launcher version for shorthand %s', (flag) => {
+    expect(exitCode(['custom', flag])).toBe(0)
+    expect(parse(['custom', 'task', flag])).toMatchObject({ args: ['task', flag] })
   })
 
   it('routes the plugin pnpm forwarder', () => {
@@ -91,10 +127,8 @@ describe('parseDshArgs', () => {
 
   it('rejects missing profile, removed flags, and contradictory inputs', () => {
     expect(exitCode([])).toBe(1)
-    expect(exitCode(['tui'])).toBe(1) // an app argument without --profile has no app to reach
     expect(exitCode(['--config', 'c.yml'])).toBe(1) // removed
     expect(exitCode(['-p', 'task'])).toBe(1) // removed
-    expect(exitCode(['run', 'task'])).toBe(1) // app-owned task replaced the launcher subcommand
     expect(exitCode(['--profile', ''])).toBe(1)
     expect(exitCode(['--profile', 'x', '--from-default-profile='])).toBe(1)
     expect(exitCode(['--profile', 'x', '--from-default-profile'])).toBe(1)
@@ -104,7 +138,6 @@ describe('parseDshArgs', () => {
     expect(exitCode(['--profile', 'x', '--dump-default-config', '--patch', 'p.yml'])).toBe(1)
     expect(exitCode(['--profile', 'x', '--dump-config', 'task'])).toBe(1)
     expect(exitCode(['--bogus'])).toBe(1)
-    expect(exitCode(['--profile', 'x', 'web'])).toBe(1)
     expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1)
     expect(exitCode(['web', '--dump-default-config', '--patch', 'w.yml'])).toBe(1)
     expect(exitCode(['web', '--patch='])).toBe(1)

+ 12 - 12
apps/cli/tests/built-bin.e2e.ts

@@ -341,7 +341,7 @@ function startStartupProfile(fixture: StartupFixture, args: readonly string[]) {
 }
 
 describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
-  it('requires --profile and rejects removed commands', async () => {
+  it('requires a profile and rejects removed flags', async () => {
     const bare = await runBuiltBin()
     expect(bare.code).toBe(1)
     expect(bare.stdout).toBe('')
@@ -351,7 +351,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     expect(help.stdout).toContain('dsh --profile web')
     expect(help.stdout).toContain('dsh plugin --profile')
     expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu)
-    for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['run', 'task']]) {
+    for (const removed of [['--config', 'x.yml'], ['-p', 'task']]) {
       const result = await runBuiltBin(removed)
       expect(result.code).toBe(1)
     }
@@ -379,7 +379,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       expect(wildcardHost.stderr).toContain('--host 0.0.0.0 is intentionally not supported yet for safety: it would expose remote code execution to the network; use 127.0.0.1 instead')
       expect(wildcardHost.stderr).not.toContain('dsh web: http://')
 
-      const headlessHelp = await runBuiltBin(['--profile', 'headless', '--help'], {
+      const headlessHelp = await runBuiltBin(['headless', '--help'], {
         DSH_HOME: home,
         DSH_TELEMETRY_DISABLED: '1',
       })
@@ -387,7 +387,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       expect(headlessHelp.stderr).toBe('')
       expect(headlessHelp.stdout).toContain('Usage: dsh --profile headless')
 
-      const sdkHelp = await runBuiltBin(['--profile', 'sdk', '--help'], {
+      const sdkHelp = await runBuiltBin(['sdk', '--help'], {
         DSH_HOME: home,
         DSH_TELEMETRY_DISABLED: '1',
       })
@@ -395,7 +395,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       expect(sdkHelp.stderr).toBe('')
       expect(sdkHelp.stdout).toContain('Usage: dsh --profile sdk')
 
-      const acpHelp = await runBuiltBin(['--profile', 'acp', '--help'], {
+      const acpHelp = await runBuiltBin(['acp', '--help'], {
         DSH_HOME: home,
         DSH_TELEMETRY_DISABLED: '1',
       })
@@ -655,7 +655,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
   it('fails loud on a nonexistent profile with the plugin-command hint', async () => {
     const home = mkdtempSync(join(tmpdir(), 'dsh-missing-profile-'))
     try {
-      const result = await runBuiltBin(['--profile', 'nope'], { DSH_HOME: home })
+      const result = await runBuiltBin(['nope'], { DSH_HOME: home })
       expect(result.code).toBe(1)
       expect(result.stderr).toContain('profile "nope" does not exist')
       expect(result.stderr).toContain('dsh plugin --profile nope add')
@@ -668,7 +668,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     const home = mkdtempSync(join(tmpdir(), 'dsh-from-default-profile-'))
     try {
       const created = await runBuiltBin(
-        ['--profile', 'rescue', '--from-default-profile', 'web', '--help'],
+        ['rescue', '--from-default-profile', 'web', '--help'],
         { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' },
       )
       expect(created.code).toBe(0)
@@ -688,7 +688,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('nodeLinker: hoisted')
 
       const repeated = await runBuiltBin(
-        ['--profile', 'rescue', '--from-default-profile', 'web', '--help'],
+        ['rescue', '--from-default-profile', 'web', '--help'],
         { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' },
       )
       expect(repeated.code).toBe(1)
@@ -697,7 +697,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       expect(repeated.stderr).toContain('omit --from-default-profile to use it')
 
       const reopened = await runBuiltBin(
-        ['--profile', 'rescue', '--help'],
+        ['rescue', '--help'],
         { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' },
       )
       expect(reopened.code).toBe(0)
@@ -720,7 +720,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       expect(existsSync(join(home, 'profiles', 'rescue', 'package.json'))).toBe(true)
 
       const retried = await runBuiltBin(
-        ['--profile', 'rescue', '--help'],
+        ['rescue', '--help'],
         { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' },
       )
       expect(retried.code).toBe(0)
@@ -1185,7 +1185,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     afterEach(() => { rmSync(home, { recursive: true, force: true }) })
 
     it('prints the web profile bundle layers without a user layer', async () => {
-      const { stdout, code, stderr } = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })
+      const { stdout, code, stderr } = await runBuiltBin(['web', '--dump-default-config'], { DSH_HOME: home })
       expect(code).toBe(0)
       expect(stderr).toBe('')
       expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'")
@@ -1280,7 +1280,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
 
     it('composes the profile user layer and a --patch overlay in order', async () => {
       // Auto-init the web profile first, then write its user layer.
-      const init = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })
+      const init = await runBuiltBin(['web', '--dump-default-config'], { DSH_HOME: home })
       expect(init.code).toBe(0)
       const profilePatch = join(home, 'profiles', 'web', 'cordis.patch.yml')
       writeFileSync(profilePatch, [

+ 1 - 1
apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts

@@ -241,7 +241,7 @@ describe('headless stream-json snapshots', () => {
       tempDirPrefix: 'headless-snapshot-profile-',
       binScript: dshBinScript,
       configPath: headlessOverlayPath,
-      binArgs: ['--profile', 'headless', '--patch', headlessOverlayPath, task],
+      binArgs: ['headless', '--patch', headlessOverlayPath, task],
       tsconfigPath,
       env: {
         DSH_PERMISSION_MODE: 'danger-full-access',