
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”的。它支持methodGET、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/ userserver:/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/ userserver:/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并在读取时做errorsreplace容错布尔值被解析成字符串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的日志和快照同步到一个简单的汇报页面方便团队协作时看执行结果。这个方向如果再配合插件化感觉会产生一些很实在的应用场景。我自己的看法是这类“杂活收纳工具”永远不可能是技术圈的主角但它的潜力和价值恰恰藏在那些不起眼的小任务里。每次花两小时封装一条命令之后每次使用都省下五分钟几个月下来成本已经远远收回来了。这就是我持续折腾它最大的动力。