大多数开发者工具的起点都一样:你需要反复做某件事,于是写了个脚本。脚本长出了参数,参数又长出了子命令。不知不觉间,你就拥有了一个 CLI。
我们在构建一个内部平台工具、帮助解决方案团队更快交付时,经历的正是这一过程。它能搭建项目脚手架、启动开发环境、生成配置,并部署到预发布环境。
随着 CLI 的功能范围不断扩大,内部用户的数量也在增长。但接着发生了一件有意思的事:突然之间,我们构建的对象不再只是人类开发者,还有编码智能体。
好的开发者体验是什么样的
在谈智能体之前,先来聊聊什么样的 CLI 才真正让人类开发者用着舒服。
Spaces 在那些枯燥的事情上很有主见。它帮你选好合理的目录结构,你不必自己操心;它生成你原本要从上一个项目里复制的配置文件;它把各个服务连接起来,让你的 API 和前端从第一天起就能互通,而不是经历一小时的 YAML 编辑或“凭感觉瞎试(vibing)”。
一次典型的使用过程如下:
$ spaces init my-project
$ cd my-project
$ spaces dev
三条命令。你就从一无所有走到了一个正在运行的多服务项目,带有热重载、数据库和自动生成的 Dockerfile。这就是我们的标准。
真正重要的命令往往可以归为三类:
- 脚手架 -- 这类命令创建结构、提出问题、展示选项,让你自由探索。
- 开发 -- 这是你的内循环。它们只管“运行”。
- 运维 -- 这类命令会触及生产环境。要谨慎,行动前再三确认
这些都是基本门槛。但随后我们的第二类用户出现了,我们发现需要一些略有不同、也有趣得多的东西。
第二类用户:智能体
我们为 init 构建了一个 TUI 模块选择器。界面花哨,命令齐全,看上去很棒。
然后一个智能体尝试使用它。
智能体看到的是原始的 ANSI 转义码。\\\\x1b[36m?\\\\x1b[0m Select components。它无法发送方向键,也无法切换选项。这条命令对它而言完全不可用。
事后看来,解决办法似乎很显然:加一个 --components 参数。但真正的洞见远不止一个参数。
每个提示都是伪装的参数
每当你的 CLI 提出一个交互式问题,背后都有一个隐含的约定:“我需要这条信息才能继续。”要履行这个约定,你可以用交互式提示,也可以用参数或配置文件。
诀窍在于先考虑信息,再考虑输入方式。
def init_command(
components: str | None = Option(None),
yes: bool = Option(False, "-y"),
):
if components:
selected = components.split(",")
elif yes:
selected = get_defaults()
else:
selected = show_picker()
# Same logic from here
create_project(selected)
三条输入路径,一条执行路径。业务逻辑并不知道输入是如何到达的。这意味着你只需测试一次,而不是三次。
-y 参数值得特别关注。它不只是“跳过确认”,而是一份约定,意思是:我会以编程方式提供你需要的一切,不要阻塞在标准输入上。当你带着 -y 运行时,每个提示都会解析为某个参数值或一个合理的默认值。如果做不到,就明确地报错,而不是卡住不动。
实际案例:这篇文章里的交互式元素。
这些元素是在一个全新的仓库里构建的。我们事后让一个智能体把该项目接入 [spaces cli] 以便部署。它先对相关命令运行 --help,了解接口;据此弄清了需要哪些配置文件,生成了 config.yaml,并连接好了 Dockerfile 和镜像仓库设置,全程无需手把手指导。
同一轮操作中,它还搭建了 GitHub Actions CI 流水线。从一条提示到上线部署,耗时不到 10 分钟(我们正在努力缩短这一周期)。此后,仓库已完整配置好,这些嵌入内容通过 Koyeb 部署上线——作为一个 Space,由 Spaces 自身完成脚手架搭建和部署。没错,这篇介绍 Spaces 的博文里嵌入的交互式演示,本身就运行在一个 Space 上。Spaception(Space 套娃)。
由于每个交互式输入都有对应的参数,智能体得以端到端自主操作。
把结构化数据作为接口
我们团队构建的这个 CLI 帮助应用 AI 工程师交付应用。但并非每个应用都长一个样。有些需要后端和向量数据库,有些只需要关系型数据库、前端和一些 API,还有少数是完全没有 UI 的纯 worker 服务。
我们不打算把模块类型写死。于是我们构建了一套插件系统,每个组件都是一个插件,并声明自己的属性:
class ModulePlugin(BaseModel):
type_id: str
category: str
default_port: int
def get_env_vars(self) -> list[EnvVarDef]: ...
def get_dev_command(self, port: int) -> str: ...
插件是可内省的。你可以列出它们、序列化它们、对比它们的差异。人类浏览 TUI 选择器;智能体查询注册表,得到 JSON。数据相同,呈现方式不同。
这无意间解决了一个我们此前并不知道存在的问题。以前,新增一种模块类型意味着要更新选择器、Dockerfile 生成器、环境文件写入器和 compose 模板。现在只需编写一个插件类。注册表是唯一的事实来源,其他一切都从中读取。
让智能体了解你的项目
有句老话说,内容为王。对智能体而言,上下文为王。
为提升智能体可用性,我们做的最有效的一件事,就是在每次 init 时生成两个文件:
context.json -- 项目的结构化快照:有哪些模块、它们使用的端口、运行命令,以及所需的环境变量。
AGENTS.md -- 一组写给 LLM 的规则,比常规规则更具命令性。不是“这个项目使用 PostgreSQL”,而是“在测试数据库改动之前,先运行 mycli dev --migrate”。
智能体在行动前读取这些文件,出错会大幅减少。简单说,它不会瞎猜端口号、不会运行错误的测试命令,也不会去安装那些已由工具链管理的依赖。
上下文文件还起到清除智能体过时假设的作用。当你添加模块或更改部署目标时,上下文文件会在下一次 dev 或 init 时自动更新。智能体每次读到的都是最新状态。
隐式状态是敌人
我们遇到的最微妙的问题是隐式状态。我们的 add 命令会从当前工作目录读取 config.yaml。人类会不假思索地 cd 到正确的文件夹,而从工作区根目录运行命令的智能体并不知道自己需要进入某个子目录。
解决办法:
# Before: CWD dependency
config = load_config(Path.cwd() / "config.yaml")
# After: explicit with fallback
config = load_config(
path or find_config_in_parents(Path.cwd())
)
每一个隐藏的假设——CWD、环境变量、$HOME 中的点文件——都是智能体会栽跟头的地方。带有合理回退值的显式参数,既解决了智能体的问题,也让人类编写脚本更轻松。
检查清单
回头看,这些改动单独来看都不大,只是一组被一以贯之地应用的原则:
- 每个交互式输入都有对应的参数
- 每个参数在无头模式下都有合理的默认值
- 状态是显式的。CWD、环境变量和配置路径是输入,而不是假设
- 插件是数据模型,而不只是代码。默认可内省
- 上下文文件为智能体(以及 CI 和脚本)提供项目的结构化描述
为所有人打造更好的工具
有趣的是,这一切并没有让 CLI 对人类变差。TUI 选择器依然好用、依然好看,进度转轮照转,确认对话框照常确认。我们只是多开了一扇门。
而这第二扇门被证明更为重要。并不是因为智能体比人类更重要,而是因为它们施加的约束,恰恰就是让 CLI 可组合、可脚本化、可测试的约束。为智能体而设计,迫使我们为所有人打造了更好的工具。
如果你现在正在构建开发者工具,你并不需要单独的智能体 API。你需要审视每一次 input() 调用、每一个对 CWD 的假设、每一种只适合美化打印的输出,然后问自己:如果另一端的用户是进程而不是人,会怎样?
对这个问题的回答,无论如何都会改进你的工具。
Spaces CLI 由 Mistral AI 的 Lorenzo Signoretti、Riwa Hoteit & Sam Fenwick 构建。特别感谢我们的 Applied AI 团队,他们是这个 CLI 最早也最苛刻的用户,其真实使用塑造了这里描述的每一种模式。我们期待看到它帮助他们与客户一起构建应用,解决棘手的实际问题。
人类与智能体之间的工具层仍在探索之中。如果在 AI 与基础设施的交汇处构建开发者工具对你有吸引力,我们正在招人。
