# 15. CLI 设计规范

> **doc_type**: Standard
> **status**: Active
> **owner**: chentao.jia
> **last_verified**: 2026-05-14
> **关联**: [#331](http://43.130.59.228/FFAIWorkspace/workspace/issues/331)（首个落地工单：Gitea CLI）

## 目的

本仓库以 AI-first 工作流为主，CLI 工具的首要消费者是 **AI agent + CI workflow + 团队成员**，而不是只有人类。
后续项目会出现 ≥ 10 个内部 CLI（Gitea ops、deploy、备份、agent-pool 管理等），需要一份**轻规范**让所有 CLI 长得像同一个工具，让 AI 不用每次重新猜参数形态。

本规范综合 [clig.dev](https://clig.dev/) / [Heroku CLI Style Guide](https://devcenter.heroku.com/articles/cli-style-guide) / [12 Factor CLI Apps](https://medium.com/@jdxcode/12-factor-cli-apps-dd3c227a0e46) / GNU + POSIX 底层不变量 / 2026 AI-agent-first 实践（glab / Algolia / DeployHQ），取**五家一致部分**作为硬规则，**五家有分歧时偏向 AI-first 与本项目现有 idiom**。

## 适用范围

**强制适用**：
- 子命令数量 ≥ 2 的 CLI
- 预计会被 AI / CI / 多个团队成员重复调用的脚本

**不强制（可选遵守）**：
- 一次性脚本（如 migration 辅助、临时 dump）
- 构建脚本（`scripts/dev/*.sh` 中只跑一次的步骤）
- Git hook（受 hook 自身机制约束）

> 偏离规范时必须在脚本顶部 docstring 写一行"本脚本不遵循 15-cli-design-spec，原因：xxx"。

---

## 元规则 5 条（无条件遵守）

### R1：Human-first 默认，`--json` 机读
- 默认输出给人看（表格 / 缩进 / 适度上色）。
- 加 `--json` 切到纯 JSON 模式（机读 / pipe / AI 消费）。
- 不能预设 stdout 一定有 tty——AI 和 CI 跑时没有 tty。

### R2：stdout = 数据，stderr = 对话
- stdout **只能**写"该命令的数据输出"——能直接 pipe 给 jq / grep / awk。
- 错误、警告、进度、debug 信息**必须**走 stderr。
- 不要把进度条 / spinner 印到 stdout（会污染 pipe）。

### R3：noun-verb 命名结构
- 形式：`<tool> <topic> <verb> [args...]`
- topic 用**单数名词**（如 `gitea issue`，不是 `gitea issues`）——与本仓库 library 函数命名（`get_issue`、`add_label`）保持一致。
- verb 用动词（`list` / `get` / `add` / `remove` / `comment` / `create`）。
- 例：`gitea issue list --label X` ✅；`gitea list-issues --label X` ❌

### R4：长 flag 是规范，短 flag 是补充
- 所有选项**必须有**长 flag（kebab-case，如 `--label`、`--limit`、`--dry-run`）。
- 短 flag 只给最高频项；候选保留：`-h`（help）、`-v`（verbose）、`-n`（dry-run）、`-q`（quiet）。
- 反向开关用 `--no-*`（如 `--no-color`）。
- `--` 终止参数解析（POSIX 兼容，处理含 `-` 的参数值）。

### R5：所有 mutation 支持 `--dry-run`
- 任何写操作（POST / PUT / PATCH / DELETE / 改本地文件 / 改 git）必须支持 `--dry-run`。
- 批量 mutation（影响 ≥ 5 个对象）**必须**默认 dry-run，加 `--execute` 才真跑——延续 `scripts/ops/sweep-remote-stale.py` 已确立的 idiom。
- 单条 mutation（如 `gitea label add 331 X`）可以默认 execute，但 `--dry-run` 仍必给。

---

## 子命令命名

### topic
- **单数名词**：`issue` / `pr` / `label` / `branch` / `release`。
- **不允许**用复数（与 Heroku 主流惯例不同；本项目优先与现有 library 一致性）。

### verb
| 动作类型 | 推荐动词 | 反例 |
|---|---|---|
| 读单个 | `get` 或省略（`gitea issue 331`） | `show`、`view`、`fetch` |
| 读多个 | `list` | `ls`、`index`、`all` |
| 创建 | `create` | `new`、`make`、`add`（这个留给 label 关系） |
| 修改 | `update` 或具体动作（`comment`） | `edit`、`modify`、`set` |
| 删除 | `delete` | `remove`、`rm`、`destroy` |
| 关系类（label 加/移） | `add` / `remove` | `attach` / `detach` |

> 同一仓库的所有 CLI 用同一套动词；不要在 A 工具用 `delete` 在 B 工具用 `remove`。

---

## Flag 规范

### 通用 flag（约定保留名，不要复用作他义）

| 长 flag | 短 flag | 含义 |
|---|---|---|
| `--help` | `-h` | 显示帮助 |
| `--version` | — | 显示版本 |
| `--verbose` | `-v` | 详细日志（可叠 `-vv` debug） |
| `--quiet` | `-q` | 只输出错误 |
| `--json` | — | 机读 JSON 模式 |
| `--no-color` | — | 强制不上色 |
| `--dry-run` | `-n` | 不真执行，只打印计划 |
| `--execute` | — | 配合默认 dry-run 的脚本，显式确认真跑 |
| `--limit N` | — | 限制结果数量 |
| `--format FMT` | — | 输出格式备选（`table` / `json` / `tsv`） |

### Flag 命名规则
- kebab-case（`--issue-id`、`--no-color`，不是 `--issue_id` 或 `--noColor`）
- 布尔 flag 用动词 / 形容词（`--verbose`、`--dry-run`），不要 `--enable-xxx`
- 值参数用名词（`--label`、`--limit`、`--state`）
- 多值用重复 flag：`--label A --label B`（不要 `--labels A,B`，CSV 解析坑多）

### Positional argument 何时用
- **只用于"主对象 id"**，且是必填项。例：`gitea issue 331`、`gitea label add 331 Owner/X`。
- 数量 ≤ 2 个 positional；超过用 flag。
- token / secret / 配置项**绝不**用 positional（会进 shell history）。

---

## 退出码（4 档语义）

| 码 | 含义 | 触发场景 | AI 应如何反应 |
|---|---|---|---|
| **0** | 成功 | 正常完成 | 继续后续动作 |
| **1** | 用户错误 | 参数缺失 / 参数非法 / token 没设 / label 名不存在 | **不要重试**；检查参数后再调 |
| **2** | 系统/网络错误 | API 5xx / 超时 / DNS 失败 / 临时不可达 | **可重试**（指数退避，最多 3 次） |
| **3** | 约定拦截 | 自合被拒 / Status 多挂被拒 / 模板字段缺失 | **不要重试**；这是工具按设计拒绝的；改方案 |

> 本规范偏离主流的"0 / 非零"二档，是因为 AI agent 用退出码做 retry 决策比解析文本输出稳定得多。所有 CLI 必须严格按这 4 档发出码。

### 落地约束
- 程序内任何 `sys.exit(N)` 必须从这 4 个值里选。
- exit 码与日志级别独立：exit 0 也可能有 stderr 警告；exit 1 也可能有 stdout 部分结果（如批量操作部分成功）。

---

## 输出格式

### 人读模式（默认）
- stdout：表格 / 缩进 / 纯文本，可读
- stderr：错误一行，红字（tty + `NO_COLOR` 未设时）
- 上色规则：`NO_COLOR=1` 或 stdout 非 tty 时关闭所有 ANSI

### JSON 模式（`--json`）
- stdout：纯 JSON 对象或数组（数组适合 list 类）
- 无任何装饰（不要 `pretty print` 默认，加 `--pretty` 才美化）
- 单条不裹外层 envelope：`{"id": 331, ...}` 而不是 `{"data": {"id": 331, ...}}`
- list：直接是数组 `[{...}, {...}]`

### 错误输出（`--json` 模式下）
错误写 stderr，结构如下：
```json
{
  "code": "E_TOKEN_MISSING",
  "message": "GITEA_API_TOKEN not set",
  "hint": "export GITEA_API_TOKEN, see CLAUDE.md 'Gitea 平台配置' section",
  "doc_url": "http://43.130.59.228/FFAIWorkspace/workspace/src/branch/develop/CLAUDE.md#gitea-平台配置"
}
```
- `code`：`E_<UPPER_SNAKE>`，工具自己定义一套，全仓库不去重（每个工具自治）
- `message`：一行人话
- `hint`：可操作的下一步
- `doc_url`：可选，指向相关文档段

### 不允许的格式
- ❌ stdout 混进进度信息（污染 pipe）
- ❌ JSON 输出里夹注释或人读 banner
- ❌ 多行 JSON（每行一个对象）——除非显式 `--ndjson`

---

## 日志与进度

### 日志级别
- 默认：quiet，只输出结果 + 致命错误
- `-v`：info（外部 API 调用 / 关键决策）
- `-vv`：debug（请求 body / 响应原文 / 内部状态）
- **不允许** `-vvv` 及更多档

### 进度
- 长任务（> 2 秒）必须有进度反馈
- 反馈写 stderr，**不要用 spinner**（AI / CI / 非 tty 跑不动）
- 推荐格式：`[12/50] processing PR #331 ...`（每条一行，可被 grep）
- tty 模式下可以用 `\r` 覆盖同一行；非 tty 模式必须逐行追加

---

## 鉴权

### token 来源（优先级）
1. 命令行 env var（如 `GITEA_API_TOKEN`）
2. shell 启动文件已 export 的同名 env
3. **绝不**读 config 文件 / `~/.xxxrc` / git credential store 等持久化处

> 偏离 Heroku/glab 等主流做法。理由：本仓库是多人协作 + AI 自动化场景，token 落盘风险大；env 是显式的，AI 可以从 instructions 取，人可以 .bashrc export。

### 缺 token 时
- exit 1
- stderr 输出：`ERROR: <ENV_NAME> not set. Export it first; see <doc>.`
- `--json` 模式给上面的 JSON 错误结构

### token 处理铁律
- 不允许 token 出现在 stdout / stderr / 日志文件 / 错误堆栈
- 调试输出 `-vv` 时 token 必须 mask 成 `****<last4>`

---

## 实现技术栈（本项目约束）

### 语言与库
- **Python 3.11+** + `argparse`（标准库）
- **不**引 click / typer / fire 等第三方框架——避免依赖、保持 ops 脚本可移植
- 复用同目录 library（如 `scripts/ops/_gitea_api.py`），不重写 HTTP 层

### 项目结构
- 单文件 ≤ 500 行；超出拆 module 到同目录
- 入口文件命名：`scripts/ops/<tool-name>` （shell wrapper） 或 `<tool-name>.py`（直接执行）
- 库文件下划线前缀（`_gitea_api.py`），表明是 internal

### Shebang 与头部
```python
#!/usr/bin/env python3
"""<tool-name>: 一行说明用途。

子命令清单（一行一例）：
  <tool> issue 331
  <tool> issue list --label X --limit 10
  <tool> issue comment 331 --body "..."

Auth: GITEA_API_TOKEN（必填，详见 CLAUDE.md "Gitea 平台配置" 段）
退出码: 0 成功 / 1 用户错 / 2 系统错 / 3 约定拦截
"""
```

---

## 反模式清单（一条都不踩）

| ❌ 反模式 | 为什么不行 | 替代 |
|---|---|---|
| 交互式 prompt（`input()` / `getpass()`） | AI / CI 跑不动；卡死 | 缺参数→exit 1 提示用 flag |
| 把 token 当 positional arg | 进 shell history、`ps` 可见 | 用 env var |
| stdout 混进度信息 | 污染 pipe | 进度走 stderr |
| 隐式默认值变更不通知 | 破坏可预测性，老 script 一夜失效 | 改默认值必须升 major 版本 + 文档显眼标注 |
| 失败时静默 exit 0 | AI 误判成功；CI 误绿 | 任何失败必须非零码 |
| 引第三方 CLI 框架（click / typer） | 多一层依赖，构建/分发坑多 | argparse 够用 |
| ANSI 颜色不判断 tty | 非交互场景一堆乱码 | tty + `NO_COLOR` 未设才上色 |
| `--labels A,B,C` CSV 多值 | CSV 转义坑、含逗号的值崩 | 重复 flag `--label A --label B` |
| `--help` 只列 flag 不给例 | 学习曲线陡 | help 必含 ≥ 2 个使用例 |
| 复数 topic（`gitea issues list`） | 与本仓库 library 函数名不一致 | 单数（`gitea issue list`） |

---

## 测试与文档要求

### 测试
- 每个子命令 ≥ 1 条 L1 集成测试（mock token + 真调一次或 dry-run）
- 测试文件放 `testing/ops/<tool-name>.test.py`（与现有 ops 脚本测试共存）
- 退出码必须断言（不只断言 stdout）

### 顶部 docstring
按上面"Shebang 与头部"模板，必含 4 段：用途 / 子命令清单 / Auth / 退出码

### 同目录 README
任何 CLI 工具在同目录 `README.md` 必给：
- 一句话定位
- 安装/调用方式
- 常用命令 5 个
- 与 library 层的关系（"本 CLI 是 `_xxx_api.py` 的薄出口，业务逻辑在 library 层"）

### CLAUDE.md / AGENTS.md 更新
新 CLI 上线时必须在两份入口文档对应段加"AI 怎么调"一行示例。

---

## CLI 与 library 层的关系

```
┌──────────────────────────────────┐
│  CLI 层 (gitea / sweep / ...)    │  ← 薄出口：参数解析、输出格式、退出码
│  - argparse                       │
│  - 调用 library                   │
│  - 不写业务逻辑                   │
└──────────────────────────────────┘
              │
              ▼
┌──────────────────────────────────┐
│  Library 层 (_gitea_api.py 等)    │  ← 业务原语：HTTP / 鉴权 / 实体操作
│  - HTTP client                    │
│  - token 解析                     │
│  - 业务原语（add_label, ...）     │
└──────────────────────────────────┘
```

**严格分层**：
- 业务原语写 library；CLI 层不写业务（只组合 library 函数）
- library 必须可被 import 单独使用（如批处理脚本、未来的 MCP server）
- CLI 层做参数 → library 调用 → 输出格式化三件事，仅此而已

---

## 偏离主流的决策摘要

| 决策 | 主流做法 | 本规范 | 理由 |
|---|---|---|---|
| 退出码档位 | 0 / 非零 | 4 档（0/1/2/3） | AI retry 决策需要 |
| topic 单复数 | Heroku 用复数 | 单数 | 与本仓库 library 函数命名一致 |
| 交互式 prompt | clig.dev 鼓励 | 禁止 | AI-first：agent 跑不动 prompt |
| token 存储 | Heroku/glab 支持 config 文件 | 只用 env var | 多人 + AI 场景下落盘风险 |
| CLI 框架 | click / typer / cobra 流行 | 只允许 argparse | 减少依赖，保持可移植 |
| 错误输出 | 通常只是人读文本 | `--json` 模式下结构化 | AI 解析需要 |

---

## 关联文档

- [`13-pr-description-spec.md`](./13-pr-description-spec.md)：PR description 规范（CLI 工具的发布说明应遵循）
- [`12-five-meta-rules.md`](./12-five-meta-rules.md)：元根因 5（监控告警→工具执行）的工具化体现
- [`scripts/ops/_gitea_api.py`](../../scripts/ops/_gitea_api.py)：library 层范式
- [clig.dev](https://clig.dev/) / [Heroku CLI Style Guide](https://devcenter.heroku.com/articles/cli-style-guide) / [12 Factor CLI Apps](https://medium.com/@jdxcode/12-factor-cli-apps-dd3c227a0e46)：原始来源
- 首个落地工单：[#331](http://43.130.59.228/FFAIWorkspace/workspace/issues/331)（Gitea CLI）

---

## 变更记录

| 日期 | 版本 | 改动 | 作者 |
|---|---|---|---|
| 2026-05-14 | v1.0 | 初稿（综合 5 份外部标准 + 项目现有 idiom） | chentao.jia |
