“CLI-Anything”——不知道你们最近刷到这个热搜词的时候是什么反应我第一反应是终于有人把这件我折腾了好几年的事给“命名”明白了。现代软件缺的从来不是功能而是入口。我有几个内部工具功能没得说但每次想用都得打开网页、登录、点三四层菜单还有一堆跑批脚本参数永远靠改代码交接的时候全靠口头沟通。当时我就想如果任何工具、任何脚本、任何服务都能直接从一个命令行进去用--flag传参、看标准输出拿结果那整个工作流会顺畅得多。这篇文章就用一个我实际在维护的小项目cli-anything当例子聊聊怎么把“任何东西”变成好用的CLI。1. 为什么“任何工具”都值得有一个命令行入口1.1 CLI-Anything到底是什么CLI-Anything听起来像某个特定开源项目但我更愿意把它理解成一种工程模式把任意可调用的能力——HTTP接口、Shell脚本、Python函数、数据库查询、甚至一个PDF解析流程——统一暴露成命令行接口。它不关心你背后是什么技术栈只关心用户最终能不能在终端里用一行命令把它调用起来。如果你搜一下会发现市面上已经有“将自然语言转成命令行工具”的产品也有把AI模型接进终端的工具。CLI-Anything的差异在于“Anything”这个词它强调的是适配范围而不是某个具体功能。举个例子我可以用它给一个内部查询服务做CLI也可以用它给一个每周自动打包上传的脚本做CLI两者背后完全不是一种协议但暴露给用户的都是mytool action --param value的样子。这个思路最核心的价值是降低使用成本。终端用户不需要知道目标服务在哪儿部署、用的是什么鉴权方式、参数是JSON还是XML他只需要知道“这条命令能帮我把事办了”。1.2 从Unix哲学说起命令行是“组合”的最小接口Unix哲学里有一句话叫“Do one thing well”但很多人忽略了后半句——一个工具做得好还不够它还要能被组合。命令行接口恰恰是组合的最小公约数几乎所有语言都能启动子进程几乎所有系统都支持stdin/stdout几乎任何人都能看懂--help。当年我接手一个数据分析项目团队里每个成员都有一套自己的脚本调用方式有用jupyter notebook的有用python xxx.py直接改参数的还有的干脆把SQL嵌在Shell里。结果就是每次复现别人的结果都要先解读他的调用方式。后来我把核心功能统一成CLI所有数据抽取、清洗、建模动作都用># services/healthcheck.yaml meta: name: healthcheck description: Check service health status args: - name: service required: true description: Service name, e.g. auth, order - name: timeout default: 10 description: Timeout in seconds call: type: http method: GET url: https://status.example.internal/api/v1/health/{service} auth: type: bearer token_env: INTERNAL_API_TOKEN用YAML而不是直接写Python类有一个很实际的理由不会写代码的同事也能添加新命令。运维同学只需要复制一份配置模板改一改URL和参数名就能把新服务接入CLI。3.2 第二步把命令行参数解析成统一的结构化参数命令行参数解析这件事用Python标准库argparse就够不必上来就引Click。但有一个点需要注意argparse默认生成的是Namespace对象我建议直接转成字典方便后面传给执行器。def parse_args(service_config: dict): parser argparse.ArgumentParser( progfcli-anything run {service_config[meta][name]}, descriptionservice_config[meta][description], ) for arg in service_config[args]: kwargs {} if arg.get(required): kwargs[required] True if default in arg: kwargs[default] arg[default] kwargs[required] False parser.add_argument(f--{arg[name]}, **kwargs) parser.add_argument(f-{short(arg[name])}, destarg[name], **kwargs) args vars(parser.parse_args()) return args这一步的“统一”很关键。无论背后是HTTP还是Shell到调用阶段我们都只有一份{name: value}的字典。这也是测试时最好处理的部分直接把字典喂给执行函数不需要每次拉起一个真进程。3.3 第三步统一执行器与运行时隔离执行器是CLI-Anything的核心它根据配置里的call.type路由到不同的调用实现。我的做法是维护一个注册表每种类型对应一个处理器类class HttpCaller: def run(self, args: dict, call_conf: dict) - CallResult: url call_conf[url].format(**args) method call_conf.get(method, GET) headers build_headers(call_conf.get(auth)) resp requests.request( method, url, headersheaders, timeoutargs.get(timeout, 10), ) if resp.status_code 400: return CallResult(okFalse, errorfHTTP {resp.status_code}: {resp.text}) return CallResult(okTrue, dataresp.json())本地命令型处理器则用subprocess.run同时传入环境变量和输入数据class ShellCaller: def run(self, args: dict, call_conf: dict) - CallResult: cmd call_conf[cmd].format(**args) env os.environ.copy() env.update(call_conf.get(env, {})) proc subprocess.run( cmd, shellTrue, envenv, capture_outputTrue, textTrue ) if proc.returncode ! 0: return CallResult(okFalse, errorproc.stderr) return CallResult(okTrue, dataproc.stdout)这里最需要注意的不是功能而是运行时隔离。不要用shellTrue去拼接用户输入这个坑我后面会专门讲。如果条件允许优先用shlex.split 列表形式的cmd。3.4 第四步按CLI习惯反馈结果用户已经习惯了CLI的输出风格正常时看到结果出错时在stderr看到错误并且能用$?判断成功失败。所以CLI-Anything的打印逻辑只有三条规则成功数据默认打印到stdout格式为JSON或纯文本错误信息统一打印到stderr前缀为[error]进程退出码为0表示成功1表示调用失败2表示参数错误这三条规则看着简单但很多工具都做不到。成功率高的CLI工具基本都遵循“机器可读人类可读”双轨输出。我一般默认输出纯文本如果配置里指定--output json就输出JSON。4. 一个最小可用的CLI-Anything实现Python版4.1 项目结构我的仓库结构很轻量只保留运行时最必要的东西cli-anything/ ├── cli.py # 入口负责全局参数解析 ├── dispatcher.py # 执行器注册与路由 ├── callers/ │ ├── __init__.py │ ├── http_caller.py │ └── shell_caller.py ├── services/ # 服务配置目录 │ ├── healthcheck.yaml │ ├── backup.yaml │ └── query.yaml └── README.md你不需要一个完整的包管理工程命令行工具最忌讳“装完依赖比用它还麻烦”。标准库 requestsPyYAML三个依赖就够了。4.2 核心代码Dispatcher与注册表cli.py里先扫描配置目录把服务注册进字典import argparse import yaml from pathlib import Path from dispatcher import Dispatcher def load_services(config_dir: Path): services {} for yaml_file in config_dir.glob(*.yaml): with open(yaml_file) as f: conf yaml.safe_load(f) name conf[meta][name] services[name] conf return services def main(): parser argparse.ArgumentParser(progcli-anything) parser.add_argument(action, choices[run, list]) parser.add_argument(service, nargs?, helpservice name) args, rest_args parser.parse_known_args() services load_services(Path(services/)) if args.action list: for name in services: print(name) return if args.service not in services: print(f[error] unknown service: {args.service}, file__import__(sys).stderr) raise SystemExit(1) dispatcher Dispatcher() result dispatcher.invoke(services[args.service], rest_args) if not result.ok: print(f[error] {result.error}, file__import__(sys).stderr) raise SystemExit(1) print(result.data) if __name__ __main__: main()Dispatcher维护一个类型到处理类的映射from callers.http_caller import HttpCaller from callers.shell_caller import ShellCaller class Dispatcher: def __init__(self): self.registry { http: HttpCaller(), shell: ShellCaller(), } def invoke(self, service_conf: dict, rest_args: list): call_type service_conf[call][type] caller self.registry.get(call_type) if caller is None: return CallResult(okFalse, errorfunsupported call type: {call_type}) return caller.run_with_args(service_conf, rest_args)每个Caller内部再做各自的argparse解析这样每个服务都能生成自己的--help跟独立脚本一样。4.3 配置示例接入HTTP API、Shell脚本、本地查询我贴三个典型配置你照着改就能用。HTTP API型用于内部健康检查meta: name: healthcheck description: Check service health args: - name: service required: true - name: timeout default: 10 call: type: http method: GET url: https://status.internal/api/v1/health/{service}Shell脚本型用于备份上传meta: name: backup description: Run backup and upload to archive args: - name: target required: true - name: compress default: gzip call: type: shell cmd: ./scripts/backup.sh --target {target} --compress {compress} env: BACKUP_ENV: prod本地查询型用于查CSVmeta: name: query-csv description: Query rows from a local CSV args: - name: file required: true - name: column required: true - name: value required: true call: type: shell cmd: python scripts/csv_query.py --file {file} --column {column} --value {value}4.4 运行效果演示配置好之后使用体验是这样的$ cli-anything list healthcheck backup query-csv $ cli-anything run healthcheck --service auth --timeout 5 {status: ok, latency_ms: 132} $ cli-anything run backup --target /data/2024 backup finished, uploaded to archive $ echo $? 0到这里你已经拥有一个“加配置即加命令”的CLI工具了。接下来聊聊真正容易翻车的地方。5. 参数校验、错误码与日志最容易翻车的三个细节5.1 参数校验别只依赖argparseargparse能解决“参数缺不缺”的问题但解决不了“参数值对不对”的问题。我一开始就吃过亏把--port参数直接传给了HTTP服务结果用户填了个字符串服务端返回500排查半天才发现在CLI这层就该拦截。我的建议是在配置里增加一个validate字段支持简单规则args: - name: port required: true validate: type: int min: 1 max: 65535对应的执行逻辑就是在解析完成后跑一遍校验器def validate_arg(arg_conf, value): rules arg_conf.get(validate, {}) if rules.get(type) int: try: number int(value) except ValueError: return False, must be an integer if min in rules and number rules[min]: return False, fmust be {rules[min]} if max in rules and number rules[max]: return False, fmust be {rules[max]} return True, 这种“配置文件里声明校验规则”的做法比在代码里写一堆if判断要清晰得多而且新增服务时不会影响其他服务的校验逻辑。5.2 退出码才是CLI的“半条命”如果只想让你的CLI显得专业先把退出码管好。很多脚本无论执行成功还是失败都exit 0这在手动执行时没什么感觉一旦进了自动化调度就非常痛苦——调度系统看不到失败重试机制形同虚设。我固定了一套退出码规范退出码含义典型场景0成功调用完成结果正常1执行失败服务端返回500Shell命令返回非零2参数错误缺少必填参数、类型不合法3配置错误找不到服务配置调用类型不支持规则很简单但要让团队所有人都遵守。任何一个人写了“无论成功失败都exit 0”的处理器整个链路都会崩掉。5.3 日志从第一天就不要打到stdout刚开始做CLI-Anything时我喜欢在代码里到处print(calling service...)方便调试。结果用户执行命令时stdout里混着日志和正式结果下游脚本一解析JSON就崩。现在的原则是stdout只留结果日志一律走stderr或日志文件。如果用户想看详细过程加一个--verbose参数输出到stderr$ cli-anything run healthcheck --service auth --verbose [debug] loading service config: healthcheck.yaml [debug] calling https://status.internal/api/v1/health/auth {status: ok, latency_ms: 132}这样的输出人类看着舒服程序解析也不会出错。6. 三个真实场景的改造案例6.1 场景一把内部StatusPage API封装成健康检查CLI当时我们有个StatusPage系统网页端能看到所有服务的健康状态但运维脚本想调用它就要拼HTTP请求。我用CLI-Anything加了一个healthcheck服务参数只有--service和--timeout。改造后最明显的变化是任何脚本都能做依赖检查了。部署脚本里可以写成if ! cli-anything run healthcheck --service order --timeout 5 /dev/null 21; then echo order service is DOWN, abort exit 1 fi这段脚本用一个退出码就拿到了服务状态不再需要写curl、解析JSON、处理异常。维护成本也降下来了因为curl命令散落在十几个脚本里而healthcheck的配置只有一个。6.2 场景二给一组Python脚本加统一的批量执行入口一个算法团队的同事写了好几个数据预处理脚本每个脚本入口都叫main.py参数风格五花八门。有人用--input-path有人直接用位置参数。我帮他建了三个配置项全部走CLI-Anything统一入口。这一步的价值在执行“按批次跑多个数据任务”时体现得最明显。我可以写一个调度循环for dataset in A B C D; do cli-anything run preprocess --dataset $dataset --output out/$dataset.csv done如果没有统一CLI层这个循环会变成各种python -m module --something的杂耍现场谁维护谁头疼。6.3 场景三用CLI做零碎数据提取与预览还有一类很常见的“Anything”是零碎数据处理从CSV里筛选、从日志里截取某个字段、把多次请求的结果汇总。这类工作不复杂但充满随机性很适合做成临时CLI命令。我加了一个query-csv服务本质上就是跑一段Python脚本$ cli-anything run query-csv --file users.csv --column city --value Beijing id,name,city 3,Alice,Beijing 7,Bob,Beijing这类命令单独看很普通但胜在“可复用”。下次同事要查同一份数据时不需要问他“你当时用的什么脚本”只要说“用query-csv查一下就行”。7. 我踩过的坑和值得保留的习惯7.1 坑一命令注入与转义事故前面提到ShellCaller用了shellTrue这里展开讲一下事故现场。有一天用户传了一个--compress gzip; rm -rf /tmp/backup拼接后执行的命令直接包含了恶意子命令。虽然是我们内部环境但这给所有人都敲了警钟。后期的改法是所有外部输入都先经过shlex.quote再拼接到命令串里或者干脆放弃字符串拼接改用数组传参cmd_list [scripts/backup.sh, --target, args[target], --compress, args[compress]] subprocess.run(cmd_list, checkFalse)不经过shell就不存在;、|、这些符号的解析问题。能不用shellTrue就不要用这是写入README的第一条安全红线。7.2 坑二超时和重试策略写得太草率HTTP调用不设超时是我见过最多的低级失误。有一次内部服务假死cli-anything run healthcheck卡在HTTP连接上用户还以为命令执行中等了十分钟才反应过来。后来我在HttpCaller里强制默认超时并且支持配置重试次数call: type: http timeout: 10 retry: count: 3 backoff: 0.5重试逻辑只做“连接错误”和“5xx状态码”的重试4xx一律不重试因为那是参数问题重试多少次都没用。7.3 一些值得保留的工程习惯根据我这几个月的折腾沉淀下来一套习惯也算给接手的人留个参考每个服务配置都有owner字段。出问题能快速找到负责人而不是在群里问“这个命令谁写的”配置目录纳入版本库。所有CLI命令的变更可追溯回滚也简单对输出做最大长度限制。防止一个接口返回几十MB数据直接冲爆终端我默认限制5MB超出就截断并给提示提供--pager选项。长输出自动接到less这个功能看着小但实际使用率非常高在README里写清退出码含义。团队协作时大家不需要看代码也能知道怎么判断结果。最后分享一个小技巧给每个服务配置都配一个example字段写上一两条完整示例命令。比如meta: name: healthcheck description: Check service health example: cli-anything run healthcheck --service auth --timeout 5用户执行cli-anything run healthcheck --help时argparse的epilog会把这个示例显示出来。我发现这个小小的字段比写十页文档都管用。有段时间我甚至靠这个功能替代了内部的wiki页面让新同事三分钟就学会调用所有已接入的CLI命令。