你的命令行工具,藏不住你的手艺
彼节者有间,而刀刃者无厚;以无厚入有间,恢恢乎其于游刃必有余地矣。 —— 庄子《庖丁解牛》
你的命令行工具,藏不住你的手艺
从一个 Panel(dict) 崩溃说起:为什么克制,才是命令行工具最高级的礼貌
你大概率见过这样一段崩溃日志:
Errors found Unable to render {'topic': ..., 'titles': [...]};
A str, Segment or object with __rich_console__ method is required
背后的代码通常长这样,改起来只要三十秒:
# 崩溃版本
console.print(Panel(data, title=f"Title for {selected_file}"))
# data 是一个 dict:{'topic': ..., 'titles': [...]}
大多数人会这样收尾:套一层 try/except,把异常吞掉,或者干脆 str(data) 糊一下,工具能跑就行——毕竟这只是”内部脚本”,又不是要卖钱的产品。
但这恰恰是这篇文章想戳破的假设:一个内部工具的界面质量,是你专业水准唯一不需要客户开口就能验证的证据。你可以在合同里写”资深工程师”“十年经验”,但当对方看到你随手写的一个 CLI 工具时——是裸露的 dict 报错,还是一个清楚知道”什么时候该问、什么时候不该问”的交互——判断已经悄悄发生了。
这篇文章按”从崩溃到克制”的教学顺序展开,不是我踩坑的时间顺序。读完你会带走三样东西:
rich的渲染契约到底是什么,为什么”打印”和”渲染”是两件事;InquirerPy里一个几乎没人提到的样式技巧:style_override=False;- 一个判断”这里到底要不要加一次确认”的具体标准,而不是”多加总没错”的直觉。
一、渲染契约:Panel 不是不会打印 dict,是它压根没打算猜
rich.Panel(以及大多数 rich 的容器组件)只接受三种输入:字符串、Segment,或者实现了 __rich_console__ 的对象。一个原始 dict 三样都不是,所以它拒绝渲染,而不是”尽力猜一下你想要什么”。
| 普通人的看法 | 资深工程师的洞察 |
|---|---|
“console.print 应该什么都能打印,这是库设计得不够宽容” |
“渲染层只对『渲染契约』负责,不对『你传了什么』负责——宽容地猜测,才是真正危险的设计” |
“加个 try/except 把崩溃兜住就行” |
“except 只解决『程序不崩』,解决不了『用户看懂发生了什么』;崩溃往往是工具唯一一次主动告诉你『这份数据我还没有真的看过』的机会” |
修复本身很小——从 dict 里把真正要展示的字段(这里是 titles 这个 list)取出来,转成字符串:
titles = data.get("titles", []) if isinstance(data, dict) else data
titles_text = "\n".join(f"{i+1}. {t}" for i, t in enumerate(titles))
console.print(Panel(titles_text, title=f"Title for {selected_file}"))
崩溃不是工具的失败,是工具在提醒你:你从来没有真的”渲染”过这份数据,只是在”打印”它。
二、从”打印”升级成”界面”:一张会说话的 Table
修完崩溃之后,真正的问题才浮现:五个候选标题挤在一段纯文本里,用户要靠肉眼数编号才能选。升级成一张分色的表格,成本不到十行:
TITLE_PALETTE = ["cyan", "magenta", "green", "yellow", "bright_blue"]
table = Table(
title="✨ Generated Title Options ✨",
box=box.ROUNDED,
show_lines=True,
title_style="bold white on dark_magenta",
)
table.add_column("#", style="bold white", justify="center", width=3)
table.add_column("Title", style="bold")
for i, t in enumerate(titles):
color = TITLE_PALETTE[i % len(TITLE_PALETTE)]
table.add_row(f"[{color}]{i + 1}[/{color}]", f"[{color}]{t}[/{color}]")
console.print(table)
颜色在这里不是装饰,是分组信号——每一行一个颜色,眼睛不需要再靠数字对齐就能锁定一整行。这是 rich 真正的卖点:它把”终端只能输出字符”这件事,从约束变成了一个可以设计的画布。
三、get_style(..., style_override=False):借默认值的光,别把灯全拆了
InquirerPy 的样式系统里有个几乎没人在文档示例里强调的参数,踩错了会让你以为自己需要重新发明整套配色:
TITLE_PICKER_STYLE = get_style(
{
"questionmark": "#f5a742 bold",
"pointer": "#f542a7 bold",
"answer": "#42c5f5 bold",
},
style_override=False,
)
style_override=True(大多数示例代码的写法) |
style_override=False(这里用的写法) |
|---|---|
你没写的每一个字段(instruction、checkbox、separator……)全部变成空字符串,样式”清零” |
你没写的字段继续沿用 InquirerPy 精心调过的默认色板(#61afef、#98c379……) |
| 你必须把十几个字段全部手写一遍,否则界面会看起来”缺了一块” | 你只需要覆盖你真正在意的三五个字段,其余的交给库的默认审美 |
这和写 CSS 时”只覆盖你关心的属性,别整份重写”是同一个直觉——但这个参数的名字(style_override)恰好和它的行为反直觉:False 才是”不要粗暴覆盖”的那一档。
四、console.status() 包一次真实的 await:把”不确定”变成”看得见的不确定”
工具在等一次真实的网络调用(这里是等 Gemini 生成候选标题)时,最常见的两种写法都不好:要么界面直接冻住,用户不知道是卡死还是在跑;要么打一行”正在处理中…“就再也不刷新。
Console.status() 当上下文管理器用,能在 await 真正等待的这段时间里跑一个动画:
with console.status("[bold cyan]Asking Gemini for title ideas...[/bold cyan]", spinner="dots"):
result = await mcp_client.call_tool("suggest_N_titles", {...})
细节:status() 单独调用不会显示任何东西——它返回的是一个 Status 对象,只有作为 with 块使用(或手动 .start())才会真正启动那个后台刷新的 spinner。这也是这份代码里原本的一个隐藏 bug:早期版本里 console.status("fetching raw files....") 只是孤零零调用了一次,从没进 with,所以那行状态提示其实从来没有真正显示过。
五、庖丁解牛:确认框只放在真正的”节”上
开头那句庄子的话,字面意思是刀刃很薄,关节之间有空隙,把没有厚度的刀刃送进有空隙的关节,自然游刃有余。这句话经常被简化成”熟能生巧”,但真正值得学的其实是前一句被省略的部分——庖丁的刀十九年不卷刃,不是因为他动作快,是因为他”依乎天理,批大郤,导大窾”,只在骨节的天然缝隙里下刀,从不硬砍。
命令行工具的确认提示(confirm/prompt)就是那把刀。加得太多,用户会对着一堆”你确定吗?”练出肌肉记忆式的连续回车,真正危险的那一次也会被顺手按过去——这和”狼来了”是同一个机制。加得太少,一次误触就把用户的源文件移进了归档目录。诀窍不是”多加一层保险”,是找到真正的”节”:
publish_languages = await inquirer.checkbox(
message="🌐 Publish which language version(s)?",
choices=[Choice("zh", ...), Choice("en", ...)],
).execute_async()
confirmed = await inquirer.confirm(
message=f"Publish {selected_file} as {'/'.join(publish_languages)} with this title?",
default=False, # 关键:不确认的默认值不是"是"
).execute_async()
| 这里选择要确认的动作 | 特征 |
|---|---|
| 发布:真实调用两次 Gemini(花钱)、往另一个仓库写文件、把源笔记移进 archive/ | 不可逆或有真实成本,且用户此前的选择(标题、语言)不会自动暴露这个后果 |
| 语言 checkbox 本身、标题 fuzzy 搜索的每一次按键 | 可逆、零成本、随时能改主意——加确认框只是在制造摩擦 |
default=False 也是一个刻意的选择:这是一个会花钱、会移动文件的动作,默认值不该是”用户按回车就等于同意”。
收束:五个决定,对应五种”用户成本”
| 决定 | 保护的是哪种成本 |
|---|---|
Panel 要传字符串,不是 dict |
可读性成本——用户能不能看懂输出 |
| 分色 Table | 扫描成本——用户要不要靠数数字才能选对 |
style_override=False |
你自己的开发成本——不用重新发明整套配色 |
console.status() |
等待中的确定性成本——用户知不知道工具还活着 |
checkbox + confirm(default=False) |
决策成本 + 不可逆成本——只在真正的”节”上停一停 |
立刻可以做的事
- 翻一下你自己最近写的一个”内部小工具”,搜一下有没有
print(some_dict)或者裸露的except Exception as e: print(e)——这些地方就是”崩溃在替你说话”的地方。 - 检查你所有的
confirm()/input("确定吗?"),数一下有几个。如果超过三个,大概率有一半是在制造摩擦而不是保护用户——试着删掉那些”可逆、零成本”的确认。 - 找一个你重复运行超过十次的脚本,给它加一个
console.status()或等价的进度提示——这十分钟的投入,会在你自己第十一次运行它的时候还清。
一把好的刀十九年不卷刃,不是因为它锋利,是因为它只切开真正有空隙的地方。你的命令行工具也一样——克制,才是最高级的礼貌。