我的CLI最常见的用户并不是我自己
lql 是一个用 Rust 开发的CLI工具,用于管理Linear的问题任务。我开发这个工具是因为现有的替代方案并不能满足我的实际使用需求:让一个自主的AI代理来管理问题任务。
为什么我必须开发自己的CLI
Linear的MCP服务是我的第一个尝试。这个想法很优雅:搭建一个MCP服务器,从而直接向代理公开Linear的API。但是在实际使用中,它的运行速度很慢且非常不稳定,每次调用时,代理都需要从头开始构建GraphQL查询。这增加了在每次调用中引发不存在字段的机会。用了一两周后,我把这个方案卸载了。
社区版CLI(schpet 的 linear)是我的第二个尝试。这个工具主要是为人类用户设计的,带有交互式菜单、箭头选择以及确认提示。但问题是,一个代理不能操作交互式菜单。也不适合。
Linear 的“智能代理”功能。 2026年3月,Linear 发布了一个集成了 AI 的智能代理。乍一看听起来很理想,直到你发现其局限性:它仅能在Linear的Web界面内使用,没有终端调用功能,没有API,也无法与外部工具集成。它就是一个嵌在自己用户界面中的聊天机器人。如果你的工作流包括“编程的代理也需要管理问题”,Linear 的代理功能无法满足需求。
基于数据的设计:初步分析
在开发 lql 之前,我解析了Claude Code与Linear交互时165次会话中的每一个错误。结果表明,共记录了500多个错误,370次重试,并估算出每月约有700,000个token被浪费。--sort忘记使用40次;写成了--state "Todo" 而非 --state unstarted共12次;--no-interactive未添加导致CLI挂起,等待键盘输入64次。(全套数据细节见原始文章。)
基于这些数据,我设计了 lql 的用户界面。我并没有猜测一个代理需要什么,而是使用测量得到的数据。这形成了一些基础设计决策:输出格式为精简化版本TOON(每个问题约25个token,而不是繁多的JSON格式),为LLM容易混淆的选项添加别名(例如--status → --state),对值进行标准化(比如Todo 转化为 unstarted,urgent 转化为 1),并提供建议正确命令的错误提示信息,而不是简单地显示“未知选项”。
当时我并不清楚,实际上自己是在应用Postel法则。这是后来才意识到的。
第二次分析:lql在生产环境中的一个月
lql上线已经一个月。Claude Code每天会调用它30到50次,用于创建问题、更新状态、链接依赖、查询细节等。我自己从来不手动使用它,因为对我而言,手动管理问题不是兴趣所在。我有代理来解决这些事情。如果哪天我必须亲自执行lql,那说明出了严重的问题。
lql唯一的用户是一个LLM。这让整个设计变得完全不同。
所以我再次解析了Claude Code调用lql的会话日志,搜寻错误。
| 指标 | 数值 |
|---|---|
| 分析的会话数 | 200 |
lql 的调用次数 | 1,324 |
错误数 (is_error: true) | 210 |
| 错误率 | 15.9% |
15.9%的错误率未包括因为并行调用失败且被Claude取消的调用。这里只计算CLI实际发生的错误。
错误分类
并非所有错误都是一样的。有些表明缺乏约定,而有些则表明需要添加操作。
| 错误 | 频率 | 实例 |
|---|---|---|
| 未找到Label | 20 | --label tokamak (在该团队中不存在) |
--title 用作 create 的选项 | 8 | lql create --title "Epic: ..." --team PROD |
将 show/get 写成 view | 6 | lql show PROD-911 |
relate 参数顺序错误 | 12 | lql relate PROD-834 PROD-833 blocked-by |
尝试 update --team (移动问题任务) | 超过15次 | lql update PRIV-32 --team PROD |
relates 写成 related | 2 | lql relate PROD-912 relates PROD-910 |
--body 用于 comment | 1 | lql comment PROD-926 --body "文本" |
--comments 用于 view | 1 | lql view PROD-824 --comments |
其余是Linear的API错误、1Password认证问题、或shell错误(长heredocs中的引号破裂)。
错误揭示了什么
LLM 不阅读 --help,而是依赖语义直觉
当开发者不知道命令如何运作时,他们会执行 lql view --help。而Claude不知道时,会靠猜测。它84%的时间猜对了,但16%的错误率揭示了它的偏见。
例如,lql show 比 lql view 更直观。大多数工具使用 show:如kubectl get、docker inspect、git show。Claude不会参考lql的文档来选择动词,而是会使用适合它所见过的成千上万的CLI工具中的自然选择。
解决方案不是更好地文档化,而是接受这个同义词:
#[command(alias = "show", alias = "get")]
View(ViewOpts),
一行代码,避免了六次错误。
代理更偏好命名参数而非位置参数
在 lql create 中,标题是一个位置参数:
lql create "我的标题" --team PROD
Claude在8次调用中写成了:
lql create --title "我的标题" --team PROD
--title 并不存在作为一个命名选项。对于人类用户来说,这显而易见——你读取 --help 会看到 <TITLE> 是一个位置参数。而对LLM来说,命名参数更安全,因为它们不依赖位置。
修正方法:两者都接受。
pub struct CreateOpts {
pub title: Option<String>,
#[arg(long = "title", hide = true)]
pub title_flag: Option<String>,
// ...
}
--title 在 --help 中被隐藏(人类用户不需要),但它仍然有效。
如果可以检测到错误,与其拒绝不如直接修复
最显著的案例:lql relate 期望三个严格顺序的参数:
lql relate <FROM> <RELATION_TYPE> <TO>
Claude写了12次:
lql relate PROD-834 PROD-833 blocked-by
对于LLM来说,这种自然顺序是 FROM TO TYPE —— “将这个与那个关联,用这种方式”。但CLI的顺序是 FROM TYPE TO —— “从这个出发,关系类型,到那个”。
POSIX哲学是:拒绝不正确的输入,返回描述错误的提示。而Agentic体验哲学是:如果你可以检测到第二个参数是问题ID,第三个是关系类型,那么自动重新排序。
pub fn normalize_args(args: &[String]) -> Option<Vec<String>> {
if args.len() < 5 { return None; }
if args[1] == "relate"
&& looks_like_issue_id(&args[2])
&& looks_like_issue_id(&args[3])
&& !looks_like_issue_id(&args[4])
{
let mut fixed = args.to_vec();
fixed.swap(3, 4);
eprintln!(
"ℹ Reordered: relate {} {} {} → relate {} {} {}",
args[2], args[3], args[4], fixed[2], fixed[3], fixed[4]
);
return Some(fixed);
}
None
}
检测是确定的:问题ID具有格式 TEAM-123(大写字母、短横线、数字)。关系类型则没有这种格式,因此不会引发任何模糊性。
会向标准错误流输出一条信息(ℹ Reordered: ...)来记录纠正过程。如果某次启发式方法失败,用户可以追踪到底发生了什么。
如果某个操作被反复尝试,它应当存在
代理在多次会话中尝试了超过15次 lql update PRIV-32 --team PROD。在Linear中,将一个问题任务从一个团队移动到另一个团队是一个合法操作,而 lql 并未实现它。
这不是一个接口上的错误,而是一个功能缺失。数据让这个问题变得清晰。
在 update 中添加 --team 只需在 clap 的解析器中添加3行代码,并在更新逻辑中额外调用一次 meta.find_team()。Linear 的API早已支持在 issueUpdate 变更中使用 teamId。
宽容无法完全解决的问题
需要诚实面对局限性。在这210次错误中:
20次是标签不存在。 Claude会虚构如
tokamak或improvement这样的标签,而这些标签并不存在于Linear的该团队中。这无法用别名解决,而需要代理在创建之前查询可用标签。lql已经返回了模糊匹配的建议(如“Closest: …”),但Claude并不总是重试。18次是API错误(涉及标签的错误团队、
raw命令中的无效GraphQL查询)。这些都是代理的错误,而非CLI的问题。7次是认证问题(1Password掉线或会话过期)。这属于基础设施问题,而非界面问题。
界面宽容性或许能覆盖约60%的错误。剩下的部分需要代理更高的纪律性,或者工具在向API发送请求之前进行更多验证。
Postel法则与CLI的应用
Jon Postel 在1980年写道:“Be conservative in what you send, be liberal in what you accept” (RFC 761)。这是TCP的鲁棒性原则。任何能够正常工作的互联网协议都应用了这一原则。
但几乎没有人会将它应用到CLI工具上。POSIX的正统理念恰恰相反:拒绝任何与规范不完全匹配的输入,返回清晰的错误提示,并让用户自行纠正。当用户是能读懂错误的开发者时,这是有效的。但当用户是一个只会通过随机变换重试的LLM时,这只是在浪费时间和token。
Agentic Experience 就是将Postel法则应用到CLI工具的参数上。这不是一个新想法,而是1980年的原则,但从未被应用到这个上下文。
从数据中得出以下五条具体规则:
接受自然的同义词。 如果某个动词存在于流行的CLI工具中(如
show、get、display),接受它作为别名。成本几乎为零,但可以消除许多词汇错误。接受命名的选项(flags)以及位置参数。 LLM更喜欢
--title "X"而不是将"X"放在正确的位置。如果不想让人类用户混淆,可以在--help中隐藏这些选项。优先重排而非拒绝。 如果可以区分参数的类型(如问题ID与枚举字符串),可以识别并自动纠正错误顺序。
规范化相近的变体。 如
relates→related,blockedby→blocked-by。编辑距离很小,接受这些变体几乎没有成本,而拒绝它却会导致错误和重试。如果某个操作被尝试超过3次,它可能应当存在。 会话日志是关于功能缺失问题的资源宝库。代理不会无意义地尝试某个操作15次。如果反复出现,说明该操作是有意义的,而工具未对其提供支持。
超越代码的角度
我用Claude来解析自己的会话日志,分类Claude在使用我工具时出错的地方,并据此实施改进。工具借助数据适应了它最常见的用户。
所有代码都是公开的。包含这些修改的提交是34f1f08。数据可以通过解析存放在~/.claude/projects/中的JSONL文件进行重现。
如何实现
解析日志。 Claude Code的JSONL文件位于
~/.claude/projects/<project>/。每个带有is_error: true的tool_result都是有价值的数据。这个格式适用于所有工具,不仅限于CLI。分类而后修正。 并非所有错误都是一样的。将界面错误(CLI拒绝有效输入)与逻辑错误(代理请求了无意义的操作)区分开来。只有前者可以通过宽容性修复。
后期测量。 在更改之前的错误率为15.9%。下次分析时,我将知道是否有所降低。如果没有初始测量,就没有基准。
lql 可以通过 brew install frr149/tools/lql 安装。代码仓库在 github.com/frr149/lql。
本文原文为西班牙语,借助AI翻译。