工具装好了,--help 写了 60 行,README 贴了三条示例。一个月后同事还是用他那套 sed | xargs 拼出来的旧脚本。这不是培训问题:命令行工具的界面只有一条窄缝——从键盘输入到屏幕输出。用户在这条缝里学习你的工具,能拿到的线索只有三种:按 Tab 出现的候选、--help 的文本、敲错时的报错。可发现性就是把这三处设计好。
真正的决策不是"要不要写帮助",而是这三处的投入怎么分配,以及什么条件下这笔投入不该花。
1. 三个入口的分工
| 入口 | 触发时机 | 用户此刻状态 | 解决的问题 | 实现成本 |
|---|---|---|---|---|
| 补全 | 敲到一半按 Tab | 已决定用工具,正想参数 | 存在性:有什么子命令、什么取值 | 中到高 |
--help |
主动索取 | 不确定意图能否表达 | 语法:互斥、默认值 | 低,易腐坏 |
| 错误提示 | 参数不合法 | 已失败,最想放弃 | 修正路径:改哪个字 | 低到中,最被忽略 |
优先级和直觉相反:错误提示收益最高,它出现在用户最想放弃的瞬间;补全成本最高,要和 shell 生态对接;--help 最好写,也最容易和实际行为脱节。
可用的配比是先让错误提示"能复制粘贴重跑",再做 --help 的示例层,最后才考虑补全。反过来做,就是花两周写补全、报错仍是 invalid argument。
2. 补全:静态脚本与动态生成
# 静态:候选写死在脚本里,随工具发布
_mycli() {
local cur=${COMP_WORDS[COMP_CWORD]}
case "${COMP_WORDS[1]}" in
deploy) COMPREPLY=($(compgen -W "--env --dry-run --tag" -- "$cur")) ;;
*) COMPREPLY=($(compgen -W "deploy rollback status" -- "$cur")) ;;
esac
}
complete -F _mycli mycli
# 动态:工具自己吐脚本,候选来自运行期状态
$ mycli completion bash > /etc/bash_completion.d/mycli # 安装时
$ mycli __complete deploy -- # 每次 Tab
_dag_complete:4: command not found: mycli # 典型故障
| 维度 | 静态脚本 | 动态生成 |
|---|---|---|
| 候选来源 | 手写 | 根据上下文计算(分支名、集群名、镜像 tag) |
| 与代码一致性 | 必然漂移,加参数时没人回头改 | 天然一致,同一份定义 |
| 首次延迟 | 毫秒级 | 每次 Tab 起子进程,冷启动可达数百毫秒 |
| 失效方式 | 静默给错候选,用户以为参数不存在 | 子进程失败则整段补全消失 |
动态补全的两笔隐藏账单:每条补全路径都要处理无网络、无凭据,否则离线时退化成全量回退;生成脚本要打版本戳,否则出现"补全里有这个子命令,执行却报 unknown command"——比没有补全更伤信任。
更省的选择是只提供机器可读帮助(--help-json),把补全交给通用框架生成。
3. 帮助文本的四个层次
mycli deploy --app <NAME> [--env ENV] [--dry-run] # 第 1 层:子命令列表里的一行摘要
Usage: # 第 2 层:用法与参数
mycli deploy --app NAME [--env ENV] [--tag TAG] [--dry-run]
Examples: # 第 3 层:示例
mycli deploy --app api --env staging
mycli deploy --app api --tag v1.2.3 --dry-run
Flags:
--app required 应用名,取自 mycli apps
--env default: staging 可选 staging|prod
| 层次 | 内容 | 字数预算 | 缺失后果 |
|---|---|---|---|
| 一行摘要 | 这个子命令做什么 | ≤ 60 字 | 只能靠子命令名猜 |
| 用法 | 参数形状、互斥 | 2–4 行 | 靠试错 |
| 示例 | 常见组合的完整命令 | 2–4 条 | 转去问人 |
| 进阶 | 默认值来源、边界条件 | 折叠到 --help --all |
卡住后翻源码 |
示例比参数表有效,原因不是"更友好",而是信息形态不同:参数表描述取值域,用户脑子里装的是任务。"把一个 tag 部署到 staging"和 --env 的枚举之间隔着一层翻译,示例把这层翻译预先做掉了。参数表能机械生成,示例不能——所以示例才是帮助里唯一含判断力的部分。
硬规则:--help 必须一屏可见,超出的折叠。长度不是问题,"翻两页才看到示例"才是。
4. 错误提示的三级
# 一级:等于没说
error: invalid argument
# 二级:说清怎么改
error: unknown flag "--dryrun"
did you mean "--dry-run"? run: mycli deploy --help
# 三级:给出可直接复制的命令
error: --env "stg" is not valid; valid values: staging, prod
retry: mycli deploy --app api --env staging
| 级别 | 输出 | 用户下一步 | 实现成本 |
|---|---|---|---|
| 一级 | 参数名 | 去翻 --help |
一行 printf |
| 二级 | 最近邻建议(编辑距离) | 复制建议 | 一个相似度函数 |
| 三级 | 已回填参数的完整命令 | 复制即执行 | 需要上下文里其余参数 |
第三级值得做的理由很实在:用户已经完整输入过一次,那些值都在内存里,回填是零额外信息量的操作,却是失败瞬间的全部。两条约束:错误提示走 stderr,不污染管道;非交互环境下关掉颜色和边框,否则 CI 日志全是乱码。
5. 参数的可猜性
| 设计点 | 可猜的做法 | 不可猜的做法 |
|---|---|---|
| 命名 | --environment + -e |
--en、--envname |
| 必需项 | 缺失在解析阶段就报错 | 运行到一半才报缺 |
| 默认值 | --help 里标 default: |
埋在代码里,环境间行为莫名不同 |
| 布尔开关 | --dry-run / --no-verify 成对 |
无法在命令行覆盖配置文件里的开关 |
| 取值来源 | 枚举列出,且可用命令查 | 要求用户去文档抄名字 |
6. 双模:人和脚本共用一套接口
| 面向 | 特征 | 设计要求 | 典型错误 |
|---|---|---|---|
| 人 | 有 TTY、会中断、看进度 | 进度、颜色、确认、分页 | 脚本模式下也输出进度条 |
| 脚本 | 无 TTY、set -e、解析 stdout |
稳定输出、退出码语义、--json |
人类提示混进 stdout |
# 行为分流:只在 TTY 上交互
[ -t 0 ] || { dry_run=1; assume_yes=0; }
# 输出分流:stdout 只放数据,诊断信息走 stderr
$ mycli status --json | jq -r '.services[].name'
$ mycli status # 人读的表
退出码是脚本模式下最重要的"错误提示":0 成功、1 业务失败、2 用法错误、>2 留给具体语义。if mycli ...; then 的可靠性全押在这套约定上。
7. 破坏性操作:确认与 --dry-run
$ mycli rollback --app api --to v1.1.0 --dry-run
would rollback: api
current: v1.2.3
target : v1.1.0
effect : 2 pods replaced, no data migration
- 确认要求输入具体资源名(
type "api" to confirm),[y/N]挡不住误操作; - 无 TTY 时默认拒绝破坏性操作,除非显式
--yes; --dry-run的输出应是"将要执行的动作列表",不是DRY RUN OK这种空话——后者无法验证参数解析是否正确。
8. 子命令上限与弃用提示
| 子命令数 | 用户行为 | 需要的组织手段 |
|---|---|---|
| ≤ 7 | 能记住,直接敲 | 列表足够 |
| 8–15 | 记大致名字,靠补全 | 分组 + 别名 |
| 16–30 | 只记常用 5 个 | 按任务分组,而非字母序 |
| > 30 | 放弃,退回旧办法 | 拆工具,或改为配置驱动 |
弃用提示只有两个位置有效:执行时打到 stderr,以及 --help 里标注。写在 CHANGELOG 等于没写。
$ mycli deploy --app api --env stg
warning: "--env stg" is deprecated and will fail after 2.0
use: --env staging # 不给替代写法,用户只会去搜"为什么 warning"
9. 唯一来源:文档与 --help 不漂移
帮助腐坏的方式很一致:参数在代码里,文案在另一处,示例在 README 里,三份各自演化,半年后没一份对。
# 与参数解析共用一份定义;help 与补全脚本都从它生成
name: deploy
args:
- {name: app, required: true, help: "应用名,取自 mycli apps"}
- {name: env, default: staging, enum: [staging, prod]}
- {name: dry-run, type: bool, help: "只打印动作,不调用后端"}
examples: ["mycli deploy --app api --env staging"]
判据:改动一个参数时需要修改的文件数。理想是 1,等于 2 还能靠自律,3 以上漂移只是时间问题。
10. 排错
| 现象 | 真实原因 | 处理 |
|---|---|---|
| 补全没反应 | 脚本未 source,或函数名与命令名不匹配 | complete -p mycli 查注册 |
| 有候选却报 unknown command | 补全脚本版本落后 | 生成脚本带版本戳,升级时重生成 |
| 用户仍去问人 | 帮助里只有参数表,没有任务级示例 | 补 2–4 条完整命令 |
| 报错后重复同样的命令 | 提示没给修正路径 | 输出合法枚举 + 回填命令 |
| 脚本解析不了输出 | 人类提示混进 stdout,颜色未按 TTY 关闭 | stdout 只放数据;[ -t 1 ] 判色 |
| CI 里命令挂住 | 交互确认在无 TTY 下等待输入 | 非 TTY 默认非交互 |
11. 边界:什么情况不必设计可发现性
| 场景 | 判断 | 理由 |
|---|---|---|
| 一次性脚本 | 不设计 | 生命周期 < 一周,读者是作者 |
| 单团队内部工具、< 5 人用 | 只做错误提示 | 口头同步便宜过维护补全 |
| 已有成熟替代品 | 不做 | 用户的肌肉记忆在别处 |
| 只被程序调用 | 改做库/API | 可发现性的受众是人 |
| 极低频(一季度一次) | 只做示例层 | 补全成本摊到每次使用太高 |
一旦工具跨过"作者以外的第三个人"和"半年以上"两条线,投入就开始回本——省下的是每次被问"这个参数怎么填"的打断。
12. 度量
| 指标 | 采集方式 | 读法 |
|---|---|---|
--help 调用率 |
参数解析层埋点 | 过高 = 语法不可猜;为零 = 用户直接放弃 |
| 错误后重试成功率 | 同 session 内失败→成功 | 低于一半说明没给出修正路径 |
| 帮助后立即执行率 | --help 后是否紧跟成功执行 |
偏低说明示例不可直接复制 |
| 退回旧办法的比例 | 命令调用分布 | 唯一真正重要的指标 |
13. 自测
- 三个入口里哪个产出比最高?为什么不是补全?
- 静态补全的失效方式为什么比动态补全更危险?
- 为什么示例不能被机械生成,而参数表可以?
- 三级错误提示需要哪些信息才能做到?这些信息从哪来?
- 工具同时面向人和脚本,stdout 与 stderr 各承担什么?
- 什么信号说明该停止增加子命令,而不是继续加帮助文本?