`<app>_cli.py` 的入口模式与命令注册方式
如果你只能读一个 harness 的一个文件,读 <app>_cli.py。理由很直接:我们采集时(2026-08-10,对应仓库快照 39634a6)按结构项数了一遍 69 个 agent-harness 目录,core/ 只有 64 个有、utils/ 66 个、__main__.py 58 个,而入口文件是这套结构里同构度最高的一层:agent-harness 内匹配 *_cli.py 的文件我们数到 69 个,其中位于 cli_anything/<app>/ 下的入口文件是 68 个;缺件清单里仍有一个例外——sketch 一个 *_cli.py 都没有。命令树长什么样、状态怎么存、出错怎么退,全在这一个文件里能看到轮廓。
本篇只讲入口和注册这一层。目录树整体怎么排、core/ 与 utils/ 各管什么、backend 怎么去动宿主软件,各有专门的篇目在说,这里不重复。
口径出奇地统一:Click,且没有 argparse
先给一个统计结果。按 */agent-harness/cli_anything/*/*_cli.py 这条路径数,入口文件是 68 个,其中:
| 特征 | 命中数(分母 68) | 检查命令 |
|---|---|---|
用 @click.group | 67 | grep -l '@click.group' */agent-harness/cli_anything/*/*_cli.py | wc -l |
import argparse | 0 | 同上换关键词 |
带 invoke_without_command=True | 64 | 同上 |
带字面量 "--json" 选项 | 65 | 同上 |
定义了顶层 def main | 58 | grep -l '^def main' ... |
67 比 0,这在一个由几十个软件各自套壳堆起来的仓库里算是相当高的一致度。这不是巧合:cli-anything-plugin/HARNESS.md 第 533 行把 REPL 写成了硬要求,原文是 REPL MUST be the default behavior(invoke_without_command=True),第 530 行同样用 MUST 要求每条命令支持 --json。规范直接把 Click 的两个具体参数写进了条文里。
这里插一句本组通用的提醒:结构齐不等于能用。注册表 registry.json 里我们采集时数到 79 条记录,那是条目数,不是「这 79 个软件你装上就能驱动」的保证。这些 harness 最终要在你本机起子进程去执行第三方软件与脚本,宿主软件还得你自己装。入口文件写得再齐整,这一层也绕不过去。
反直觉的那一处:不带子命令时它不打 help
绝大多数命令行工具你光敲一个命令名回车,会得到一段 usage 帮助。这一套不是。
以 blender 这个样本为例,根组的声明是 @click.group(invoke_without_command=True) 加 def cli(...)(blender/agent-harness/cli_anything/blender/blender_cli.py 第 191 和 198 行),函数体末尾这两行决定了默认行为(第 212 到 213 行):
if ctx.invoked_subcommand is None:
ctx.invoke(repl, project_path=None)
没有子命令,就直接进交互式 REPL。带 invoke_without_command=True 的 *_cli.py,我们采集时数到 64 个;blender 这个样本里,这个参数对应的就是上面 212 到 213 行那两行代码。
真正反直觉的是第二层:REPL 并没有另写一套命令解析器。你在 REPL 里敲的每一行,会被 shlex.split 切成参数列表,然后原样喂回同一棵 Click 命令树(同文件 1135 到 1141 行):
args = shlex.split(line)
cli.main(args, standalone_mode=False)
standalone_mode=False 是这里的关键。Click 在默认的 standalone 模式下,命令跑完会自己抛 SystemExit 把进程结束掉——放在 REPL 里就是敲一条命令整个会话就没了。关掉这个模式,再在外面把 SystemExit 吞掉,一棵命令树就同时具备了「一次性命令」和「交互会话」两种形态。
这个做法的直接好处是:新增一个子命令,一次性用法和 REPL 用法同时到位,不用两边各写一遍。代价在下一节。
代价:同一个错误,两种模式下退出行为不一样
命令树复用了,但「出错之后怎么办」这件事在两种形态下必须不同——REPL 里不能真的 sys.exit。这套 harness 的处理办法是一个 handle_error 装饰器(blender/.../blender_cli.py 160 到 186 行),它捕获三组异常:
| 捕获的异常 | --json 模式下的 type 字段 |
|---|---|
FileNotFoundError | file_not_found |
ValueError / IndexError / RuntimeError | 异常类名本身 |
FileExistsError | file_exists |
输出走向也分两路:--json 模式下把 {"error": ..., "type": ...} 打到 stdout,否则把 Error: ... 打到 stderr。而最后一步是非 REPL 模式才 sys.exit(1),REPL 里不退出。
这意味着一件对写调用方的人很要紧的事:面向 agent 的退出码约定(SKILL 模板里那句 Check return codes - 0 for success, non-zero for errors,cli-anything-plugin/templates/SKILL.md.template 第 110 行)只在一次性命令这条路上成立。你在 REPL 会话里跑同一条命令、撞上同一个异常,拿到的是打印出来的错误文本,而不是一个非零退出码。要靠退出码做判断的自动化流程,得走一次性命令那条路。
顺带说清覆盖面:定义了 def handle_error 的 *_cli.py 我们采集时数到 32 个,也就是约一半的 harness 走这套,剩下的各自处理。样本里 browser 就另有分支——它在根组里先跑 backend.is_available(),不可用时 JSON 模式输出 {"error": msg, "type": "dependency_error"},人类模式打错误加上扩展安装地址,然后 sys.exit(1);--help 和 --version 跳过这个检查(browser/.../browser_cli.py 118 到 132 行)。这个「先探依赖再干活」的分支值得单独记一下,因为它对应的正是宿主没装好的场景。
同一个 _repl_mode 开关还管着自动保存
顺着上一节的模式区分再往下看一层,会发现自动保存挂的是同一个开关。
blender 的入口文件里有一个 @cli.result_callback() 装饰的 auto_save_on_exit(216 到 229 行),它在命令跑完之后触发,需要同时满足三个条件才落盘:不是 REPL 模式、不是 --dry-run、并且会话对象 sess._modified 为真且有 project_path。保存失败只打一条 Warning,不中断。
把这两处摆在一起看,<app>_cli.py 里的模式区分其实是一条贯穿始终的主线:REPL 与一次性命令共用命令树,但在错误退出、状态落盘这两件有副作用的事情上,走的是完全不同的分支。你要改这类 harness 的行为,先确认自己改的是哪一侧。至于会话本身怎么做快照、undo 上限是多少,那是 session.py 的话题,不在本篇。
命令注册:两级装饰器,一层不多一层不少
注册方式是 Click 的标准两级结构,blender 这个样本可以逐行对上:
- 根组:
@click.group(invoke_without_command=True)+def cli(...)(191、198 行) - 子组:
@cli.group(),例如scene(232 行)、object_group(310 行)、modifier_group(513 行) - 叶命令:
@<group>.command("name"),例如scene new(238 行)、object add(316 行)
blender 这一个文件里我们数到 10 个 @cli.group、54 处 .command( 装饰。顺带记一处可以自己核的差异:代码里数到的是 10 个 @cli.group,而这个 harness 的 cli_anything/blender/README.md 在「Command Groups」一节下列的是九个三级小节,两处不一致,以实读仓库为准。想知道某个 harness 到底提供了哪些命令,grep -c '@cli.group' 和 grep -c '.command(' 两条就能给你一个量级。
值得注意的是命令组名和 Python 函数名不总是一致:上面 object_group、modifier_group 这两处的函数名带了 _group 后缀,而实际命令名以装饰器括号里的字符串为准。你按命令名去代码里搜函数名,可能会搜空。
从函数到可执行命令:三条路各自的位置
一个 harness 装好之后能怎么被调起来,涉及三个文件,职责分得很清楚:
第一条,main() 函数。 58 个 *_cli.py 定义了顶层 def main,blender 那个的函数体只有一行 cli()(1152 到 1153 行)。它存在的意义不是逻辑,是给下面两条路提供一个统一的入口符号。
第二条,__main__.py。 我们看的几个样本(blender、audacity、browser)里它都是三行:一句 docstring、一行 from cli_anything.<app>.<app>_cli import main、一行 main()。它撑起的是 python3 -m cli_anything.<app> 这条不用装 console script 也能跑的路径。这个文件 69 个 harness 里有 58 个。
第三条,setup.py 的 console_scripts。 声明形如:
"cli-anything-blender=cli_anything.blender.blender_cli:main"
在 blender/agent-harness/setup.py 58 到 60 行。全仓这种同形声明我们数到 68 处(grep -h 'cli-anything-' */agent-harness/setup.py | grep '=cli_anything' | wc -l)。这 68 处声明用的都是 cli-anything-<software> 这一种前缀形态;至于同一个环境里装多个 harness 实际会怎样共存,取决于你的环境,我们没有装过,不下结论。
三条路最终都指向同一个 main 符号,但覆盖面并不齐:def main 58 个、__main__.py 58 个(分母都是 69 个 harness)、console_scripts 同形声明 68 处,三者本身就对不齐。
文档里那条运行命令是失效的
这是本篇最该提醒的一处坑,因为它直接决定你能不能把 harness 跑起来。
blender 的 README 在 Quick Start 一节(20 到 46 行)用的运行命令全是这种形式:
python3 -m cli.blender_cli ...
而我们采集时执行 ls -d */agent-harness/cli | wc -l 得到的结果是 0:仓库里不存在任何 agent-harness/cli 包目录。实际由 __main__.py 提供的可用路径是 python3 -m cli_anything.blender。同样的过期写法还留在 __main__.py 自己的 docstring 里("""Allow running as python3 -m cli.blender_cli"""),全仓仍在用 python3 -m cli. 这种写法的 md 文件我们数到 12 个(grep -rl 'python3 -m cli\.' */agent-harness --include='*.md' | wc -l)。
文档写的是一条路径、仓库里的包目录是另一条,这是两个可以各自核对的事实,我们只陈述到这里。你照文档敲不通的时候,先按 python3 -m cli_anything.<app> 试,或者直接看该 harness 的 setup.py 里 console_scripts 声明的命令名。
规范用了 MUST,实际有几处对不上
前面提过 HARNESS.md 用 MUST 写了两条入口层的硬要求,我们采集时的实况是:
--json(第 530 行,Every command MUST support--json):dify-workflow、intelwatch、slay_the_spire_ii三个*_cli.py里搜不到字面量"--json"。其中slay_the_spire_ii根组的选项只有--base-url和--timeout(该文件 25 到 26 行),intelwatch_cli.py里则一个click.option都没有。invoke_without_command(第 533 行,REPL MUST be the default behavior):intelwatch、jumpserver、live2d、wiremock四个没写。
规范写的是 MUST,实际有 3 处和 4 处不符合,两边都能自己核对。这里只陈述差异。
还有一种规范之外的入口形态
intelwatch 在上面两条里都出现了,因为它根本不是同一种入口。intelwatch_cli.py 全文只有 32 行,用的是 @click.command(ignore_unknown_options=True, allow_extra_args=True),然后把所有参数原样拼成 ["npx", "intelwatch"] + ctx.args 交给 subprocess.call,最后 sys.exit() 透传返回码(该文件 6 到 22 行)。
也就是说它是一层薄转发,不解析任何参数、不建命令树,真正干活的是本机上的另一个命令。这种形态规范里没写,但它在这套「让软件 agent-native」的思路里是自洽的:如果宿主本来就有一个好用的命令行,harness 的活就只剩把调用透传过去。
从使用者角度,这一层要清醒地看待:这类入口会在你本机拉起外部程序(这里是 npx),执行什么完全取决于你传进去的参数和那个外部命令本身。这不是 CLI-Anything 特有的风险,但它把「有一个 CLI 可以调」和「在本机执行外部程序」这两件事捆在了一起,装之前值得先看一眼这个文件——32 行,一分钟就看完了。
依赖声明:口径一致,版本串不一致
最后补一个装环境时会碰到的细节。我们采集时(2026-08-10,快照 39634a6),这些 harness 的 setup.py 在依赖口径上高度一致(都是 click + prompt-toolkit 这条线),但版本约束字符串没统一:
| 声明 | 出现次数 |
|---|---|
python_requires=">=3.10" | 62 |
click>=8.0.0 | 47 |
click>=8.0 | 11 |
click>=8.1 | 3 |
click>=8.1,<9.0 | 3 |
prompt-toolkit>=3.0.0 | 43 |
prompt-toolkit>=3.0 | 8 |
prompt-toolkit>=3.0,<4.0 | 2 |
python_requires 另有 >=3.10,<3.13、>=3.11、>=3.8、>=3.9 各 1 处。统计命令都是 grep -h ... */agent-harness/setup.py | sort | uniq -c。语义上这些约束大多等价,但同环境里装多个 harness 时,解析器面对的是这些不同写法的并集,实际会解出什么版本取决于你的环境,我们没有装过,给不出结论。
还有一个该看的字段:同样是我们采集时(2026-08-10,快照 39634a6),打包文件里 Development Status :: 4 - Beta 45 处、3 - Alpha 2 处,全仓没有一个标 5 - Production/Stable。这是项目自己给的成熟度口径。
你可以自己跑一遍的核对动作
上面的数都是从文本里数出来的,clone 下来就能复现:
grep -l '@click.group' */agent-harness/cli_anything/*/*_cli.py | wc -l
grep -L 'invoke_without_command' */agent-harness/cli_anything/*/*_cli.py
grep -l 'def handle_error' */agent-harness/cli_anything/*/*_cli.py | wc -l
ls -d */agent-harness/cli | wc -l
以上为按仓库中的文件布局组合的检查命令,未经实测,以仓库实际内容为准。我们采集时这四条依次得到 67、上文那四个 harness 名、32,以及 0。
要认一个陌生 harness 的入口,顺序建议是:先 grep '@click.group' 确认它是不是标准形态(不是的话大概率是薄转发那一类,直接读完整个文件),再 grep '@cli.group' 数命令组,最后看 setup.py 里 console_scripts 声明的命令名——那个名字才是你在终端里真正要敲的东西,README 里的运行命令未必对得上。
本文依据 CLI-Anything 官方仓库(github.com/HKUDS/CLI-Anything)的 README、registry.json、
docs/ 与各 agent-harness/ 下的源码整理,核对日 2026-08-10,对应仓库快照 39634a6。
本文内容为仓库源码与文档口径,我们没有安装或运行过其中任何一个 harness,
也没有在本机驱动过任何一款宿主软件,因此不涉及实际操控效果的任何描述。
这类 harness 会在本机执行外部程序与脚本,是否使用请结合自身环境评估。
注册表与 harness 内容随上游更新而变动,请以仓库最新内容为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。