☰
CLI-Anything:把任意脚本封装成统一命令行工具的实战指南
2026/9/28 16:26:31 网站建设 项目流程

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(f"deploying to {host}:{port}...") # 这里写真正的部署逻辑 if __name__ == "__main__": app()

跑一下python cli.py --help,你会得到一个格式规范的帮助文档,参数说明、默认值、使用示例全都有。这一步完成之后,脚本和工具之间的鸿沟就跨过去一半了。

2.2 执行逻辑的封装:把"业务代码"和"入口代码"解耦

很多人封装 CLI 失败,问题出在把业务逻辑直接堆在main函数里。一两个命令还好,命令多了之后,每个命令的函数体越来越长,参数越来越多,最后变成一个大泥球。

我的做法是三层结构:

  1. 入口层:只做参数解析、调用业务函数、捕获异常;
  2. 业务层:一个命令对应一个纯函数,只接收参数、返回结果,不直接接触sys.argv;
  3. 领域层:真正干活的模块,比如 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}", err=True) raise typer.Exit(code=1)
# 业务层:sync_service.py def run(source: str, dest: str, force: bool) -> SyncResult: # 这里只写业务逻辑,不关心命令行怎么调用 ... return SyncResult(message="同步完成", count=42)

这样做的直接好处是:同一个业务函数可以同时被 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_ascii=False)) 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.py

pyproject.toml里最关键的是把命令注册到系统 PATH 上。这样用户装完包之后可以直接运行my-cli而不是python -m mycli:

[project.scripts] my-cli = "mycli.cli:app"

3.2 入口文件的最小模板

# src/mycli/cli.py from typing import Optional import typer app = typer.Typer(help="my-cli:一个示例 CLI-Anything 工具") @app.command() def hello( name: str = typer.Option("world", help="你的名字"), shout: bool = typer.Option(False, help="是否大写"), ): """打印问候语""" msg = f"hello, {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, min=1, max=16, help="并发数"), ): """批量处理文件""" # 这里调用 services 层 ...

这里有几个容易被忽略的细节:

  • typer.Option(..., ...)表示该参数必填,不填会直接报错退出;
  • Path类型注解会被 Typer 自动处理成路径校验;
  • Optional[str]加None默认值可以让参数变为可选。

3.3 安装与测试:一分钟跑通

pip install -e . my-cli --help my-cli hello --name="CLI-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

背后的实现思路是:

  1. 读取hosts.txt文件,一行一个主机地址;
  2. 用--parallel开关控制是否并发执行(内部用concurrent.futures.ThreadPoolExecutor);
  3. 每台主机的执行结果收集起来,最后统一打印成表格;
  4. 失败的机器单独列出来,并给出非零退出码,方便 CI 感知。

关键在于--user参数——不同环境用的登录用户名不一样,把这个参数暴露出来之后,开发环境和生产环境可以用同一条命令、不同参数完成部署,脚本本身不用分叉。

4.2 场景二:把"数据查询"做成可交互的 CLI

团队里经常有人让我帮忙查数据库,每次我都要手敲一长串 SQL,非常烦。后来我封装了一个db-query工具,把最常用的几个查询固化下来:

db-query user --id=12345 db-query order --date=2025-06-01 --status=paid --json db-query user --list --limit=20 --offset=40

这个工具最核心的设计是"预定义查询模板 + 动态参数":

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}", err=True) raise typer.Exit(code=2) params = {...} # 把可选参数拼进去 rows = db_service.execute(sql, params) ...

有了这个工具之后,最明显的改变是——同事不再提"帮我跑一条 SQL"的需求了,他们自己装好命令行就直接跑。一个好的 CLI 工具是可以把"人工请求"转化为"自助服务"的,这才是工具最大的杠杆效应。

4.3 场景三:把 AI 提示词流程封装成 CLI

大模型相关的工作流最近特别多。我身边很多人处理 AI 调用,方式是"打开网页版,复制粘贴提示词,再把生成结果手工保存",很原始。CLI-Anything 同样可以解决这个问题——把提示词模板、参数注入、结果保存全部标准化。

ai-summarize --input article.md --template=summary --lang=zh --output=result.md ai-review --diff=patch.diff --focus=security

提示词模板存在独立的文件里,用占位符区分可变部分:

你是一个资深 {role}。请根据以下内容进行 {action}: 内容: {content} 要求: 1. 使用 {lang} 回答 2. 输出格式为 Markdown

CLI 层负责读取模板、替换占位符、调用大模型 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}", err=True) raise typer.Exit(code=1) except Exception as e: logger.exception("unexpected error") typer.echo("发生未知错误,已记录日志,请联系维护者", err=True) raise typer.Exit(code=2)

这里我把错误分成两类:

  • 已知错误(例如"文件不存在""网络超时"),直接给用户清晰的提示,退出码为 1;
  • 未知错误,打日志记录详细堆栈,给用户简短的提示,退出码为 2。

退出码的分类也很重要。如果所有错误都是 1,脚本调用方就没法区分"是参数错了要改命令"还是"是环境出问题了要重试"。我用 2 表示"环境性问题",调用方看到这个退出码会自动增加一点重试逻辑。

5.3 交互与进度:别让用户盯着空白终端怀疑人生

命令执行时间超过三秒,就得考虑给用户反馈。我最常用的两个库是tqdm和rich:

from rich.console import Console from rich.progress import track console = Console() for item in track(items, description="处理中", total=len(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 --status=pending --json | my-cli task filter --older-than=7d | my-cli task notify --template=due-reminder

每个环节都独立可测,任何一个环节出错,可以单独拉出来调试,不用重跑整个流程。

6.2 子命令分组:命令多了之后如何保持结构清晰

当一个工具的子命令超过 10 个,把全部命令都平铺在--help里就会显得非常乱。Typer 允许用"子应用"方式分组:

admin_app = typer.Typer(help="管理命令") project_app = typer.Typer(help="项目命令") app.add_typer(admin_app, name="admin") app.add_typer(project_app, name="project")

于是命令行就变成了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 这个名字对我而言,真正意思是:多花二十分钟做一次封装,往后每次使用都能省下五分钟,而且越用越值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询