CLI-Anything:把任意脚本封装成统一命令行工具的实战指南
1. 为什么写了三年脚本我最后还是攒了一个 CLI-Anything先交代一下背景。我平时的工作里有一半时间在和各种命令行工具打交道另一半时间在写那些用完就忘的一次性脚本——批量改文件、调 API 拉数据、跑测试、同步服务器配置。时间长了你会发现一个很尴尬的事实纯脚本散落在各个目录里参数写死在代码中下次想复用根本想不起来当时是怎么写的。更不用说同事借你电脑跑个命令看到一长串python3 xxx.py --input xxx --output yyy的时候脸上写满了这玩意儿到底怎么用。CLI-Anything 这个名字字面意思就是把任何操作变成命令行工具。它不是某个单一功能库而是一套把任意脚本、任意任务、任意工作流快速封装成统一命令行界面的思路和工具集。你写过的 Python 脚本、Shell 命令、Node 工具、甚至调用大模型的提示词模板都可以用这套方式包装成规范、可复用、可分享的命令行工具。这篇博文就把我这几年攒下来的封装思路、踩过的坑、以及最终形成的工作流完整拆开讲一遍适合给所有写过脚本但又不想止步于脚本的人参考。2. CLI-Anything 的核心设计逻辑把一次性脚本变成可持续工具的关键抽象先别急着看代码我觉得最值得先聊的是设计逻辑。CLI-Anything 能成立靠的不是某个法术级的框架而是一个很朴素的抽象任何任务都可以被拆成输入参数 执行逻辑 输出结果三段。你写脚本时之所以觉得不方便复用往往是因为这三段没有明确分开。2.1 输入参数的标准化从 写死在代码里 到 声明式定义大部分一次性脚本长这样# 老写法参数写死 host 192.168.1.10 port 22 username root run_deploy()一旦要换一台机器、换一个环境你就得打开文件改代码。CLI-Anything 的思路是让参数变成声明式的——你只需要描述这个参数叫什么、是什么类型、默认值是什么剩下的解析工作交给工具层去做。在 Python 生态里我最常用的是argparse标准库和click/Typer第三方库。以 Typer 为例它基于类型注解来自动生成命令行参数写起来几乎没有任何模板代码# cli.py import typer app typer.Typer() app.command() def deploy(host: str 192.168.1.10, port: int 22): 将当前项目部署到指定主机 print(fdeploying to {host}:{port}...) # 这里写真正的部署逻辑 if __name__ __main__: app()跑一下python cli.py --help你会得到一个格式规范的帮助文档参数说明、默认值、使用示例全都有。这一步完成之后脚本和工具之间的鸿沟就跨过去一半了。2.2 执行逻辑的封装把业务代码和入口代码解耦很多人封装 CLI 失败问题出在把业务逻辑直接堆在main函数里。一两个命令还好命令多了之后每个命令的函数体越来越长参数越来越多最后变成一个大泥球。我的做法是三层结构入口层只做参数解析、调用业务函数、捕获异常业务层一个命令对应一个纯函数只接收参数、返回结果不直接接触sys.argv领域层真正干活的模块比如 SSH 连接、文件操作、API 调用。# 入口层cli.py app.command() def sync(source: Path, dest: Path, force: bool False): 同步目录 try: result sync_service.run(str(source), str(dest), force) print(result.message) except SyncError as e: typer.echo(f同步失败: {e}, errTrue) raise typer.Exit(code1)# 业务层sync_service.py def run(source: str, dest: str, force: bool) - SyncResult: # 这里只写业务逻辑不关心命令行怎么调用 ... return SyncResult(message同步完成, count42)这样做的直接好处是同一个业务函数可以同时被 CLI、Web 接口、定时任务调用。我有一次就是把某个内部 CLI 工具的逻辑层抽出来接到一个简单的 Flask 服务上半天就完成了内部运维面板的接口开发——因为业务函数压根不关心自己是被命令行调用还是被 HTTP 调用。2.3 输出结果的规范化机器可读与人类可读的平衡CLI 工具最容易翻车的地方在输出。早期我写的工具特别随性print想打什么就打什么结果想做日志收集、想接入 CI 判断成功失败都没有标准。CLI-Anything 对输出有几个约定正常结果打到 stdout错误信息打到 stderr退出码用 0 表示成功非 0 表示失败支持--json参数切换成结构化输出方便脚本调用进度条和日志只走 tqdm / logging 的重定向通道不污染标准输出。app.command() def query(keyword: str, json_output: bool False): 搜索资源 results search_service.search(keyword) if json_output: typer.echo(Json.dumps([r.to_dict() for r in results], ensure_asciiFalse)) else: for r in results: typer.echo(f{r.id}\t{r.title})为什么这么强调输出规范因为一个 CLI 工具一旦要接入 CI/CD 流水线、要被其他脚本调用输出格式就是你的API 接口。接口不稳定下游必炸。3. 从零搭一个最小可用的 CLI-Anything 骨架我用的目录结构和代码模板聊完设计逻辑直接上骨架。这套骨架是我在多个项目里反复删减保留下来的版本不花哨但够用。3.1 推荐的目录结构my-cli-project/ ├── pyproject.toml # 项目元数据、依赖、入口点配置 ├── README.md # 使用文档 ├── src/ │ └── mycli/ │ ├── __init__.py │ ├── __main__.py # 支持 python -m mycli │ ├── cli.py # 入口层Typer 命令定义 │ ├── services/ # 业务层具体逻辑 │ │ ├── __init__.py │ │ ├── sync.py │ │ └── query.py │ └── utils/ # 领域层小工具函数 │ ├── __init__.py │ ├── ssh_utils.py │ └── file_utils.py └── tests/ └── test_services.pypyproject.toml里最关键的是把命令注册到系统 PATH 上。这样用户装完包之后可以直接运行my-cli而不是python -m mycli[project.scripts] my-cli mycli.cli:app3.2 入口文件的最小模板# src/mycli/cli.py from typing import Optional import typer app typer.Typer(helpmy-cli一个示例 CLI-Anything 工具) app.command() def hello( name: str typer.Option(world, help你的名字), shout: bool typer.Option(False, help是否大写), ): 打印问候语 msg fhello, {name} if shout: msg msg.upper() typer.echo(msg) app.command() def batch( input_dir: Path typer.Option(..., help输入目录), output_dir: Path typer.Option(..., help输出目录), pattern: str *.txt, workers: int typer.Option(4, min1, max16, help并发数), ): 批量处理文件 # 这里调用 services 层 ...这里有几个容易被忽略的细节typer.Option(..., ...)表示该参数必填不填会直接报错退出Path类型注解会被 Typer 自动处理成路径校验Optional[str]加None默认值可以让参数变为可选。3.3 安装与测试一分钟跑通pip install -e . my-cli --help my-cli hello --nameCLI-Anything --shout-e是开发模式安装改代码不用重装。这是我在所有 CLI 项目里都会先做的一步——省掉每次改完代码还得重新 install 才能测的蠢事。4. 真实场景实战我用 CLI-Anything 解决的三个具体问题骨架摆在那不落到真实场景里就是空壳。以下三个场景是我在团队里实际部署过、并且一直用到现在的东西拿出来当参考案例再合适不过。4.1 场景一把服务器批量操作封装成一条命令以前给一批服务器同步配置文件我的做法是写一个长 Shell 循环for host in $(cat hosts.txt); do ssh root$host bash -s script.sh; done。问题很多某个主机连不上不会单独报错、输出全糊在一起、想跳过某台机器要手动改文件。用 CLI-Anything 重构之后工具长这样my-cli deploy --hosts hosts.txt --config nginx.conf --user ubuntu --parallel背后的实现思路是读取hosts.txt文件一行一个主机地址用--parallel开关控制是否并发执行内部用concurrent.futures.ThreadPoolExecutor每台主机的执行结果收集起来最后统一打印成表格失败的机器单独列出来并给出非零退出码方便 CI 感知。关键在于--user参数——不同环境用的登录用户名不一样把这个参数暴露出来之后开发环境和生产环境可以用同一条命令、不同参数完成部署脚本本身不用分叉。4.2 场景二把数据查询做成可交互的 CLI团队里经常有人让我帮忙查数据库每次我都要手敲一长串 SQL非常烦。后来我封装了一个db-query工具把最常用的几个查询固化下来db-query user --id12345 db-query order --date2025-06-01 --statuspaid --json db-query user --list --limit20 --offset40这个工具最核心的设计是预定义查询模板 动态参数QUERIES { user: SELECT * FROM users WHERE id :id, order: SELECT * FROM orders WHERE date :date AND status :status, } app.command() def query( table: str typer.Argument(..., help查询类型), id: Optional[int] None, date: Optional[str] None, status: Optional[str] None, json_output: bool False, ): sql QUERIES.get(table) if not sql: typer.echo(f不支持的表: {table}, errTrue) raise typer.Exit(code2) params {...} # 把可选参数拼进去 rows db_service.execute(sql, params) ...有了这个工具之后最明显的改变是——同事不再提帮我跑一条 SQL的需求了他们自己装好命令行就直接跑。一个好的 CLI 工具是可以把人工请求转化为自助服务的这才是工具最大的杠杆效应。4.3 场景三把 AI 提示词流程封装成 CLI大模型相关的工作流最近特别多。我身边很多人处理 AI 调用方式是打开网页版复制粘贴提示词再把生成结果手工保存很原始。CLI-Anything 同样可以解决这个问题——把提示词模板、参数注入、结果保存全部标准化。ai-summarize --input article.md --templatesummary --langzh --outputresult.md ai-review --diffpatch.diff --focussecurity提示词模板存在独立的文件里用占位符区分可变部分你是一个资深 {role}。请根据以下内容进行 {action} 内容 {content} 要求 1. 使用 {lang} 回答 2. 输出格式为 MarkdownCLI 层负责读取模板、替换占位符、调用大模型 API、处理超时和重试、把结果写入文件。这么一封装AI 能力就变成了和其他命令一样可以组合的工具单元可以接进定时任务也可以接进 CI 里做自动生成。5. 从能用到好用我在参数设计、错误处理、交互细节上踩过的五类坑骨架搭好、场景跑通之后工具进入了给别人用的阶段。这个阶段我发现的问题说实话比前期开发多得多。5.1 参数命名好的参数名可以让文档少写一半早期我把参数命名为-p、-t、-d自己看得懂别人完全懵。后来统一规范成全拼优先 短选项只留高频参数用--host、--port、--timeout而不是-h、-p、-t短选项只留给-v/--verbose、-q/--quiet、-h/--help这种全局高频参数布尔参数统一用--force/--no-force这种带反义的形式Typer 天然支持。尤其要注意-h默认被 help 占用所以千万不要再拿-h去代表 host这是新手最容易犯的错误。5.2 错误处理不要让用户面对 Python Traceback未捕获的异常会打印一长串堆栈信息对命令行用户来说既不友好也不安全。我在所有命令外面套了一层统一的异常处理app.command() def safe_command(): try: services.run() except KnownError as e: typer.echo(f操作失败: {e.message}, errTrue) raise typer.Exit(code1) except Exception as e: logger.exception(unexpected error) typer.echo(发生未知错误已记录日志请联系维护者, errTrue) raise typer.Exit(code2)这里我把错误分成两类已知错误例如文件不存在网络超时直接给用户清晰的提示退出码为 1未知错误打日志记录详细堆栈给用户简短的提示退出码为 2。退出码的分类也很重要。如果所有错误都是 1脚本调用方就没法区分是参数错了要改命令还是是环境出问题了要重试。我用 2 表示环境性问题调用方看到这个退出码会自动增加一点重试逻辑。5.3 交互与进度别让用户盯着空白终端怀疑人生命令执行时间超过三秒就得考虑给用户反馈。我最常用的两个库是tqdm和richfrom rich.console import Console from rich.progress import track console Console() for item in track(items, description处理中, totallen(items)): process(item) console.print([green]完成[/green])有个细节必须提进度条输出是往 stderr 写的。如果你把进度条打到 stdout然后又通过重定向my-cli result.txt保存正常输出进度条就会污染 result.txt。这一点很多人踩过坑包括我自己。5.4 配置管理用户配置文件是 CLI 工具的隐藏需求一个工具的参数如果超过五个每次敲命令都带所有参数就很反人类。我在项目里加了一层配置文件的逻辑用户可以在~/.config/my-cli/config.toml里写默认参数CLI 解析顺序是命令行参数 配置文件 默认值提供my-cli config init命令来生成配置模板。# config.toml 示例 [deploy] host 192.168.1.10 user root port 22 [query] default_limit 20这样用户日常使用只需要my-cli deploy完全不用每次敲主机地址。配置文件的解析我放在入口层做业务层拿到的就是最终合并完的参数保持业务层简单。5.5 幂等性设计CLI 工具一定要能重复跑这是我做运维类工具时总结出最重要的一条一个命令应该可以安全地执行两次。如果第二次执行会覆盖数据、产生冲突、或者留下中间产物那这个工具就是不成熟的。具体做法写文件时先写临时文件再原子替换os.replace删除操作加上--dry-run参数让用户先看要删什么再真删有状态的操作比如创建资源先查重已存在就直接跳过并提示。有了幂等性CLI 工具才能放心进自动化流程。6. 组合与进阶把多个 CLI 命令串成自己的操作流单个命令好用之后下一个阶段就是组合。CLI 的美妙之处在于它天然支持管道和脚本串联而 CLI-Anything 做的自定义命令恰好能作为管道里的零件。6.1 管道友好的输入输出设计要让命令支持my-cli query ... | my-cli convert ...就必须支持标准输入和标准输出。上面提到的--json参数在这里就发挥作用了第一个命令输出 JSON第二个命令用json.load(sys.stdin)读入处理完再输出一条链就通了。我常用的一个组合例子是列出所有待处理任务 - 过滤出过期的 - 批量发送提醒邮件my-cli task list --statuspending --json | my-cli task filter --older-than7d | my-cli task notify --templatedue-reminder每个环节都独立可测任何一个环节出错可以单独拉出来调试不用重跑整个流程。6.2 子命令分组命令多了之后如何保持结构清晰当一个工具的子命令超过 10 个把全部命令都平铺在--help里就会显得非常乱。Typer 允许用子应用方式分组admin_app typer.Typer(help管理命令) project_app typer.Typer(help项目命令) app.add_typer(admin_app, nameadmin) app.add_typer(project_app, nameproject)于是命令行就变成了my-cli admin user list和my-cli project deploy——跟git remote、kubectl get这种成熟工具的结构是一样的。用户看到一个层级化的帮助信息远比看到一长串扁平命令列表更容易上手。6.3 Shell 补全让命令行工具的使用体验上一个台阶最后推荐一个投入产出比极高的功能Shell 自动补全。Typer 和 Click 都内置了补全脚本生成只需要my-cli --install-completion bash # 或者 zsh / fish用户在终端里输入my-cli dep再按 Tab命令自动补全成my-cli deploy参数也能提示。这个功能我加上之后团队里命令行基础一般的同事都明显更愿意用 CLI 工具了——因为不记得参数这个最大的使用阻力被补全解决了。7. 发布与分享让工具在团队里真正落地的一些经验一个 CLI 工具写完了不发布就跟没写一样。尤其在一个团队里只有能轻松安装和升级的工具才会真正被人使用。7.1 发布到内部 PyPI 源公司内部一般都会搭一个 PyPI 私有源。发布命令非常简单python -m build twine upload --repository-url https://pypi.internal.example.com/ dist/*同事安装pip install my-cli走官方 pip 流程的好处是升级方便pip install -U my-cli一条命令完事。7.2 文档要写使用示例而不是参数清单老实说绝大多数 CLI 工具的死因不是功能缺失而是文档太差。我写 README 的原则是每个命令至少给一个完整的示例复制粘贴就能跑通而不是只贴参数定义。# 不推荐 # deploy: 部署到指定主机 # 参数: --host, --user, --port # 推荐 # 一条命令完成部署 my-cli deploy --host 192.168.1.10 --user ubuntu --port 22 # 指定配置文件部署 my-cli deploy --config ./prod.toml我自己写完 README 之后有个检验方法找一位没碰过这个项目的同事让他完全靠文档从零跑通一个任务。如果他全程没来问我文档就合格了。7.3 版本号和 changelog升级要有仪式感内部工具有个通病——版本号永远是0.0.1或0.1.0更新记录完全不存在。CLI-Anything 落地的时候我顺手把版本管理也规范了用semantic-release自动生成版本号和 changelog代码合并到 main 分支就自动发布新版本重大变更breaking change在 changelog 里用醒目标记标出来每个命令的--help末尾自动带上当前版本号方便用户反馈问题时对版本。有了这个流程之后用户更新工具时至少知道自己会面对什么变化出了问题也知道该报哪个版本我们排查起来效率直接翻倍。8. 最后分享两件我在实际使用中的小事第一件是关于给 CLI 工具写帮助文档这件事。刚开始我总觉得--help里的描述随便写写就好后来我发现用户遇到困惑时第一个动作就是敲--help而且大概率只看第一屏。所以我现在每个typer.Option的help参数都会认真写尽量把这个参数是干嘛的、多久用一次、有没有默认值说清楚。这是成本极低但口碑提升极明显的细节。第二件是关于工具该做多小。有人觉得 CLI-Anything 这种把任何东西都包装成命令的方式会导致工具膨胀但我的体感恰好相反——正是因为封装成本低我才会把一个原本要 50 行的临时脚本在写之前就拆成参数 函数 输出三部分逻辑反而是更清晰了。以后业务变化改的是services层里的一个函数而不是翻遍整个脚本删删改改。CLI 工具的魅力就在于它的可组合性、可脚本化和可自动化。CLI-Anything 这个名字对我而言真正意思是多花二十分钟做一次封装往后每次使用都能省下五分钟而且越用越值。