---
title: "Docs Agent"
description: "本文档供 AI 阅读，不是可执行 skill 配置。相关工具脚本为 `docs-rev.sh`（入口）与 `docs-rev.py`（实现），二者放在同一目录下即可，脚本自身不再依赖任何工作区绝对路径。"
---

文档站唯一真源是线上 https://blog.miomoe.cn/docs。 工作区内没有本地 `Docs/` 检出，不要重建本地文档树，也不要用本地旧副本当依据。所有读写一律走 Agent API。

---

## 鉴权

- Base URL：`https://api.miomoe.cn`
- 每个请求带 `X-API-Key: <key>`（也接受 `Authorization: Bearer <key>`）
- 凭据读取顺序：
  1. 系统环境变量 `MIOMOE_AGENT_BASE_URL` / `MIOMOE_AGENT_KEY`
  2. `--env` 指定的 `.env` 文件
  3. 当前目录下的 `.env`
- 不要硬编码 key，不要把 key 写进本文件、记忆文件或提交内容。
- 开工先自检：`GET /agent/me` → 看 `group`、`permissions`。提交修订需要 `docs.edit`，新建页需要 `docs.create`。没有对应权限就直接告知用户，不要反复重试。

`.env` 需包含：

```
MIOMOE_AGENT_BASE_URL=https://api.miomoe.cn
MIOMOE_AGENT_KEY=<key>
```

---

## 接口

| 方法 | 路径 | 用途 |
|---|---|---|
| GET | `/agent/me` | 账号、组与权限自检 |
| GET | `/agent/docs` | 目录配置 + 全部文档摘要（约 277 页 / 17 分区） |
| GET | `/agent/docs/page?path=<path>` | 标题 / 描述 / markdown 正文（JSON，`data.content`） |
| GET | `/agent/docs/raw?path=<path>` | 直出 `text/plain` markdown，含 frontmatter |
| POST | `/agent/docs` | **新建页面**（需 `docs.create`），同路径的 GET 是拿目录 |
| POST | `/agent/docs/revisions` | 提交修订（需 `docs.edit`，进待审队列） |
| GET | `/agent/docs/revisions` | 修订列表，支持 `status` / `page` 分页 |
| GET | `/agent/docs/revisions/:uuid` | 修订详情（仅本人） |

`path` 是去掉分区前缀后的相对路径，例如 `xbase/version`、`xbase/data-layout`、`index`。

---

## 返回结构

`/agent/docs` → `data.metas[]`（分区：`path/title/root/icon/pages`）与 `data.pages[]`（`uuid/path/section/title/description/sort_order`）。先拉它拿准确 path，别猜。

`/agent/docs/page` → `data.{uuid,path,section,title,description,sort_order,created_at,updated_at,content}`，`content` 是带 frontmatter 的完整 markdown。

`/agent/docs/raw` → 纯文本 markdown，适合直接读/做 diff。

`/agent/docs/revisions` → `data.items[]` + `data.pagination`。修订对象字段：`uuid, doc_uuid, path, title, description, content, status, note, review_note, editor_uuid, editor_name, reviewer_uuid, reviewer_name, created_at, reviewed_at, applied_at`。

---

## 提交修订

`POST /agent/docs/revisions`，JSON body 字段与修订对象对齐：

```json
{
  "path": "xbase/version",
  "title": "Version",
  "description": "游戏版本检测与版本名称。",
  "content": "---\ntitle: Version\n...",
  "note": "改写原因（可选）"
}
```

要点：

- 修订进待审队列，不会立刻上线。提交后用 `GET /agent/docs/revisions` 或详情端点确认 `status`。
- 修订列表按 `editor_uuid` 过滤，只能看到自己提交的条目。
- `content` 必须是完整正文（含 frontmatter），不是增量 diff。正规做法：先 `raw` 拉原文 → 在此基础上改 → 整体回传。
- 权限判定与网页端共用 `hasPermission` / `rolesForGroup`；403 就是权限不够，别绕。
- 提交前拿用户的明确许可，这属于对外写操作。

---

## 工具：docs-rev

优先用工具，不要手工拼 curl + 字符串替换。

```bash
D="./docs-rev.sh"

"$D" me                                  # 账号与权限自检
"$D" list --section xbase                # 分区 + 页面（--paths 只输出 path）
"$D" show xbase/version                  # 打印原始 markdown
"$D" pull xbase/version -o v.md          # 拉到本地编辑（保留原始行尾）
"$D" push v.md --path xbase/version --dry-run   # 只看 diff，不提交
"$D" push v.md --path xbase/version --yes       # 确认后提交
"$D" replace xbase/version --old-file a.txt --new-file b.txt --count 1 --yes
"$D" create xbase/new-page --file n.md --title "标题" --yes   # 新建页面
"$D" status --status pending             # 修订列表
"$D" status <uuid>                       # 单条详情
```

### 脚本组成

- `docs-rev.sh`：Bash 入口。负责关闭 MSYS 路径转换、探测 Python 解释器、转发到 `docs-rev.py`。
- `docs-rev.py`：Python 实现。所有 HTTP、diff、行尾处理、提交流程都在这里。

### Python 解释器选择

`docs-rev.sh` 按以下顺序探测：

1. 环境变量 `PYTHON`
2. `python3`
3. `python`

三者都找不到时报错退出。不写死任何绝对路径。

### 凭据加载

`docs-rev.py` 按以下顺序读取：

1. 环境变量 `MIOMOE_AGENT_BASE_URL` / `MIOMOE_AGENT_KEY`
2. `--env` 指定的 `.env`
3. 当前目录 `.env`

如果三处都没有有效凭据，直接报错退出。

### 新建页面

- `create` 会先确认 path 不存在，存在就提示改用 `push`。
- 正文开头的 H1 会被去掉，页面标题来自 `--title` 或文件 frontmatter。
- 建页走 `POST /agent/docs`（返回 201）。`POST /agent/docs/revisions` 对不存在的 path 返回 404「文档不存在」，它只能修订已有文档——别拿它建页。
- `create` 内置候选端点依次尝试，`--probe` 打印每次结果，`--endpoint` 指定路由。端点有问题就扩展工具，不要另写临时脚本。

### 行为约定

- 自动处理 CRLF：拉原文 → 归一化成 LF → 改 → 按线上原本的行尾风格回写，不会因行尾翻转产生假 diff。
- 内容与线上一致、或替换命中 0 次时直接拒绝提交，不会制造空修订。
- `--count N` 可断言命中次数，与预期不符就中止。
- 提交前打印 unified diff；非交互环境必须显式 `--yes`，否则只给提示不提交。
- `--path` 省略时按文件名推断（`xbase_version.md` → `xbase/version`）。

### 无工具时的兜底

```bash
set -a && . ./.env && set +a
curl -s -H "X-API-Key: $MIOMOE_AGENT_KEY" \
  "$MIOMOE_AGENT_BASE_URL/agent/docs/raw?path=xbase/version"
```

Windows Git Bash 下不要把输出写到 `/tmp`（Python 侧找不到），直接管道给 Python 处理。

---

## 已知坑

- Git Bash 会把 `/agent/docs` 这类参数转成 Windows 路径（MSYS 路径转换），`docs-rev.sh` 里已 `export MSYS_NO_PATHCONV=1`；代价是脚本自身路径要用 `pwd -W` 取，否则会变成 `/c/...`。

- **行尾不统一，别假设全局 CRLF**：实测 `xbase/shared-runtime` 是 CRLF、`xbase/index` 是 LF。pull 下来先按字节数出该页行尾（`data.count(b"\r\n")` 与裸 `\n` 对比），改写后按该页原本行尾回写，否则整文件都会变成假 diff。另注意 `curl | python -c` 经 stdin 会被 universal newlines 转成 `\n`，诊断时极易看错；多行替换落空往往就是行尾不一致（"看起来一模一样却匹配不上"）。

- **受限沙箱会挡掉 `docs-rev.sh`**：运行时报 `sandbox-center cmd decisionRecord missing actual resource subject`，禁用沙箱同样被挡。不要反复重试或换 shell 绕圈，直接用兜底链路：`curl` 拉 `raw` → Python 改写 → `POST /agent/docs/revisions`，凭据照常从环境变量或 `.env` 读。

- `POST /agent/docs/revisions` 实测可用 body：`{path, title, description, content, note}` → `201`，`data.status = "pending"`。一次只覆盖一页，多页就提交多次。替换用 `str.replace` 并断言命中次数，别静默跳过。

- `/agent/docs` 的 `metas[]` 用 **`path`** 作分区标识（如 `xbase`），`root` 只是 0/1 层级标记，拿 `root` 当分区名会匹配不上；`pages[]` 的分区字段叫 **`section`**。

- 旧域名 `gtadev.miomoe.cn` 会 301 跳到 `blog.miomoe.cn`，写链接时统一用新域名。

- `sort_order` 有跳号（如 xbase 缺 100 / 250 / 300），新增页挑一个空位，不要重排已有页。

- 修改 XBase 代码后如果公共 API 变了，同步改 `xbase/*` 对应页面；正文里的方法名与签名必须和 `include/XBase/*.h` 一致，禁止臆造。

```bash
#!/usr/bin/env bash
# docs-rev — blog.miomoe.cn/docs 修订工具入口（Git Bash）
set -e

# Git Bash 会把 /agent/docs 这类参数转成 Windows 路径，必须关闭 MSYS 路径转换
export MSYS_NO_PATHCONV=1

# 选择可用的 Python 解释器：PYTHON 环境变量 > python3 > python
if [ -n "${PYTHON:-}" ]; then
    PY="$PYTHON"
elif command -v python3 >/dev/null 2>&1; then
    PY=python3
elif command -v python >/dev/null 2>&1; then
    PY=python
else
    echo "错误: 未找到 Python 解释器，请安装 python3 或设置 PYTHON 环境变量" >&2
    exit 1
fi

# 脚本所在目录。Windows 下用 pwd -W 取盘符路径，非 MSYS 环境回退到 pwd
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -W 2>/dev/null || pwd)"

exec "$PY" "$HERE/docs-rev.py" "$@"
```

```python
#!/usr/bin/env python3
"""blog.miomoe.cn/docs 修订工具

读凭据、拉原文、本地改、看 diff、提交修订，全流程一条命令
修订提交到待审队列，不会直接上线。
"""

import argparse
import difflib
import json
import os
import sys
import urllib.error
import urllib.request

try:
    sys.stdout.reconfigure(encoding="utf-8")
    sys.stderr.reconfigure(encoding="utf-8")
except Exception:
    pass


# ── 系统常量 ─────────────────────────────────────────────

# 凭据环境变量名
ENV_BASE_URL = "MIOMOE_AGENT_BASE_URL"
ENV_API_KEY = "MIOMOE_AGENT_KEY"

# 本地凭据文件名
ENV_FILE = ".env"

# 修订提交端点
REVISION_ENDPOINT = "/agent/docs/revisions"

# 建页端点候选（Agent API 未明确，探测时依次尝试）
CREATE_ENDPOINT_CANDIDATES = (
    "/agent/docs",
    "/agent/docs/revisions",
)

# 请求超时（秒）
REQUEST_TIMEOUT = 45

# 分页默认值
DEFAULT_PAGE = 1


# ── 基础工具 ─────────────────────────────────────────────

def fail(message):
    sys.exit("错误: " + message)


def normalize(text):
    return text.replace("\r\n", "\n")


def denormalize(text, crlf):
    """按线上原本的行尾风格重建，避免整篇行尾翻转产生假 diff。"""
    return text.replace("\n", "\r\n") if crlf else text


def read_text(path):
    with open(path, encoding="utf-8", newline="") as handle:
        return handle.read()


def write_text(path, text):
    directory = os.path.dirname(path)
    if directory:
        os.makedirs(directory, exist_ok=True)
    with open(path, "w", encoding="utf-8", newline="") as handle:
        handle.write(text)


# ── HTTP 客户端 ──────────────────────────────────────────

class Client:
    def __init__(self, base, key):
        self.base = base.rstrip("/")
        self.key = key

    def _open(self, request):
        try:
            with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
                return json.loads(response.read().decode("utf-8"))
        except urllib.error.HTTPError as error:
            body = error.read().decode("utf-8", "replace")
            fail("HTTP %s %s\n%s" % (error.code, request.full_url, body[:600]))
        except urllib.error.URLError as error:
            fail("网络失败 %s (%s)" % (request.full_url, error.reason))

    def _request(self, path, method="GET", body=None):
        headers = {"X-API-Key": self.key}
        data = None
        if body is not None:
            headers["Content-Type"] = "application/json"
            data = json.dumps(body).encode("utf-8")
        return urllib.request.Request(
            self.base + path, data=data, method=method, headers=headers
        )

    def _get(self, path):
        return self._open(self._request(path))

    def _get_text(self, path):
        request = self._request(path)
        try:
            with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
                return response.read().decode("utf-8")
        except urllib.error.HTTPError as error:
            fail("HTTP %s %s" % (error.code, request.full_url))

    def me(self):
        return self._get("/agent/me")["data"]

    def docs(self):
        return self._get("/agent/docs")["data"]

    def raw(self, path):
        return self._get_text("/agent/docs/raw?path=" + path)

    def page(self, path):
        return self._get("/agent/docs/page?path=" + path)["data"]

    def try_raw(self, path):
        """页面不存在时返回 None，用来区分「新建」与「改已有」。"""
        request = self._request("/agent/docs/raw?path=" + path)
        try:
            with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
                return response.read().decode("utf-8")
        except urllib.error.HTTPError as error:
            if error.code == 404:
                return None
            fail("HTTP %s %s" % (error.code, request.full_url))
        except urllib.error.URLError as error:
            fail("网络失败 %s (%s)" % (request.full_url, error.reason))

    def revisions(self, status=None, page=DEFAULT_PAGE):
        query = "?page=%d" % page
        if status:
            query += "&status=" + status
        return self._get("/agent/docs/revisions" + query)["data"]

    def revision(self, uuid):
        return self._get("/agent/docs/revisions/" + uuid)["data"]

    def submit(self, path, title, description, content, note, endpoint=None):
        target = endpoint or REVISION_ENDPOINT
        if not target.startswith("/"):
            target = "/" + target
        # Git Bash 可能把 /agent/... 转成 Windows 路径，这里显式拦下来
        if "://" in target or target.startswith("C:"):
            fail("端点不是合法的相对路径: %s" % target)
        request = self._request(
            target,
            method="POST",
            body={
                "path": path,
                "title": title,
                "description": description,
                "content": content,
                "note": note or "",
            },
        )
        return self._open(request)


# ── 凭据加载 ─────────────────────────────────────────────

def load_env(explicit=None):
    """凭据优先级：系统环境变量 > --env 指定文件 > 当前目录 .env。"""
    base = os.environ.get(ENV_BASE_URL)
    key = os.environ.get(ENV_API_KEY)
    if base and key:
        return {ENV_BASE_URL: base, ENV_API_KEY: key}

    candidates = [explicit, os.path.join(os.getcwd(), ENV_FILE)]
    for candidate in candidates:
        if not candidate or not os.path.isfile(candidate):
            continue
        env = {}
        with open(candidate, encoding="utf-8") as handle:
            for line in handle:
                line = line.strip()
                if not line or line.startswith("#") or "=" not in line:
                    continue
                field, value = line.split("=", 1)
                env[field.strip()] = value.strip()
        if env.get(ENV_BASE_URL) and env.get(ENV_API_KEY):
            return env

    fail(
        "找不到凭据：请设置环境变量 %s / %s，或用 --env 指定 .env 文件"
        % (ENV_BASE_URL, ENV_API_KEY)
    )


# ── diff 与提交流程 ──────────────────────────────────────

def show_diff(before, after, path):
    lines = list(difflib.unified_diff(
        normalize(before).splitlines(True),
        normalize(after).splitlines(True),
        fromfile="online:" + path,
        tofile="new:" + path,
        n=2,
    ))
    if not lines:
        return False
    sys.stdout.write("".join(lines))
    return True


def confirm_or_exit(args, path):
    if args.yes:
        return
    if not sys.stdin.isatty():
        fail("非交互环境，确认 diff 无误后加 --yes 提交（%s）" % path)


def publish(client, path, content, note, args, description=None):
    online = client.raw(path)
    crlf = "\r\n" in online
    content = denormalize(normalize(content), crlf)

    if normalize(content) == normalize(online):
        fail("内容与线上一致，不提交（%s）" % path)

    if not show_diff(online, content, path):
        fail("归一化后无差异，不提交（%s）" % path)

    if getattr(args, "dry_run", False):
        print("[dry-run] 未提交（%s）" % path)
        return

    confirm_or_exit(args, path)
    meta = client.page(path)
    result = client.submit(
        path,
        meta["title"],
        description or meta["description"],
        content,
        note,
    )
    data = result.get("data") or {}
    print("已提交 %s -> %s | uuid=%s | status=%s"
          % (path, result.get("code"), data.get("uuid"), data.get("status")))


# ── 子命令 ───────────────────────────────────────────────

def cmd_me(client, args):
    data = client.me()
    print("账号:   %s (%s)" % (data.get("name"), data.get("display_name")))
    print("组:     %s" % data.get("group"))
    print("权限:   %s" % ", ".join(data.get("permissions") or []))


def cmd_list(client, args):
    data = client.docs()
    for meta in data["metas"]:
        # metas 用 path 作分区标识；root 只是 0/1 层级标记，不是分区名
        marker = "*" if (args.section and meta.get("path") == args.section) else " "
        print("%s %-16s %-22s %d 页"
              % (marker, meta.get("path"), meta.get("title"), len(meta.get("pages") or [])))
    print()
    for page in data["pages"]:
        if args.section and page.get("section") != args.section:
            continue
        if args.paths:
            print(page["path"])
            continue
        print("%-6s %-42s %s" % (page.get("sort_order"), page["path"], page["title"]))


def cmd_show(client, args):
    sys.stdout.write(client.raw(args.path))


def cmd_pull(client, args):
    text = client.raw(args.path)
    target = args.out or os.path.join(".workbuddy", "tmp", args.path.replace("/", "_") + ".md")
    write_text(target, text)
    print("已拉取 %s -> %s（%d 字节，行尾 %s）"
          % (args.path, target, len(text), "CRLF" if "\r\n" in text else "LF"))


def cmd_push(client, args):
    if not os.path.isfile(args.file):
        fail("文件不存在: %s" % args.file)
    path = args.path
    if not path:
        base = os.path.basename(args.file)
        path = base[:-3] if base.endswith(".md") else base
        path = path.replace("_", "/", 1)
        print("未指定 --path，按文件名推断为 %s" % path)
    publish(client, path, read_text(args.file), args.note, args, args.description)


def cmd_replace(client, args):
    if args.old_file:
        old = read_text(args.old_file)
    elif args.old is not None:
        old = args.old
    else:
        fail("需要 --old 或 --old-file")

    if args.new_file:
        new = read_text(args.new_file)
    elif args.new is not None:
        new = args.new
    else:
        fail("需要 --new 或 --new-file")

    text = client.raw(args.path)
    hits = normalize(text).count(normalize(old))
    if hits == 0:
        fail("未找到目标片段，替换数为 0（%s）— 注意行尾是 CRLF，用 --old-file 可避免转义问题" % args.path)
    if args.count and hits != args.count:
        fail("命中 %d 次，与 --count %d 不符（%s）" % (hits, args.count, args.path))

    updated = normalize(text).replace(normalize(old), normalize(new))
    print("命中 %d 处" % hits)
    publish(client, args.path, updated, args.note, args, args.description)


def split_frontmatter(text):
    meta = {}
    body = text
    if text.startswith("---"):
        end = text.find("\n---", 3)
        if end != -1:
            for line in text[3:end].splitlines():
                if ":" in line:
                    key, value = line.split(":", 1)
                    meta[key.strip()] = value.strip().strip('"')
            body = text[end + 4:].lstrip("\r\n")
    return meta, body


def cmd_create(client, args):
    if client.try_raw(args.path) is not None:
        fail("页面已存在，改用 push 提交修订（%s）" % args.path)

    content = read_text(args.file)
    meta, body = split_frontmatter(content)
    title = args.title or meta.get("title")
    description = args.description or meta.get("description")
    if not title:
        fail("缺少标题，用 --title 指定或在文件里写 frontmatter")

    # 正文开头的 H1 与 frontmatter 标题重复，线上页面用 frontmatter 作为标题
    lines = body.splitlines()
    if lines and lines[0].startswith("# "):
        body = "\n".join(lines[1:]).lstrip("\r\n")

    content = "---\ntitle: %s\ndescription: %s\n---\n\n%s" % (title, description or "", body)
    content = denormalize(normalize(content), True)

    if getattr(args, "dry_run", False):
        print(content)
        print("[dry-run] 未提交（%s）" % args.path)
        return

    print("新建 %s · %d 字节" % (args.path, len(content)))

    endpoints = [args.endpoint] if args.endpoint else list(CREATE_ENDPOINT_CANDIDATES)
    if args.probe and not args.endpoint:
        print("探测建页端点，共 %d 个候选" % len(endpoints))

    for endpoint in endpoints:
        try:
            result = client.submit(
                args.path, title, description or "", content, args.note, endpoint
            )
        except SystemExit as error:
            print("  %-28s 失败 %s" % (endpoint, error))
            continue
        data = result.get("data") or {}
        print("  %-28s 成功" % endpoint)
        print("已提交 %s -> %s | uuid=%s | status=%s"
              % (args.path, result.get("code"), data.get("uuid"), data.get("status")))
        return

    fail("所有候选端点都不可用，用 --endpoint 指定正确的建页路由")


def cmd_status(client, args):
    if args.uuid:
        data = client.revision(args.uuid)
        for key in ("uuid", "path", "title", "status", "note",
                    "review_note", "created_at", "reviewed_at"):
            print("%-12s %s" % (key, data.get(key)))
        return

    data = client.revisions(args.status, args.page)
    for item in data["items"]:
        print("%-10s %-38s %-24s %s"
              % (item.get("status"), item.get("uuid"),
                 item.get("path"), item.get("title")))
    pagination = data.get("pagination") or {}
    print("第 %s/%s 页，共 %s 条"
          % (pagination.get("current_page"),
             pagination.get("last_page"),
             pagination.get("total")))


# ── 命令行入口 ───────────────────────────────────────────

def build_parser():
    parser = argparse.ArgumentParser(description="blog.miomoe.cn/docs 修订工具")
    parser.add_argument("--env", help="指定 .env 路径")
    parser.add_argument("--yes", action="store_true", help="跳过交互确认直接提交")
    sub = parser.add_subparsers(dest="command", required=True)

    sub.add_parser("me", help="账号与权限自检").set_defaults(func=cmd_me)

    list_parser = sub.add_parser("list", help="列出分区与页面")
    list_parser.add_argument("--section", help="只看某个分区")
    list_parser.add_argument("--paths", action="store_true", help="只输出 path")
    list_parser.set_defaults(func=cmd_list)

    show_parser = sub.add_parser("show", help="打印页面原始 markdown")
    show_parser.add_argument("path")
    show_parser.set_defaults(func=cmd_show)

    pull_parser = sub.add_parser("pull", help="拉取页面到本地文件")
    pull_parser.add_argument("path")
    pull_parser.add_argument("-o", "--out", help="输出文件")
    pull_parser.set_defaults(func=cmd_pull)

    push_parser = sub.add_parser("push", help="用本地文件提交修订")
    push_parser.add_argument("file")
    push_parser.add_argument("--path", help="目标 path，缺省按文件名推断")
    push_parser.add_argument("--note", help="修订说明")
    push_parser.add_argument("--description", help="同时更新页面描述")
    push_parser.add_argument("--dry-run", action="store_true", help="只看 diff 不提交")
    push_parser.add_argument("--yes", dest="yes_sub", action="store_true", help="跳过确认直接提交")
    push_parser.set_defaults(func=cmd_push)

    replace_parser = sub.add_parser("replace", help="对线上原文做一次文本替换并提交")
    replace_parser.add_argument("path")
    replace_parser.add_argument("--old")
    replace_parser.add_argument("--new")
    replace_parser.add_argument("--old-file", help="从文件读被替换片段，避免命令行转义")
    replace_parser.add_argument("--new-file", help="从文件读替换后的片段")
    replace_parser.add_argument("--count", type=int, help="期望命中次数，不符则拒绝提交")
    replace_parser.add_argument("--note", help="修订说明")
    replace_parser.add_argument("--description")
    replace_parser.add_argument("--dry-run", action="store_true", help="只看 diff 不提交")
    replace_parser.add_argument("--yes", dest="yes_sub", action="store_true", help="跳过确认直接提交")
    replace_parser.set_defaults(func=cmd_replace)

    create_parser = sub.add_parser("create", help="新建页面并作为修订提交")
    create_parser.add_argument("path")
    create_parser.add_argument("--file", required=True, help="本地 markdown 文件")
    create_parser.add_argument("--title", help="页面标题，缺省取文件 frontmatter")
    create_parser.add_argument("--description", help="页面描述，缺省取文件 frontmatter")
    create_parser.add_argument("--note", help="修订说明")
    create_parser.add_argument("--endpoint", help="指定建页路由，缺省依次探测内置候选")
    create_parser.add_argument("--probe", action="store_true", help="打印每个候选端点的尝试结果")
    create_parser.add_argument("--dry-run", action="store_true", help="只看将要提交的内容")
    create_parser.add_argument("--yes", dest="yes_sub", action="store_true", help="跳过确认直接提交")
    create_parser.set_defaults(func=cmd_create)

    status_parser = sub.add_parser("status", help="查看修订列表或单条详情")
    status_parser.add_argument("uuid", nargs="?", help="修订 uuid")
    status_parser.add_argument("--status", help="按状态过滤 pending/approved/rejected")
    status_parser.add_argument("--page", type=int, default=DEFAULT_PAGE)
    status_parser.set_defaults(func=cmd_status)

    return parser


def main():
    parser = build_parser()
    args = parser.parse_args()
    if getattr(args, "yes_sub", False):
        args.yes = True
    env = load_env(args.env)
    client = Client(env[ENV_BASE_URL], env[ENV_API_KEY])
    args.func(client, args)


if __name__ == "__main__":
    main()
```