
1. 从CLI-Anything这个名字说起它到底想解决什么第一次看到CLI-Anything这个标题我脑子里蹦出来的第一个念头是又是一个把命令行包装成万能入口的项目。但仔细琢磨了一下这个命名逻辑再结合关键词里反复出现的 CLI、Agent、CLI-Hub、Python、Click 这几个词我大概能还原出这个项目想干的事情——把任意能力工具、脚本、服务、Agent统一收敛到一个命令行入口下用一套约定把它们组织起来形成一个可发现、可组合、可复用的 CLI 生态。这个思路其实不新鲜但真正落地做好的不多。为什么因为大部分人做 CLI 工具的时候脑子里想的是我要写一个命令而不是我要设计一套命令的注册、发现、调用、编排机制。前者做出来的是一个孤立的脚本后者做出来的才是一个平台。CLI-Anything 这个名字里的Anything才是重点——它暗示的不是某一个具体功能而是一种容纳能力不管你是 Python 脚本、Shell 工具、HTTP 服务还是现在最火的 Agent只要能抽象成一个输入-处理-输出的过程就应该能被挂到这个 CLI 体系下面。我自己在过去几年里陆陆续续写过不少命令行工具从最早的 argparse 一把梭到后来用 Click 做参数解析再到给团队做内部的 CLI-Hub 把几十个脚本统一管理起来。踩过的坑包括但不限于参数命名冲突、子命令层级混乱、依赖版本打架、跨平台路径处理翻车、以及最要命的——工具写完之后没人知道它存在。所以当我看到CLI-Anything这个方向时第一反应是如果它真能把发现和编排这两件事解决好那价值就大了。这篇文章我会围绕这个标题和它背后的关键词网络把 CLI 工具生态的设计思路、Click 框架的实战用法、CLI-Hub 的组织方式、以及 CLI 与 Agent 结合的最新玩法全部拆开讲一遍。不管你是刚学 Python 想写第一个命令行工具的新手还是已经在做 Agent 开发、想把工具链统一起来的老手应该都能从里面找到能直接抄作业的东西。提示本文涉及的所有代码和配置都基于 Python 3.10 和 Click 8.x 版本其他版本可能有细微差异遇到问题先检查版本。2. 为什么是 Click而不是 argparse 或 Typer2.1 参数解析这件事远比你想的复杂很多人觉得命令行参数解析是个小问题argparse 够用了。我一开始也这么想直到我写了一个带 15 个子命令、每个子命令又有 8 个可选参数的工具argparse 的代码量直接爆炸而且子命令之间的公共参数没法优雅复用。后来换成 Click同样的功能代码量少了差不多一半可读性还上去了。Click 的核心优势在于它的装饰器式声明。你不需要手动创建 parser 对象、注册 argument、再 parse_args而是直接在函数上打装饰器import click click.group() click.option(--verbose, -v, is_flagTrue, help开启详细输出) click.pass_context def cli(ctx, verbose): ctx.ensure_object(dict) ctx.obj[verbose] verbose cli.command() click.argument(name) click.option(--count, -c, default1, help重复次数) click.pass_context def greet(ctx, name, count): for _ in range(count): click.echo(fHello, {name}!) if __name__ __main__: cli()这段代码定义了一个带全局--verbose选项的 CLI下面挂了一个greet子命令。click.group()创建命令组cli.command()注册子命令click.pass_context让子命令能拿到父级的上下文。这套机制看起来简单但它解决了一个关键问题全局配置和子命令之间的数据传递。argparse 要做同样的事情你得手动搞一个 namespace 传来传去很容易出错。2.2 Click 的上下文机制是 CLI-Hub 的基础CLI-Hub 这个概念说白了就是把一堆命令组织成一个树状结构然后提供一个统一的入口去调用它们。Click 的Context对象天然适合干这个事。你可以把数据库连接、配置文件路径、日志级别这些全局状态挂在ctx.obj上所有子命令都能访问不用每个命令都重新初始化一遍。我做过一个内部工具集大概有 30 多个子命令分成db、deploy、monitor、utils四个大组。结构大概是这样click.group() click.option(--config, -c, default~/.mycli/config.yaml) click.pass_context def hub(ctx, config): ctx.ensure_object(dict) ctx.obj[config] load_config(config) hub.group() def db(): 数据库相关操作 pass db.command() click.argument(table) click.pass_context def query(ctx, table): cfg ctx.obj[config] # 用 cfg 里的连接信息去查 ...这种嵌套 group 的方式让命令的层级非常清晰。用户敲hub db query users的时候Click 会自动路由到对应的函数。而且--help是自动生成的每个层级的帮助信息都很完整。这一点对于 CLI-Hub 来说太重要了——工具的可发现性一半靠帮助文档一半靠命令命名。2.3 Typer 也很好但 Click 更底层可控有人会问现在 Typer 不是更流行吗确实Typer 基于 Click 封装用类型注解来定义参数写起来更简洁。但我在做 CLI-Hub 这种需要动态注册命令的场景时还是更倾向直接用 Click。原因很简单Typer 的抽象层太厚了动态注册命令的时候容易碰到它没暴露出来的边界情况。举个例子如果你想让 CLI-Hub 从一个配置文件或者插件目录里动态加载命令Click 的add_command方法可以直接用import importlib def load_plugins(hub_group, plugin_dir): for module_name in discover_plugins(plugin_dir): module importlib.import_module(module_name) if hasattr(module, register): module.register(hub_group)插件模块里只需要定义一个register函数把命令挂到传入的 group 上就行。这种灵活性在 Typer 里做起来就绕一些。所以我的建议是如果你只是写一个简单的单命令工具Typer 很香如果你要做 CLI-Hub 这种平台级的东西Click 更合适。3. CLI-Hub 的组织哲学让工具被找到比让工具能跑更重要3.1 命名规范决定了工具集的生死我见过太多内部工具集死于没人知道有这个命令。你辛辛苦苦写了一个批量处理日志的脚本结果同事还在手动 grep因为他根本不知道你写了这个东西。CLI-Hub 要解决的第一个问题不是技术问题是信息架构问题。命名规范是信息架构的地基。我的经验是遵循动词-名词或者领域-动作的结构。比如命名方式示例适用场景动词-名词create-user、delete-file操作明确、对象单一领域-动作db-migrate、log-analyze工具体系大、需要分组嵌套子命令db migrate、log analyze命令数量多、层级清晰我个人的偏好是嵌套子命令因为 Click 对它的支持最好而且--help的展示效果最清晰。但嵌套层级不要超过三层否则用户敲命令像在走迷宫。hub db migrate是合理的hub db schema migrate apply就过分了。3.2 动态发现让插件自己报到CLI-Hub 的第二个核心能力是动态发现。你不可能把所有命令都写在一个文件里那样维护成本太高。合理的做法是每个功能模块独立成一个 Python 包通过 entry_points 或者约定目录来注册。用entry_points的方式最规范在pyproject.toml里声明[project.entry-points.mycli.plugins] db mycli_db:register deploy mycli_deploy:register然后在主程序里加载from importlib.metadata import entry_points def load_all_plugins(hub_group): eps entry_points(groupmycli.plugins) for ep in eps: register_func ep.load() register_func(hub_group)这种方式的好处是解耦彻底——插件包可以独立发布、独立安装主程序不需要知道任何插件的具体实现。坏处是调试的时候稍微麻烦一点因为入口是动态加载的。我一般会在开发阶段加一个--list-plugins命令把所有已注册的插件和命令打印出来方便排查。3.3 配置管理别让用户在每个命令里重复输入CLI-Hub 的第三个关键点是配置的集中管理。用户不应该在每个子命令里都输入一遍 API key、数据库地址、日志级别。这些应该有一个统一的配置文件在 CLI 启动的时候加载一次挂到ctx.obj上。我的做法是用一个~/.mycli/config.yaml结构大概是这样default: log_level: info output_format: table db: host: localhost port: 5432 user: admin deploy: target: staging timeout: 300加载逻辑放在根 group 的回调里子命令通过ctx.obj[config]访问。同时支持环境变量覆盖优先级是命令行参数 环境变量 配置文件 默认值。这个优先级顺序是行业惯例用户不用记凭直觉就能猜对。注意配置文件里不要明文存密码。我一般用 keyring 库把敏感信息存到系统密钥链里配置文件里只存一个引用名。4. 当 CLI 遇上 Agent工具调用的新范式4.1 Agent 为什么需要 CLI最近半年 Agent 开发火得一塌糊涂关键词里 agent、agent 框架、agent 记忆、agent 安全这些词频繁出现。但很多人做 Agent 的时候忽略了一个问题Agent 要调用工具工具从哪来大部分 Agent 框架的做法是定义一个 JSON schema描述工具的名称、参数、返回值然后让模型去生成调用。这个方式在工具少的时候没问题但工具一多schema 的管理就成了噩梦。而且很多现成的工具本来就是命令行程序你非要给它包一层 HTTP API 或者 Python 函数纯属重复劳动。CLI 天然就是 Agent 的工具接口。一个设计良好的 CLI它的--help就是工具描述它的参数就是工具入参它的 stdout 就是工具返回值。Agent 只需要知道命令名和参数格式就能调用。这也是为什么关键词里同时出现了 CLI 和 Agent——CLI 是 Agent 工具层最自然的抽象。4.2 把 CLI 命令暴露给 Agent 的实操我最近做的一个实验是把一个 Click 写的 CLI-Hub 直接暴露给 Agent 调用。核心思路是遍历 Click 的命令树自动生成工具描述。def extract_tools(group, prefix): tools [] for name, cmd in group.commands.items(): full_name f{prefix}{name} if isinstance(cmd, click.Group): tools.extend(extract_tools(cmd, f{full_name} )) else: params [] for p in cmd.params: params.append({ name: p.name, type: p.type.name, required: p.required, help: p.help or }) tools.append({ name: full_name.strip(), description: cmd.help or , parameters: params }) return tools这段代码会递归遍历所有命令生成一个工具列表。Agent 拿到这个列表之后就可以根据用户意图选择合适的命令拼出命令行字符串然后通过 subprocess 执行。这里有个坑要注意Agent 生成的命令字符串一定要做校验。我见过 Agent 把用户输入直接拼到命令里结果用户输入了一个带分号的字符串直接执行了额外命令。正确的做法是用shlex.quote对每个参数做转义或者干脆不用 shell直接用subprocess.run的列表形式传参。import subprocess import shlex def safe_run(cmd_parts): # cmd_parts 是列表比如 [mycli, db, query, users] result subprocess.run( cmd_parts, capture_outputTrue, textTrue, timeout30 ) return result.stdout4.3 Agent 记忆与 CLI 的结合点关键词里有个词叫agent 记忆还有个学术味很浓的 a-memguard: a proactive defense framework for llm-based agent memory。这两个词放在一起其实指向一个很实际的问题Agent 调用工具的历史本身就是一种记忆。我的做法是给 CLI-Hub 加一个--record选项每次执行命令的时候把命令、参数、结果、时间戳写到一个本地 SQLite 里。Agent 在规划下一步的时候可以先查一下历史记录看看之前有没有执行过类似的命令、结果是什么。这样既避免了重复调用也让 Agent 的行为更有连续性。import sqlite3 from datetime import datetime def record_execution(cmd, args, result): conn sqlite3.connect(~/.mycli/history.db) conn.execute( INSERT INTO history (cmd, args, result, ts) VALUES (?, ?, ?, ?) , (cmd, str(args), result[:1000], datetime.now().isoformat())) conn.commit() conn.close()这个历史表还可以用来做工具推荐——Agent 遇到一个新任务时先检索历史里相似的任务看看当时用了什么命令。这比让模型凭空想要靠谱得多。5. 从零搭一个 CLI-Anything 风格的工具集完整步骤5.1 项目结构设计说了这么多原理现在来一套能直接跑的。假设我们要做一个叫anycli的工具集支持动态插件、配置管理、Agent 调用。目录结构如下anycli/ ├── pyproject.toml ├── anycli/ │ ├── __init__.py │ ├── main.py # 入口定义根 group │ ├── config.py # 配置加载 │ ├── plugin.py # 插件发现 │ └── commands/ │ ├── __init__.py │ ├── db.py │ └── sysinfo.py └── tests/pyproject.toml里声明入口点和依赖[project] name anycli version 0.1.0 dependencies [ click8.1, pyyaml6.0, ] [project.scripts] anycli anycli.main:cli [project.entry-points.anycli.plugins] db anycli.commands.db:register sysinfo anycli.commands.sysinfo:register5.2 根命令与配置加载main.py里定义根 group加载配置注册插件import click from .config import load_config from .plugin import load_plugins click.group() click.option(--config, -c, defaultNone, help配置文件路径) click.option(--verbose, -v, is_flagTrue, help详细输出) click.version_option() click.pass_context def cli(ctx, config, verbose): anycli - 一个可扩展的命令行工具集 ctx.ensure_object(dict) ctx.obj[config] load_config(config) ctx.obj[verbose] verbose load_plugins(cli) if __name__ __main__: cli()config.py负责加载 YAML 配置并支持环境变量覆盖import os import yaml from pathlib import Path DEFAULT_CONFIG Path.home() / .anycli / config.yaml def load_config(pathNone): config_path Path(path) if path else DEFAULT_CONFIG if config_path.exists(): with open(config_path) as f: cfg yaml.safe_load(f) or {} else: cfg {} # 环境变量覆盖前缀 ANYCLI_ for key, value in os.environ.items(): if key.startswith(ANYCLI_): cfg[key[7:].lower()] value return cfg5.3 插件注册的两种方式plugin.py同时支持 entry_points 和目录扫描两种方式方便开发和发布import importlib from importlib.metadata import entry_points from pathlib import Path def load_plugins(cli_group): # 方式一entry_points for ep in entry_points(groupanycli.plugins): try: register ep.load() register(cli_group) except Exception as e: click.echo(f加载插件 {ep.name} 失败: {e}, errTrue) # 方式二本地插件目录开发用 plugin_dir Path.home() / .anycli / plugins if plugin_dir.exists(): for py_file in plugin_dir.glob(*.py): spec importlib.util.spec_from_file_location(py_file.stem, py_file) module importlib.util.module_from_spec(spec) spec.loader.exec_module(module) if hasattr(module, register): module.register(cli_group)5.4 写一个具体的命令模块commands/db.py里定义数据库相关命令import click def register(cli_group): cli_group.group() def db(): 数据库操作 pass db.command() click.argument(table) click.option(--limit, -l, default10, help返回行数) click.pass_context def query(ctx, table, limit): 查询表数据 cfg ctx.obj[config] host cfg.get(db_host, localhost) click.echo(f查询 {host} 上的 {table}限制 {limit} 行) # 实际查询逻辑... db.command() click.pass_context def tables(ctx): 列出所有表 click.echo(users, orders, products)5.5 跑起来验证安装之后直接敲pip install -e . anycli --help anycli db --help anycli db query users --limit 5如果一切正常你会看到完整的帮助信息和执行结果。这套结构的好处是加新功能只需要写一个新的命令模块在 pyproject.toml 里加一行 entry_point其他什么都不用改。6. 那些文档里不会写的踩坑记录6.1 Windows 下的路径和编码问题Click 在 Windows 上跑的时候有几个坑我踩过不止一次。第一个是路径分隔符Path.home() / .anycli在 Windows 上会变成C:\Users\xxx\.anycli这本身没问题但如果你在配置文件里写了~/.anycli/config.yamlWindows 是不认~的。解决办法是用Path.expanduser()显式展开。第二个是控制台编码。Windows 的默认编码是 GBK如果你的命令输出里有中文或者特殊字符可能会报UnicodeEncodeError。Click 的echo函数在大部分情况下能处理但如果你直接用print就容易翻车。我的习惯是全程用click.echo并且在入口处设置import sys import io if sys.platform win32: sys.stdout io.TextIOWrapper(sys.stdout.buffer, encodingutf-8) sys.stderr io.TextIOWrapper(sys.stderr.buffer, encodingutf-8)6.2 参数命名冲突的隐蔽陷阱Click 有个不太直观的行为参数名会自动把连字符转成下划线。你定义--log-level函数里的参数名是log_level。这本身是合理的但如果你同时定义了--log-level和--log_levelClick 不会报错而是后者覆盖前者排查起来很费劲。还有一个坑是短选项冲突。Click 不会自动检查短选项是否重复如果你在父命令定义了-v表示 verbose子命令又定义了-v表示 version行为会变得很奇怪。我的做法是全局短选项只用-vverbose和-hhelpClick 自带其他短选项都留给子命令。6.3 插件加载失败的静默处理用 entry_points 加载插件的时候如果某个插件包损坏或者依赖缺失ep.load()会抛异常。如果你不捕获整个 CLI 都启动不了。但如果你捕获了却什么都不做用户又不知道插件没加载上。我的处理方式是捕获异常记录到日志并且在--verbose模式下打印警告。这样正常使用不受影响排查问题的时候又能看到信息。import logging logger logging.getLogger(__name__) def load_plugins(cli_group, verboseFalse): for ep in entry_points(groupanycli.plugins): try: register ep.load() register(cli_group) except Exception as e: logger.warning(f插件 {ep.name} 加载失败: {e}) if verbose: click.echo(f警告: 插件 {ep.name} 加载失败: {e}, errTrue)6.4 Agent 调用 CLI 的超时与输出截断前面讲了 Agent 调用 CLI 的思路但实际跑起来还有两个问题。第一是超时有些命令跑起来没完没了Agent 不能一直等。我的做法是给每个命令设一个默认超时比如 30 秒超时就 kill 掉返回一个错误信息给 Agent。第二是输出截断。有些命令的输出可能有几万行全塞给模型会爆 token。我的做法是只取前 N 行和后 N 行中间用省略号代替并且在返回结果里标注总行数。这样模型既能看到关键信息又不会超限。def truncate_output(text, head50, tail20): lines text.splitlines() if len(lines) head tail: return text return \n.join( lines[:head] [f... 省略 {len(lines) - head - tail} 行 ...] lines[-tail:] )7. 关于 CLI 与 Agent 结合的一些个人判断做了一段时间 CLI 和 Agent 的结合实验之后我有一个越来越强烈的感受未来 Agent 的工具层大概率会是 CLI 和 API 并存但 CLI 的比重会上升。原因很简单——API 需要你为每个工具写适配层而 CLI 本身就是适配层。一个设计良好的 CLI它的帮助信息、参数校验、错误码全都是现成的工具描述。但这不意味着 CLI 可以随便写。恰恰相反要被 Agent 调用的 CLI对设计质量的要求更高。因为人用 CLI 的时候遇到报错会看帮助、会试错、会凭经验猜Agent 不会它只会根据你给的描述去拼命令。所以如果你的 CLI 帮助信息写得含糊参数命名不一致错误信息不明确Agent 的调用成功率会非常低。我的建议是如果你现在正在做 Agent 开发并且需要接入一批工具先花时间把这些工具统一成一个 CLI-Hub。用 Click 定义清晰的命令树用 entry_points 做插件发现用配置文件管理全局状态用--help生成工具描述。这套东西搭好之后不管是给人用还是给 Agent 用都会顺畅很多。至于 CLI-Anything 这个名字我觉得它更像是一个方向而不是一个具体项目。它代表的是这样一种理念命令行不应该是一堆孤立脚本的集合而应该是一个有组织、可扩展、能被程序调用的能力平台。这个理念在 Agent 时代会越来越重要因为 Agent 需要调用的工具只会越来越多没有一套好的组织方式迟早会乱成一锅粥。最后分享一个我最近在用的技巧给 CLI-Hub 加一个--json全局选项所有命令的输出都支持 JSON 格式。人用的时候看表格Agent 用的时候读 JSON。实现方式是在根 group 里设置ctx.obj[json] json_flag然后在每个命令的输出处判断一下。这个改动不大但对接 Agent 的时候省了太多解析文本的麻烦。