CLI-Anything这个项目,说起来有点一根筋。我的日常工作和命令行绑得太深,每天要重复做的事情里,有一大半是“打开终端,敲一串差不多的命令,处理一份文件、调一个接口、跑一次构建”。这些操作单独看都不复杂,但攒多了就烦:要么是某个脚本散落在某个目录里,几个月后连自己都忘了怎么用;要么是命令参数太长,每次都靠上下翻历史记录;要么是换一台机器,配好的alias全部作废。做CLI-Anything的初衷特别朴素——我想把那些“反复出现但又不太一样”的杂活,统一收进一套声明式的配置里,让每件杂活都变成一条干净、可复现、能分享的命令。
它适合这样的人:日常有大量文件处理、接口调用、构建发布类重复任务;想用命令行但不想每次都手写脚本;或者团队里想让非技术人员也能安全地跑一些“半成品工具”的人。下面我把这个项目从设计到落地,再到被各种实际问题折腾的过程完整记下来,包括踩过的坑。
1. CLI-Anything到底是什么
1.1 从“万物皆文件”到“万物皆命令”
早年间Unix社区有一个口号,叫“万物皆文件”,意思是把设备、进程、数据流统统抽象成文件,然后用统一的文件操作方式去管理。这几年我看到越来越多新项目在用另一个思路做事情:万物皆命令。GitHub上有大量单文件Python工具、Node脚本、Rust CLI,它们做的事情都差不多——把一个重复性的手工操作,封装成一个有名字、有参数、有输出的可执行入口。
CLI-Anything就是这个思路的极端版本。它的核心概念只有三个:配方(recipe)、动作(action)、执行器(executor)。配方是一份YAML文件,描述“这条命令叫什么名字、需要用户输入什么参数、依次执行哪些动作”;动作是配方里的最小步骤,比如“读取一个JSON文件”“发起一次HTTP请求”“在Shell里跑一条命令”;执行器是真正干活的东西,一个执行器负责处理某一类动作。用户使用时的体验是统一的:不管底层是写文件还是调接口,都是输入一行命令,然后传给同一个入口去解析和调度。
一句话概括就是:它不做任何“业务”,只负责把“你想做的事情”翻译成一系列可执行步骤,并且保证这些步骤可以被重复、被分享、被版本管理。
1.2 为什么现有的方案都不够顺手
在做CLI-Anything之前,我其实已经试过好几条路线,最后发现它们都差了口气。
第一种是Shell alias和Shell函数。它的优点是零依赖,缺点是割裂——alias只管固定命令,稍微带点参数判断就得写函数;函数一多,.bashrc变成一团浆糊;换台服务器,还得手动迁移。更麻烦的是,Shell函数很难给非技术背景的人解释清楚“你打开终端跑这个函数就行”。
第二种是Makefile。Makefile适合编译型项目,它的目标和依赖机制天然贴合“先编译A再打包B”的场景。但把它用在日常文件处理和接口调用上,非常拧巴,因为Makefile对“状态”和“交互”的支持几乎为零。我想要的是一个能问用户“你确认部署到生产环境吗?”的工具,Makefile做不到。
第三种是各类任务执行器,比如一些项目里的自定义Runner、NPM Scripts、Python的invoke。这些工具本身都很成熟,但它们都绑定在具体的生态上——Node项目用NPM Scripts顺手,换了Python项目就得重新想一套;而且它们的配置通常是命令模板加上少量约定,真要处理跨步骤的数据传递、分支判断、超时重试,还是得写代码。
CLI-Anything的思路是全部推倒重来,用一种“最笨但最通用”的方式组织:不管任务本身多复杂,最终都被拆成一张动作清单,每个动作是一个结构化的键值对对象,而不是一段自由脚本。这样做的好处是,配置可以脱离任何语言生态独立存在,识别率极高,后续也方便写校验、写日志、甚至做可视化。
| 方案 | 灵活度 | 可分享性 | 跨平台性 | 交互能力 | 适合场景 |
|---|---|---|---|---|---|
| Shell函数 | 高 | 低 | 低 | 中 | 单机上的临时快捷方式 |
| Makefile | 中 | 中 | 低 | 低 | 编译、构建、依赖链任务 |
| 生态内Runner | 中 | 中 | 中 | 中 | 绑定特定语言/框架的项目任务 |
| CLI-Anything | 高 | 高 | 中 | 高 | 跨场景的重复性杂活统一入口 |
2. 整体架构与设计取舍
2.1 配方文件:用YAML描述一切
我选择YAML作为配方文件的格式,而不是JSON、TOML或者自定义DSL。核心原因是YAML的可读性在几种主流格式里是最贴近自然语言的。JSON写起来到处是引号和逗号,不适合人类维护;TOML的结构化程度高,但表达嵌套关系时略显笨重;YAML的缩进和短横线天然适合描述“一组有序的动作”,而且绝大多数开发者都认识它,学习成本几乎为零。
一份最小配方长这样:
name: csv-export description: 把JSON数组导出为CSV文件 args: - name: input type: path required: true help: 输入的JSON文件路径 - name: output type: path required: false default: output.csv help: 输出的CSV文件路径,默认output.csv actions: - id: read-data use: file method: read_json params: file: "{args.input}" - id: write-csv use: file method: write_csv params: data: "{result.read-data}" file: "{args.output}"这里有几个设计上的细节值得解释。
args是命令的入参声明,执行时用户输入的命令长这样:cli-anything run csv-export --input data.json --output result.csv。CLI-Anything的入口程序会在真正执行动作之前,先根据args的定义做参数解析和校验。我的做法是复用Python标准库里的argparse做基础解析,然后自己封装了一层“根据YAML声明自动生成解析器”的逻辑。这样好处很明显:参数的类型、是否必填、默认值全部收敛在配方文件里,代码不需要为每一条命令单独写参数处理。
actions里的每个动作都有一个id、一个use(指定执行器)、一个method(指定执行器上的方法)和一组params。这里最关键的是引用机制:"{args.input}"和"{result.read-data}"。我设计了一套非常简单的小模板语法,用花括号加路径前缀来指代“命令行参数”和“前序动作的输出”。在实现上,其实是每个动作执行完成后,会把返回值按id存进一个运行上下文(RuntimeContext)里;当渲染下一个动作的params时,扫描字符串里所有花括号引用,逐个替换成实际值。
这个设计一开始就被朋友吐槽过,说“这就是个简易的模板引擎,随便用Jinja2不就行了”。但我仔细想了之后还是坚持用这套自研的最小语法,原因有两点。第一,Jinja2这类完整模板引擎功能太强,循环、条件、过滤器全都有,一旦允许在配方里写这些逻辑,配方很快会退化成一种“谁都能写但谁也维护不了”的编程语言,这就违背了“只描述步骤、不写逻辑”的初衷。第二,自定义语法可以精确控制沙箱边界,搅拌模板时我只需要处理args和result两个命名空间的属性引用,根本不会给用户暴露底层运行时的对象模型,安全性和确定性强得多。
2.2 六个核心执行器
CLI-Anything内置的执行器目前有六个:shell、file、http、prompt、path和log。
shell执行器负责在系统Shell里执行命令,这是最万能、也最需要小心的一个。它的method主要有run和run_async,参数包括command、cwd(工作目录)、env(环境变量)、timeout(超时秒数)。默认情况下run会捕获标准输出和标准错误,并把它们连同退出码一起放进返回值。需要单独说的一点是,我在设计run方法时加了两个默认开关:一是开启超时,默认15秒,超过即杀掉子进程并报错;二是不自动继承父进程的完整环境变量,只注入一组白名单变量。原因后面在“常见问题”章节细聊,这里先记住结论:无状态命令比有状态命令可靠得多。
file执行器封装了文件读写和格式转换的常用操作。除了最基础的read_text、write_text、read_json、write_csv之外,我后来还加了一个非常高频的transform方法,它接受一个输入文件、一个输出路径和一段“内联函数字符串”。所谓内联函数字符串,是指用户在配方里写一行简单的表达式,比如lambda row: {**row, "total": row["price"] * row["count"]},CLI-Anything会在受限命名空间里动态执行它,把结果写到新文件。这个功能最初是为了处理CSV里加一列这种需求,也顺带让配方能搞定不少DataFrame之外的轻量数据整理工作。
http执行器是用来替代“在命令行里敲一长串curl”的。它支持method(GET、POST等)、url、headers、query、body、timeout、retries几个参数。返回值包含status_code、headers、body_text,如果响应内容是JSON,会自动解析成字典存在body_json字段里。
prompt执行器专门负责和用户交互,目前有confirm(是/否)、input(接收一段文本)、select(从选项里挑一个)三种方法。这个执行器在自动化脚本里好像很不起眼,但在真实场景中作用极大——很多任务卡在“到底该不该执行”这一步,用confirm能避免很多因为误操作导致的事故。
path执行器处理路径相关操作:判断文件是否存在、拼接路径、解析通配符、列出目录下所有匹配文件。它解决的问题是跨平台路径分隔符。Windows用反斜杠,Linux用正斜杠,如果配方里直接写./data/*.json,换个环境就废了。我让用户统一写Posix风格路径,由path执行器在运行时转换,减轻配方的平台耦合。
log执行器就是打印日志,支持info、warn、error三个级别。它的主要价值是给配方的执行过程加上“脚手架”:哪一步开始了、哪一步完成、耗时多少。没有它,CLI-Anything在跑多步骤任务时就像个黑盒,用户只能干等着。
六个执行器的关系并不是树状结构,而是互相独立的候选集。CLI-Anything运行时注册了一个叫ExecutorRegistry的对象,键是执行器的名称(就是配方里的use字段),值是一个Python类。要扩展能力,只需要实现一个基类,然后在入口注册,不需要改动调度器的主体代码。我为这个写过一个测试验证“加新执行器不改调度器”,后面在复盘时会再提。
2.3 为什么不用自定义DSL而是配置化
动手写解析器之前,我在“设计一套面向任务编排的DSL”和“用声明式配置描述动作流”之间纠结了很久。前者听起来更优雅,可以写循环、写变量、写函数;后者很朴素,只能列出动作清单和参数引用。最后选配置化,是被一个实际教训推着走的——我在上一个项目里见过“优雅DSL”演化到失控的全过程:因为能写逻辑,所有人都在DSL里加逻辑,最后整个DSL变成一门需要文档的语言,真正想用的人反而被挡在门外。
配置化的约束是刻意保留的:你没有循环,没有复杂运算,没有状态变更。如果你想“对10个文件各做一次处理”,你需要写10个动作,或者用path执行器的通配符能力把10个文件汇聚成一个动作的输入。听起来笨,但好处的确很大——配方文件永远是数据,不是程序;你可以放心地把它交给任何人阅读和修改,不用怕被里面的循环或者递归把机器搞崩。更进一步,因为动作流是纯数据,我可以用Python的抽象语法树(AST)模块对配方做完整静态检查,不经过任何动态执行,就能发现“引用了不存在的参数”“某个动作的method拼错了”等错误。这一点是手写DSL很难做到的,DSL的逻辑代码往往要真正跑起来才能发现问题。
3. 实操过程与核心环节实现
3.1 场景一:把JSON文件处理变成一条命令
我先用CLI-Anything封装了第一个真实需求:把一批JSON文件里的某个字段提取出来,汇总成一个CSV表格。以前我的做法是打开Python REPL,或者临时用jq加awk的组合。现在只需要在recipes/目录下写一份名为flatten-field的配方。
name: flatten-field description: 从多个JSON文件中提取指定字段,合并输出为CSV args: - name: pattern type: string default: "./data/*.json" help: 输入JSON文件的通配符路径 - name: field type: string required: true help: 要提取的字段名 - name: output type: path default: result.csv actions: - id: find-files use: path method: glob params: pattern: "{args.pattern}" - id: read-all use: file method: read_jsons params: files: "{result.find-files}" - id: extract-field use: file method: transform params: data: "{result.read-all}" expression: "lambda record: [{'name': record.get('{args.field}')}]" - id: write-result use: file method: write_csv params: data: "{result.extract-field}" file: "{args.output}"这个配方里藏着一个我用着最顺手的设计:read_jsons返回的不是一个JSON对象列表,而是一个“包装后的多文件结果对象”,它支持通过files参数同时读入多个文件,并把每个文件的路径和内容对应起来。这样后续的transform就不需要关心数据是从哪个文件来的,只需要负责变换。
我用一个包含三个JSON文件的测试目录跑了一下,命令行输入:
cli-anything run flatten-field --field age --output out.csv实际输出:
[1/4] find-files → 匹配到 3 个文件 [2/4] read-all → 读取 3 个文件,共 6 条记录 [3/4] extract-field → 提取字段 'age',输出 6 行 [4/4] write-result → 写入 out.csv 完成,耗时 0.12 秒out.csv内容:
name 23 32 29 41 27 36这段经历验证了一件事:把任务从“写脚本”改成“声明动作流”之后,最大的差异不是运行速度,而是心智负担。写脚本的时候,脑子里要同时想“数据结构怎么组织、异常怎么处理、输出格式怎么拼”;而用配方,这些都是执行器的既定行为,我只需要关心“先做什么,后做什么”。
3.2 场景二:把重复的HTTP请求包成命令
第二个场景来自我经常要做的一个检查:看某个Web服务是否健康、响应时间是否超标。以前是curl -w加一堆格式化参数,输出又长又不容易看。用CLI-Anything做这件事,重点在于http执行器的返回值结构设计和log执行器的展示效果。
先看配方:
name: site-health description: 检查网站是否在线,并显示HTTP状态码和响应时间 args: - name: url type: string required: true help: 要检查的URL - name: timeout type: int default: 10 help: 超时秒数 actions: - id: check use: http method: get params: url: "{args.url}" timeout: "{args.timeout}" - id: show use: log method: info params: message: "站点 {args.url} 状态码 {result.check.status_code},响应时间 {result.check.elapsed_ms}ms"运行效果:
cli-anything run site-health --url https://example.com[1/2] check → 200, 342ms [2/2] show → 站点 https://example.com 状态码 200,响应时间 342ms这个场景让我真正体会到抽象层带来的便利。使用curl时,我需要记住一堆参数和格式化占位符;而在这个配方里,URL和超时被显式声明,状态码和响应时间被统一塞进返回值。如果不满足于“只看一次”,还可以在外面套一层for循环脚本,批量检查几十个URL——而CLI-Anything内部不用做任何修改,因为site-health本身就是个参数化命令。
3.3 场景三:给Shell脚本加上确认和保护
第三个场景把我的旧部署脚本改写成了配方。以前的部署脚本长这样:
cd /home/project/web npm run build tar czf backup-$(date +%Y%m%d).tar.gz dist rsync -av ./dist/ user@server:/var/www/html/这段脚本的问题很明显:没有确认机制,跑错了就是真跑;没有日志分节,跑挂了也不知道挂在哪一步;没有入参校验,万一在错误的环境执行就糟了。
改写后的配方:
name: deploy-web description: 构建前端、备份当前版本、然后同步到服务器 args: - name: target type: select choices: ["staging", "production"] required: true help: 部署目标环境 - name: skip-backup type: bool default: false help: 是否跳过备份步骤 actions: - id: warn use: log method: warn params: message: "即将部署到 {args.target} 环境。" - id: confirm use: prompt method: confirm params: message: "确认继续吗?" - id: build use: shell method: run params: command: "npm run build" cwd: "/home/project/web" - id: backup use: shell method: run params: if: "{result.confirm} and not {args.skip_backup}" command: "tar czf backup-$(date +%Y%m%d).tar.gz dist" cwd: "/home/project/web" - id: sync use: shell method: run params: if: "{result.confirm}" command: "rsync -av ./dist/ user@server:/var/www/html/" cwd: "/home/project/web"注意到params里出现了一个新的字段if。这是我专门为步骤条件跳过设计的:它接受一个字符串表达式,支持and、not、==等简单运算。if表达式的值会在执行该动作前立刻求值,如果为False就跳过,并把这一步标记为“SKIPPED”。之所以没有用更复杂的条件语法,还是那个原则——禁止在配方里写逻辑,但允许在动作的“元数据”位置描述“什么情况下才执行这个动作”。语句简单到只有一行,既不破坏配置的可读性,又能覆盖大多数实用场景。
运行时它的交互过程大概是这样:
[0/5] warn → 即将部署到 production 环境。 [1/5] confirm → 确认继续吗? [y/N] y [2/5] build → OK (8.2s) [3/5] backup → OK (1.1s) [4/5] sync → OK (9.7s) 完成,耗时 19.0 秒这就是我想要的效果:一键执行关键任务、显式确认、分步日志。即使过两三个月后再看到这个配方,我也能立刻想起每个步骤是干什么的。
4. 常见问题与排查技巧实录
4.1 常见问题速查表
把CLI-Anything从原型用到顺手,中间遇到过不少问题,很多问题不是设计上的错误,而是“想得不够周全”或者是“被底层工具的正常行为坑了”。整理成一张速查表放在这里。
| 现象 | 根本原因 | 排查思路 | 解决方案 |
|---|---|---|---|
| 启动命令时提示“未知参数” | 配方里arg的name和命令行参数名对不上 | 检查cli-anything run <name> --help的自动生成帮助 | 统一使用短横线命名,避免下划线 |
| 读取JSON文件时中文乱码 | 不同环境默认编码不一致,Python的open默认编码不统一 | 检查file执行器是否显式传了encoding参数 | 设为UTF-8,并在读取时做errors="replace"容错 |
| 布尔值被解析成字符串 | YAML把true/false识别成了布尔,但代码里用的地方需要传入字符串参数 | 在配方里先用"{args.flag}"传一次,看最终渲染结果 | 明确用引号包裹或者用type: string声明 |
| HTTP请求带了响应体验证,偶尔超时 | 未设置重试机制,网络抖动导致 | 查看http执行器的retries参数 | 设置重试次数,同时把超时调得合理一点 |
| Shell命令在Windows上失败 | Windows默认Shell是cmd.exe,和Linux语法不一致 | 检查日志里的命令执行语句 | 用cwd避免绝对路径,Windows下用powershell模式或把命令改成符合本地的写法 |
| 命令执行了但没有产生预期文件 | 相对路径是相对于CLI-Anything当前工作目录,而不是配方文件所在目录 | 打印cwd和command,确认路径基准在哪里 | 直接在配方显式声明cwd字段,不依赖环境 |
| 一个动作失败,前面已经执行的动作没有回滚 | 没有事务机制,执行器设计为一次性 | 日志里查看失败点 | 先把检查类动作放到最前,重要操作放在一个单独配方里 |
| 同时跑多个配方时,日志混在一起 | 默认日志都打在stdout | 无 | 建议每个执行器增加run_id标识,日志按配方名分组 |
4.2 排查路径与调试技巧
CLI-Anything的设计里有一个我最引以为傲的东西:每个动作执行完毕后,会把它的入参和返回值快照到一个.last_run.json文件里。这个快照文件平时不显眼,排查问题的时候却是救命的。
比如有一次,我发现一个提字段的配方在某个文件上始终返回空结果。如果只盯着屏幕上的[2/3] read-all -> OK,根本不知道问题出在哪个环节。打开.last_run.json一看,发现read-all动作的输入里混进了一个文件名,内容是文件开头被截断了一半的JSON。这个信息在终端输出里可看不出来,但快照把它完整记下来了,我立刻意识到是上游的glob动作把“临时缓存文件”也匹配进来了。
于是我在path执行器的glob方法里默认排除了以~、#、.开头的文件和临时后缀。这个调整就是靠着快照才发现并修复的。
调试时还有个经验值得分享:CLI-Anything支持--dry-run模式。它会解析完配方和参数之后,把所有动作的params渲染出来打印,但不真正执行。这个模式的用途是省去验证配置时的等待时间。每次我修改了一个复杂配方,都先跑一遍--dry-run,检查参数渲染后的实际值是否符合预期;确认没问题,再正式运行。用这个模式,能避免至少一半低级错误。
4.3 复盘几个设计上的遗憾
CLI-Anything现在能稳定工作,但回头看,有几个地方如果再做一次我会换一种方式。
最大的遗憾是shell执行器的环境变量隔离做得太保守了。最初为了安全,我在执行命令时把系统环境变量过滤了一大半,只保留PATH、HOME、LANG等少数几个。结果就是用户在自己的终端里明明配好了NODE_OPTIONS、JAVA_HOME之类的变量,用CLI-Anything跑命令时却全部丢失,导致构建失败。后来我加了一个env参数,允许配方显式声明“需要继承哪些变量”或者“给命令设置哪些变量”,问题才缓解,但这个过程浪费了不少调试时间。如果重新设计,我会默认继承全部环境变量,同时在配方里提供“修剪列表”而不是“白名单”,这样对普通用户更友善。
第二个遗憾是跨平台Shell兼容性。我原以为把命令执行交给系统Shell抽象就足够了,实际发现根本不是。同一个rm -rf或者cp命令,在Windows的cmd.exe下就是不认;即使我识别出Windows之后自动切换成powershell.exe,命令语法也和Linux相差很多。这类问题没法用一个通用的Shell执行器解决,更合理的做法是:常用文件操作用file执行器实现而不是落到shell命令里,只有真正需要系统能力时才去调Shell。这也是我后来不断扩充file执行器方法的原因,本质上是“能不用Shell就不用Shell”。
5. 后续还能怎么玩:插件化与扩展思路
CLI-Anything目前的定位是一个可用的“零碎任务收纳箱”,并不算成熟框架。但我自己很清楚一件事:一个工具的生命力,不在于一开始能做什么,而在于后面能不能顺着使用习惯长出新能力。现在至少有三个明确的方向值得尝试。
第一是插件化执行器。现在新增执行器需要改Python代码,这还不够优雅。下一步应该提供一个标准的插件协议:任何人按约定实现一个类,放进插件目录,CLI-Anything启动时自动扫描并注册。这样社区里的每个人都可以往里面贡献一个又一个小而美的执行器,比如操作Excel的、读写数据库的、调用云存储的。
第二是配方仓库。我发现一个现象:自己写完的配方,经常会在另一个项目里遇到几乎一样的需求。如果CLI-Anything能内置一个“配方市场”机制,让用户能上传、搜索、一键安装别人分享的配方,那它的价值就不只停留在命令行工具层面,而是变成一种“任务知识的共享平台”。每个人都能用自己最顺手的方式去跑一条别人验证过的任务流程。
第三是Web界面。虽然界面叫“Anything”,但命令行毕竟是命令行,总有人看见黑底白字就发怵。我设想了两种轻量模式:一种是在本地起一个极简的Web服务,把配方列表渲染成网页,用户点按钮、填表单就能跑任务;另一种是把CLI-Anything的日志和快照同步到一个简单的汇报页面,方便团队协作时看执行结果。这个方向如果再配合插件化,感觉会产生一些很实在的应用场景。
我自己的看法是,这类“杂活收纳工具”永远不可能是技术圈的主角,但它的潜力和价值恰恰藏在那些不起眼的小任务里。每次花两小时封装一条命令,之后每次使用都省下五分钟,几个月下来,成本已经远远收回来了。这就是我持续折腾它最大的动力。