Jelajahi Sumber

feat: WikiPali 写入型 Skill(P1 客户端全部落地)

.claude/skills/wikipali-write/,只用 Python 标准库,可整目录复制分发。

- wp_login.py 是唯一接触密码的脚本,密码只经 getpass 进内存,落盘的
  只有 JWT(~/.wikipali/credentials.json,0600);
- wp.py:endpoint / whoami / ensure-model / revoke / channels / grant /
  write。比设计多出 whoami 与 revoke——前者是排查 401 的第一步,后者
  是 §2.3b 撤销端点的入口,安全能力做了就该有人能用;
- 三处「HTTP 200 不等于成功」的坑都按服务端实际行为处理:access-token
  无权返回 count: 0 当作 403 中止;sentence 逐句静默跳过,按返回的
  book/channel.uid 字段逐条比对并报告漏写;缺 sentences 时的 no date;
- 模型 token 被撤销或过期时自动重签一次再重试,仍失败才要求重新登录;
- 线上四站按「同版本另一域名 → 另一版本同域名」fallback 且出声提示,
  绝不落到 127.0.0.1(那是另一个库)。

验证走桩服务(复刻服务端的响应形状与上述各坑)跑完整流程,桩本身不
进仓库——它编码的是「我以为服务端是这样」,留着会变成第二份契约来源。
真实站点的端到端写入尚未验证:四个线上地址都还没部署 P0(POST
/api/v2/ai-model-token/x 返回 404 而非 405,路由不存在)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda 1 Minggu lalu
induk
melakukan
ba1f907a3a

+ 104 - 0
.claude/skills/wikipali-write/SKILL.md

@@ -0,0 +1,104 @@
+---
+name: wikipali-write
+description: "Use this skill to write sentences (translations, commentary) into the WikiPali sentence database over its HTTP API, from any project. Trigger whenever the user asks to upload, push, publish, sync, or save translated Pali sentences to WikiPali / 巴利文 / wikipali.org, or mentions writing to a WikiPali channel, or asks about wp.py / wp_login.py / ~/.wikipali/credentials.json. Handles login, AI-model identity tokens, channel selection, access tokens, and batched writes with attribution as the AI model rather than the human operator. Do not use for reading WikiPali data or for unrelated Laravel/API work."
+metadata:
+  author: mint
+---
+
+# WikiPali 写入
+
+把句子写进 WikiPali 句子库,**署名为 AI 模型身份**(`editor_uid` = 模型 uid),而不是操作者本人。
+
+两个脚本,只依赖 Python 标准库,直接跑,不要建虚拟环境:
+
+- `scripts/wp_login.py` —— 唯一接触密码的脚本,**必须由用户本人执行**
+- `scripts/wp.py` —— 其余全部操作
+
+下面的命令都假设 cwd 在本 Skill 目录(`.claude/skills/wikipali-write/`)。
+
+## 铁律
+
+1. **永远不要向用户索要密码,也不要代跑 `wp_login.py`。** 需要登录时,请用户自己在 Claude Code 里执行:
+   `! python3 .claude/skills/wikipali-write/scripts/wp_login.py`
+2. **写入前必须让用户确认。** `wp.py write` 默认会回显目标并等确认;只有用户已经明确同意本次写入时,才可以加 `-y`。
+3. **绝不打印 token 全文**(`~/.wikipali/credentials.json` 里的任何值)。脚本自己会打码,不要 `cat` 那个文件。
+4. **`count` 不等于提交条数就是有句子没写进去**,必须如实报告给用户,不要说「已全部写入」。
+5. **收到 401 不要自动重试**,按脚本的提示走。
+
+## 首次准备
+
+```bash
+python3 scripts/wp.py whoami          # 先看缺什么
+```
+
+按缺什么补什么:
+
+```bash
+# 1) 登录(用户自己跑,带 ! 前缀)
+! python3 .claude/skills/wikipali-write/scripts/wp_login.py
+
+# 2) 建立模型身份并取 token;--name 必须是你自己的模型标识
+python3 scripts/wp.py ensure-model --name claude-opus-5
+
+# 3) 看有哪些可写的 channel
+python3 scripts/wp.py channels
+```
+
+`--name` 决定句子的作者署名,**不要冒用别的模型的名字**。同名记录已存在时会直接复用(幂等)。
+
+## 写入
+
+输入是一个 JSON 文件,两种形状都接受:
+
+```json
+{
+  "channel_uid": "可选,整批共用的 channel",
+  "sentences": [
+    { "book_id": 1, "paragraph": 10, "word_start": 0, "word_end": 12,
+      "content": "译文", "content_type": "markdown" }
+  ]
+}
+```
+
+或直接是句子数组(此时用 `--channel` 指定目标)。`content_type` 可省略,默认 `markdown`;`channel_uid` 可以逐句给,用于跨 channel 批量写。
+
+```bash
+python3 scripts/wp.py write sentences.json --channel <uid或名字片段> --dry-run   # 先看回显
+python3 scripts/wp.py write sentences.json --channel <uid或名字片段>            # 再真写
+```
+
+`write` 会自动完成:解析校验 → 确定 channel → 回显确认 → 按需签发/复用 access token → 每 50 条一批提交 → 核对 `count` 并报告漏写的句子。
+
+**写入是覆盖式的**:相同 `(book_id, paragraph, word_start, word_end, channel_uid)` 的已有句子会被替换。回显里那行警告要转达给用户。
+
+## 站点
+
+四个线上地址共享同一个数据库和密钥,凭据通用;`www` 是稳定版、`next` 是最新版代码,**不是**不同的数据环境。
+
+```bash
+python3 scripts/wp.py endpoint            # 列出并标出当前
+python3 scripts/wp.py endpoint next       # 改默认(唯一会写回凭据的方式)
+python3 scripts/wp.py --api next write …  # 只影响这一次调用
+```
+
+新端点在稳定版上返回 404 是「代码版本还没到」,不是「资源不存在」。
+
+## 出问题时
+
+| 现象 | 处置 |
+|---|---|
+| 401 | 用户 token 失效 → 请用户重跑 `wp_login.py`;模型 token 失效或被撤销 → 重跑 `ensure-model` |
+| 403 | 不是 channel 的 owner/协作者,或不是模型 owner。指出缺哪项权限,别换个姿势重试 |
+| `count: 0`(签 access token) | 对该 channel 无编辑权。**中止写入**,不要继续 |
+| `count` 小于提交条数 | 逐条差集已由脚本列出,如实转达 |
+| 404(`ai-model-token` 等新端点) | 提示切到 `next` 或稍后再试 |
+
+凭据泄漏时撤销模型的全部 token:
+
+```bash
+python3 scripts/wp.py revoke
+```
+
+## 更多
+
+端点字段、返回形状与各处陷阱见 `references/api.md`。若脚本行为与该文件对不上,多半是这份 Skill 副本过期了(比对 `VERSION`),请重新安装。

+ 1 - 0
.claude/skills/wikipali-write/VERSION

@@ -0,0 +1 @@
+2026.08.05

+ 75 - 0
.claude/skills/wikipali-write/install.sh

@@ -0,0 +1,75 @@
+#!/bin/sh
+# 把本 Skill 整目录复制到目标项目或用户级 skills 目录。
+#
+#   ./install.sh ~/work/other-project   # 项目级,只在该项目激活
+#   ./install.sh --user                 # 用户级,所有项目可用
+#   ./install.sh --user --force         # 覆盖已存在的旧副本
+#
+# 只复制本目录自身,不碰仓库里的任何其他文件。副本不会自动更新——
+# API 契约一改,旧副本就静默过期,靠 VERSION 判断是否该重装。
+
+set -eu
+
+SRC=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+NAME=$(basename -- "$SRC")
+FORCE=0
+TARGET=""
+
+usage() {
+    sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'
+    exit "${1:-1}"
+}
+
+while [ $# -gt 0 ]; do
+    case "$1" in
+        --user) TARGET="$HOME/.claude/skills" ;;
+        --force|-f) FORCE=1 ;;
+        -h|--help) usage 0 ;;
+        -*) echo "未知参数:$1" >&2; usage ;;
+        *)
+            if [ ! -d "$1" ]; then
+                echo "错误:目标目录不存在:$1" >&2
+                exit 1
+            fi
+            TARGET=$(CDPATH= cd -- "$1" && pwd)/.claude/skills
+            ;;
+    esac
+    shift
+done
+
+if [ -z "$TARGET" ]; then
+    echo "错误:请给出目标项目目录,或用 --user 装到用户级。" >&2
+    usage
+fi
+
+DEST="$TARGET/$NAME"
+VERSION=$(cat "$SRC/VERSION")
+
+if [ "$DEST" = "$SRC" ]; then
+    echo "错误:源和目标是同一个目录。" >&2
+    exit 1
+fi
+
+if [ -d "$DEST" ]; then
+    OLD="(无 VERSION 文件)"
+    [ -f "$DEST/VERSION" ] && OLD=$(cat "$DEST/VERSION")
+    if [ "$FORCE" -ne 1 ]; then
+        echo "目标已存在:$DEST"
+        echo "  已装版本:$OLD"
+        echo "  本次版本:$VERSION"
+        echo "要覆盖请加 --force。"
+        exit 1
+    fi
+    echo "覆盖 $OLD → $VERSION"
+    rm -rf "$DEST"
+fi
+
+mkdir -p "$TARGET"
+cp -R "$SRC" "$DEST"
+rm -rf "$DEST/__pycache__" "$DEST/scripts/__pycache__"
+chmod +x "$DEST/scripts/wp.py" "$DEST/scripts/wp_login.py" "$DEST/install.sh"
+
+echo "已安装 $NAME $VERSION 到 $DEST"
+echo
+echo "下一步(凭据在 ~/.wikipali/,多个副本共用,通常不必重新登录):"
+echo "  python3 $DEST/scripts/wp.py whoami"

+ 158 - 0
.claude/skills/wikipali-write/references/api.md

@@ -0,0 +1,158 @@
+# WikiPali API 参考(写入路径)
+
+本文只记 Skill 用到的端点。契约以**稳定版**(`www.*`)为准;标注「最新版」的能力可能在稳定版上还是 404。
+
+基址记为 `{API}`,形如 `https://www.wikipali.org/api`。所有响应统一形如:
+
+```json
+{ "ok": true, "data": <任意>, "message": "" }
+```
+
+`ok: false` 时 `message` 是原因,HTTP 状态码同时表达语义。**HTTP 200 不等于全部成功**——见文末「静默跳过」。
+
+## 三种 token,职责不能混
+
+| Token | 从哪来 | 代表谁 | 用在哪 | 有效期 |
+|---|---|---|---|---|
+| userToken | `POST /v2/sign-in` | 人类操作者 | 查/建 ai-model、签 access token、列 channel | 365 天 |
+| modelToken | `GET /v2/ai-model-token/{uid}` | AI 模型身份 | **写句子时的 `Authorization`** | 30 天,可撤销 |
+| accessToken | `POST /v2/access-token` | 被委托的 channel 编辑权 | **写句子时的 body 字段** | 7 天 |
+
+写句子时两者同时出现:`Authorization: Bearer <modelToken>`,句子对象里带 `access_token: <accessToken>`。用错会让 `editor_uid` 落成人类用户,署名与审计就废了。
+
+---
+
+## 1. 登录
+
+`POST {API}/v2/sign-in` body `{ "username": "<用户名或邮箱>", "password": "<明文>" }`
+
+- `data` 是 JWT 字符串本身(不是对象)。
+- 失败返回 **HTTP 400**,`message` 是 `invalid token`——措辞误导,实际含义是用户名或密码不对。
+
+`GET {API}/v2/auth/current`(Bearer = userToken)
+
+- `data: { id, nickName, realName, avatar, token, roles }`
+- **`realName` 就是后面 `studio_name` 参数要用的值**,不是 `nickName`。
+
+## 2. AI Model
+
+`GET {API}/v2/ai-model?view=studio&name={studio_name}&keyword={模型名}`(Bearer = userToken)
+
+- `data: { rows: [...], count }`;`keyword` 是 `like %kw%` **模糊**匹配,客户端必须自己做 `name` 精确比对。
+- `view` 只接受 `all` / `studio` / `usable` / `chat`。**传别的值会 500**(服务端 switch 缺 default 分支,已知缺陷)。
+- 行里的 `key` / `system_prompt` 只在请求者是 owner 本人时才返回。
+
+`POST {API}/v2/ai-model`(Bearer = userToken) body `{name, studio_name, model?, url?, key?, privacy?, description?, system_prompt?}`
+
+- 一次 POST 即可带全字段,不需要再 PUT 补。
+- 同一 studio 内 `name` 重复 → **409**,客户端据此判定「已存在」。
+- `name` / `studio_name` 缺失 → 422。
+- 鉴权要求 `studio_name` 就是操作者本人的 studio(个人 studio),group studio 一律 403。
+
+`PUT {API}/v2/ai-model/{uid}`(Bearer = userToken)
+
+- **增量更新**:只改请求里出现的字段,未提交的保持原值;显式传 `null` 才会清空。
+- 改名撞上同 studio 内已有名字 → 409。
+
+## 3. 模型身份 token
+
+`GET {API}/v2/ai-model-token/{uid}`(Bearer = userToken)→ `data: { uid, name, token }`
+
+- 只有模型 owner 本人能调;未登录 401,非 owner 403,模型不存在 404。
+- 签出的 token 有效期 30 天,payload 带 `typ: "ai-model"` 与 `ver`。
+
+`DELETE {API}/v2/ai-model-token/{uid}`(Bearer = userToken)→ `data: { uid, name, token_version }`
+
+- 语义是**作废该模型已签出的全部 token**,无法只废一张。撤销后旧凭据一律 401。
+- 这两个端点较新,稳定版站点上可能还没有 → 404 时先怀疑「站点代码版本旧」,而不是「模型不存在」。
+
+## 4. 可编辑 channel 列表
+
+`GET {API}/v2/channel?view=user-edit`(Bearer = userToken)→ `data: { rows: [...], count }`
+
+- 语义:owner 是自己的 channel ∪ 协作权限 power ≥ 20 的 channel。
+- 行内含 `uid / name / summary / type / owner_uid / lang / status / updated_at / created_at / role / studio`。
+- 支持 `order` / `dir` / `limit`(默认 200)/ `offset` / `search`。
+
+`GET {API}/v2/channel/{uid}` 可用于回显单个 channel 的名字。
+
+## 5. 签发 channel access token
+
+`POST {API}/v2/access-token`(Bearer = userToken)
+
+```json
+{ "payload": [ { "res_type": "channel", "res_id": "<channel uid>", "power": "edit", "book": 0 } ] }
+```
+
+→ `data: { rows: [ { payload, token } ], count }`
+
+- **无权时该条被静默跳过**,返回 `count: 0` 和空 rows,HTTP 仍是 200。必须判空,等同 403 处理,**不可继续写入**。
+- 返回的 `payload` 里含 `nbf` / `exp`,据此判断何时重签。
+- ⚠ **`book` 必须是整数**。服务端校验用 `$jwt->book !== $book` 严格比较,而 `$book` 已被转成 int,写成 `"1"` 会让 `"1" !== 1` 恒真而永远鉴权失败。`0` 表示不限 book。
+
+## 6. 写入句子
+
+`POST {API}/v2/sentence`(Bearer = **modelToken**)
+
+```json
+{
+  "sentences": [
+    {
+      "book_id": 1,
+      "paragraph": 10,
+      "word_start": 0,
+      "word_end": 12,
+      "channel_uid": "<channel uid>",
+      "content": "译文",
+      "content_type": "markdown",
+      "access_token": "<第 5 步签出的 JWT>"
+    }
+  ]
+}
+```
+
+→ `data: { rows: [ ... ], count }`
+
+- 语义:按 `(book_id, paragraph, word_start, word_end, channel_uid)` 做 `firstOrNew`——**存在即覆盖,不存在则新建**。天然幂等,但也意味着会静默覆盖别人写的同位置句子,写前必须向用户确认。
+- 副作用:写 `sent_history`(可追溯)、清缓存、发进度消息。
+- 返回的 rows 用的是另一套字段名:`book`(不是 `book_id`)、`paragraph`、`word_start`、`word_end`、`channel.uid`、`editor`。核对写入结果要按这套字段匹配。
+- 缺 `sentences` 字段时返回 **HTTP 200** 且 `message: "no date"`——这是客户端 bug,不是成功。
+
+### 静默跳过
+
+`store()` 对**逐句**鉴权失败是 `continue` 掉的,不报错。所以:
+
+> 提交 N 条、HTTP 200、`count` 却小于 N,意味着有句子没写进去。
+
+必须把返回的 rows 与提交的句子逐条比对,把差集报给用户。
+
+---
+
+## 错误约定
+
+| 现象 | 含义 | 处置 |
+|---|---|---|
+| 401 | token 失效/过期/被撤销 | 提示重新登录或重取模型 token,**不要自动重试** |
+| 403 | 无 channel 编辑权,或不是模型 owner | 指出缺哪一项权限 |
+| 404(较新端点) | 站点跑的是旧版代码 | 提示切到最新版站点,别当成「资源不存在」 |
+| 409 | 同 studio 内模型重名 | 当作「已存在」,回查列表取 uid |
+| 422 | 参数校验失败 | 看 message |
+| `access-token` 返回 `count: 0` | 对该 channel 无编辑权(静默跳过) | 当作 403,**中止写入** |
+| `sentence` 的 `count` < 提交条数 | 部分句子鉴权失败被跳过 | 逐条比对并报告 |
+| `message: "no date"` + 200 | 请求缺 `sentences` | 客户端 bug |
+
+## 站点
+
+四个线上地址**共享同一个数据库和同一把 JWT 密钥**,凭据完全通用,可随时切换:
+
+| 地址 | 地区 | 代码版本 |
+|---|---|---|
+| `https://www.wikipali.org/api` | .org | 稳定版 |
+| `https://www.wikipali.cc/api` | .cc | 稳定版 |
+| `https://next.wikipali.org/api` | .org | 最新版 |
+| `https://next.wikipali.cc/api` | .cc | 最新版 |
+| `http://127.0.0.1:8000/api` | 开发机 | 另一个库、另一把密钥 |
+
+- `.org` / `.cc` 是地区可达性;`www` / `next` 是**代码版本,不是数据环境**。
+- 线上四个之间可以自动 fallback(同一套数据),**但绝不能自动退到 127.0.0.1**。
+- 真正的风险不是写错库(写不错),而是写到不同版本的代码上,所以写入前要回显当前 api_url。

+ 997 - 0
.claude/skills/wikipali-write/scripts/wp.py

@@ -0,0 +1,997 @@
+#!/usr/bin/env python3
+"""WikiPali 写入客户端。
+
+以「AI 模型身份」把句子写入 WikiPali 句子库。子命令:
+
+    endpoint     查看 / 切换 API 地址
+    whoami       显示当前凭据状态
+    ensure-model 幂等地建立模型记录并取模型身份 token
+    revoke       撤销该模型已签出的全部 token
+    channels     列出当前账号可编辑的 channel
+    grant        为某个 channel 签发 access token 并缓存
+    write        写入句子(分批 + 确认 + count 核对)
+
+约束:只用 Python 标准库,目录自包含,可整体复制到任意项目。
+密码只由 wp_login.py 接触,本脚本永不读密码。
+"""
+
+import argparse
+import base64
+import json
+import os
+import stat
+import sys
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from datetime import datetime, timezone
+
+# ---------------------------------------------------------------------------
+# 站点清单
+# ---------------------------------------------------------------------------
+# 四个线上地址共享同一个数据库和同一把 jwt 密钥,凭据完全通用;
+# .org / .cc 是地区可达性,www / next 是代码版本(不是数据环境)。
+# 开发机是另一个库、另一把密钥,故单独一桶,且永不作为自动 fallback 目标。
+
+SITES = [
+    {"key": "www", "url": "https://www.wikipali.org/api", "version": "稳定版", "domain": ".org"},
+    {"key": "www.cc", "url": "https://www.wikipali.cc/api", "version": "稳定版", "domain": ".cc"},
+    {"key": "next", "url": "https://next.wikipali.org/api", "version": "最新版", "domain": ".org"},
+    {"key": "next.cc", "url": "https://next.wikipali.cc/api", "version": "最新版", "domain": ".cc"},
+    {"key": "local", "url": "http://127.0.0.1:8000/api", "version": "开发机", "domain": "本机"},
+]
+
+ONLINE_URLS = [s["url"] for s in SITES if s["key"] != "local"]
+LOCAL_URL = SITES[-1]["url"]
+DEFAULT_API_URL = SITES[0]["url"]
+
+CREDS_DIR = os.path.join(os.path.expanduser("~"), ".wikipali")
+CREDS_PATH = os.path.join(CREDS_DIR, "credentials.json")
+
+# access token 剩余不足这么多秒就重新签发,避免写到一半过期
+TOKEN_REFRESH_MARGIN = 3600
+
+DEFAULT_TIMEOUT = 30
+WRITE_TIMEOUT = 120
+DEFAULT_BATCH = 50
+
+
+# ---------------------------------------------------------------------------
+# 错误类型
+# ---------------------------------------------------------------------------
+
+
+class WpError(Exception):
+    """面向用户的错误:main() 捕获后只打印 message,不打印堆栈。"""
+
+
+class ApiError(WpError):
+    def __init__(self, status, message, url=None, body=None):
+        self.status = status
+        self.url = url
+        self.body = body
+        super().__init__(message)
+
+
+# ---------------------------------------------------------------------------
+# 凭据文件
+# ---------------------------------------------------------------------------
+
+
+def load_creds():
+    if not os.path.exists(CREDS_PATH):
+        return {"current": "online"}
+    try:
+        with open(CREDS_PATH, "r", encoding="utf-8") as fh:
+            data = json.load(fh)
+    except (OSError, ValueError) as exc:
+        raise WpError(f"凭据文件无法读取({CREDS_PATH}):{exc}")
+    if not isinstance(data, dict):
+        raise WpError(f"凭据文件格式不对({CREDS_PATH}),应为 JSON 对象")
+    data.setdefault("current", "online")
+    return data
+
+
+def save_creds(creds):
+    os.makedirs(CREDS_DIR, mode=0o700, exist_ok=True)
+    tmp = CREDS_PATH + ".tmp"
+    flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
+    fd = os.open(tmp, flags, 0o600)
+    try:
+        with os.fdopen(fd, "w", encoding="utf-8") as fh:
+            json.dump(creds, fh, ensure_ascii=False, indent=2)
+            fh.write("\n")
+    except Exception:
+        os.unlink(tmp)
+        raise
+    os.replace(tmp, CREDS_PATH)
+    os.chmod(CREDS_PATH, stat.S_IRUSR | stat.S_IWUSR)
+
+
+def bucket_name_for(api_url):
+    """凭据桶名。线上四地址共用 online 桶;开发机 local;其余地址自成一桶。"""
+    if api_url in ONLINE_URLS:
+        return "online"
+    if api_url == LOCAL_URL:
+        return "local"
+    return "site:" + api_url
+
+
+def get_bucket(creds, name, api_url=None):
+    bucket = creds.setdefault(name, {})
+    bucket.setdefault("api_url", api_url or (DEFAULT_API_URL if name == "online" else LOCAL_URL))
+    bucket.setdefault("user", {})
+    bucket.setdefault("model", {})
+    bucket.setdefault("access_tokens", {})
+    return bucket
+
+
+def normalize_api_url(url):
+    url = url.rstrip("/")
+    parsed = urllib.parse.urlparse(url)
+    if parsed.scheme not in ("http", "https"):
+        raise WpError(f"API 地址必须以 http:// 或 https:// 开头:{url}")
+    host = (parsed.hostname or "").lower()
+    if parsed.scheme == "http" and host not in ("127.0.0.1", "localhost", "::1"):
+        raise WpError(f"只有 127.0.0.1 / localhost 允许用 http://,其余必须 https://:{url}")
+    return url
+
+
+def resolve_api_url(cli_api, creds):
+    """地址来源优先级:--api > 环境变量 > 凭据文件 > 内置默认。
+
+    前两者是一次性覆盖,不写回凭据文件——否则「上周试了一次 next」会一直粘着。
+    """
+    if cli_api:
+        return normalize_api_url(expand_site_alias(cli_api)), "cli"
+    env = os.environ.get("WIKIPALI_API_URL")
+    if env:
+        return normalize_api_url(expand_site_alias(env)), "env"
+    current = creds.get("current", "online")
+    bucket = creds.get(current)
+    if isinstance(bucket, dict) and bucket.get("api_url"):
+        return normalize_api_url(bucket["api_url"]), "creds"
+    return DEFAULT_API_URL, "default"
+
+
+def expand_site_alias(value):
+    """把序号 / 简称展开成完整 url;已是 url 则原样返回。"""
+    value = value.strip()
+    if value.isdigit():
+        idx = int(value) - 1
+        if 0 <= idx < len(SITES):
+            return SITES[idx]["url"]
+        raise WpError(f"站点序号超出范围:{value}(可选 1-{len(SITES)})")
+    for site in SITES:
+        if value == site["key"]:
+            return site["url"]
+    if "://" in value:
+        return value
+    raise WpError(
+        f"无法识别的站点:{value}。可用简称:" + " / ".join(s["key"] for s in SITES) + ",或直接给完整 url"
+    )
+
+
+def site_label(api_url):
+    for site in SITES:
+        if site["url"] == api_url:
+            return f"{site['version']} · {site['domain']}"
+    return "自定义地址"
+
+
+# ---------------------------------------------------------------------------
+# HTTP
+# ---------------------------------------------------------------------------
+
+
+def mask(token):
+    if not token:
+        return "(无)"
+    if len(token) <= 16:
+        return token[:4] + "…"
+    return token[:8] + "…" + token[-4:]
+
+
+def jwt_payload(token):
+    """不验签地读出 JWT payload,仅用于显示有效期。"""
+    try:
+        part = token.split(".")[1]
+        part += "=" * (-len(part) % 4)
+        return json.loads(base64.urlsafe_b64decode(part.encode("ascii")))
+    except Exception:
+        return {}
+
+
+def token_expiry(token):
+    exp = jwt_payload(token).get("exp")
+    return int(exp) if isinstance(exp, (int, float)) else None
+
+
+def fmt_ts(ts):
+    if not ts:
+        return "未知"
+    return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M")
+
+
+def note(msg):
+    print(msg, file=sys.stderr)
+
+
+def http_json(api_url, method, path, token=None, body=None, query=None, timeout=DEFAULT_TIMEOUT):
+    """发一个 JSON 请求,返回解析后的响应体(dict)。
+
+    网络层失败抛 urllib 的异常(由 Client 决定是否 fallback);
+    HTTP 层失败抛 ApiError,带上服务端 message。
+    """
+    url = api_url + "/" + path.lstrip("/")
+    if query:
+        url += "?" + urllib.parse.urlencode({k: v for k, v in query.items() if v is not None})
+    data = None
+    headers = {"Accept": "application/json", "User-Agent": "wikipali-write-skill"}
+    if body is not None:
+        data = json.dumps(body, ensure_ascii=False).encode("utf-8")
+        headers["Content-Type"] = "application/json"
+    if token:
+        headers["Authorization"] = "Bearer " + token
+    req = urllib.request.Request(url, data=data, headers=headers, method=method)
+    try:
+        with urllib.request.urlopen(req, timeout=timeout) as resp:
+            raw = resp.read().decode("utf-8", "replace")
+            status = resp.status
+    except urllib.error.HTTPError as exc:
+        raw = exc.read().decode("utf-8", "replace")
+        status = exc.code
+        payload = safe_json(raw)
+        message = payload.get("message") if isinstance(payload, dict) else None
+        raise ApiError(status, message or f"HTTP {status}", url=url, body=payload or raw)
+    payload = safe_json(raw)
+    if not isinstance(payload, dict):
+        raise ApiError(status, f"响应不是 JSON:{raw[:200]}", url=url, body=raw)
+    if not payload.get("ok", False):
+        raise ApiError(status, payload.get("message") or "请求失败", url=url, body=payload)
+    return payload.get("data")
+
+
+def safe_json(raw):
+    try:
+        return json.loads(raw)
+    except ValueError:
+        return None
+
+
+class Client:
+    """按站点收发请求,并在线上地址之间做出声的 fallback。"""
+
+    def __init__(self, api_url, source, creds, allow_fallback=True):
+        self.api_url = api_url
+        self.source = source
+        self.creds = creds
+        self.bucket_name = bucket_name_for(api_url)
+        self.bucket = get_bucket(creds, self.bucket_name, api_url)
+        self.allow_fallback = allow_fallback and api_url in ONLINE_URLS
+
+    # -- 凭据 ---------------------------------------------------------------
+
+    @property
+    def user_token(self):
+        token = (self.bucket.get("user") or {}).get("token")
+        if not token:
+            raise WpError(
+                "尚未登录。请自己执行(Claude Code 里用 ! 前缀):\n"
+                "    ! python3 " + os.path.join(os.path.dirname(os.path.abspath(__file__)), "wp_login.py")
+            )
+        return token
+
+    @property
+    def model(self):
+        model = self.bucket.get("model") or {}
+        if not model.get("token"):
+            raise WpError("尚未取得模型身份 token。请先跑:python3 wp.py ensure-model --name <模型名>")
+        return model
+
+    def save(self):
+        save_creds(self.creds)
+
+    # -- 请求 ---------------------------------------------------------------
+
+    def fallback_order(self):
+        """同版本的另一域名 → 另一版本的同域名 → 其余。绝不含 local。"""
+        cur = next((s for s in SITES if s["url"] == self.api_url), None)
+        if not cur:
+            return []
+        others = [s for s in SITES if s["key"] != "local" and s["url"] != self.api_url]
+        others.sort(
+            key=lambda s: (
+                0 if s["version"] == cur["version"] else 1,
+                0 if s["domain"] == cur["domain"] else 1,
+            )
+        )
+        return [s["url"] for s in others]
+
+    def call(self, method, path, token=None, body=None, query=None, timeout=DEFAULT_TIMEOUT):
+        urls = [self.api_url] + (self.fallback_order() if self.allow_fallback else [])
+        last = None
+        for idx, url in enumerate(urls):
+            try:
+                data = http_json(url, method, path, token=token, body=body, query=query, timeout=timeout)
+            except (urllib.error.URLError, TimeoutError, OSError) as exc:
+                # 仅网络层不可达才换站点;HTTP 错误是服务端的明确答复,不该被掩盖
+                last = exc
+                reason = getattr(exc, "reason", exc)
+                if idx + 1 < len(urls):
+                    note(f"⚠ {url} 连接失败({reason}),改用 {urls[idx + 1]}")
+                continue
+            if url != self.api_url:
+                # fallback 成功后本次会话都用它,但不写回凭据文件
+                note(f"⚠ 本次请求实际发往 {url}({site_label(url)})")
+                self.api_url = url
+            return data
+        raise WpError(f"所有可用站点都连不上,最后一次错误:{last}")
+
+    def api_note(self):
+        src = {"cli": "--api", "env": "环境变量", "creds": "凭据文件", "default": "内置默认"}[self.source]
+        return f"{self.api_url}({site_label(self.api_url)},来源:{src})"
+
+
+def make_client(args, allow_fallback=True):
+    creds = load_creds()
+    api_url, source = resolve_api_url(getattr(args, "api", None), creds)
+    return Client(api_url, source, creds, allow_fallback=allow_fallback)
+
+
+def explain_api_error(exc, what):
+    """把 HTTP 状态翻译成对操作者有意义的话(见 references/api.md 的错误约定)。"""
+    if exc.status == 401:
+        return WpError(
+            f"{what}:401 凭据失效或已被撤销。\n"
+            "  · 用户 token 失效 → 重新登录:! python3 scripts/wp_login.py\n"
+            "  · 模型 token 失效或被撤销 → 重跑:python3 wp.py ensure-model\n"
+            "  不要自动重试。"
+        )
+    if exc.status == 403:
+        return WpError(f"{what}:403 无权限(不是 channel 的 owner/协作者,或不是模型 owner 本人)。")
+    if exc.status == 404:
+        return WpError(
+            f"{what}:404。若这是较新的端点,可能是当前站点跑的是稳定版代码、端点尚未上线;\n"
+            "  可切到最新版试试:python3 wp.py endpoint next\n"
+            "  否则才是资源真的不存在。"
+        )
+    if exc.status == 409:
+        return WpError(f"{what}:409 同名记录已存在。")
+    if exc.status == 422:
+        return WpError(f"{what}:422 参数校验失败——{exc}")
+    return WpError(f"{what}:HTTP {exc.status} {exc}")
+
+
+# ---------------------------------------------------------------------------
+# 子命令:endpoint
+# ---------------------------------------------------------------------------
+
+
+def cmd_endpoint(args):
+    creds = load_creds()
+    current_url, source = resolve_api_url(getattr(args, "api", None), creds)
+
+    if not args.target:
+        for idx, site in enumerate(SITES, 1):
+            mark = "  ← 当前" if site["url"] == current_url else ""
+            print(f"  {idx}) {site['url']:<32} {site['version']} · {site['domain']}{mark}")
+        if source in ("cli", "env"):
+            src = "--api" if source == "cli" else "WIKIPALI_API_URL"
+            note(f"注意:当前地址来自 {src},是一次性覆盖,未写入凭据文件。")
+        if current_url not in [s["url"] for s in SITES]:
+            print(f"  *) {current_url:<32} 自定义地址  ← 当前")
+        print("\n切换:python3 wp.py endpoint <序号|www|www.cc|next|next.cc|local|完整url>")
+        return 0
+
+    url = normalize_api_url(expand_site_alias(args.target))
+    name = bucket_name_for(url)
+    bucket = get_bucket(creds, name, url)
+    bucket["api_url"] = url
+    creds["current"] = name
+    save_creds(creds)
+    print(f"已切换到 {url}({site_label(url)})")
+    if name != "online":
+        note("提示:该地址与线上四站不共用数据库/密钥,凭据是独立的一桶,可能需要重新登录。")
+    return 0
+
+
+# ---------------------------------------------------------------------------
+# 子命令:whoami
+# ---------------------------------------------------------------------------
+
+
+def cmd_whoami(args):
+    client = make_client(args)
+    print(f"API      : {client.api_note()}")
+    print(f"凭据文件 : {CREDS_PATH}(桶:{client.bucket_name})")
+
+    user = client.bucket.get("user") or {}
+    if user.get("token"):
+        exp = token_expiry(user["token"])
+        expired = exp is not None and exp < time.time()
+        print(f"用户     : {user.get('username', '?')}  uid={user.get('uid', '?')}")
+        print(f"           token {mask(user['token'])}  到期 {fmt_ts(exp)}{'  ⚠ 已过期' if expired else ''}")
+    else:
+        print("用户     : 未登录(! python3 scripts/wp_login.py)")
+
+    model = client.bucket.get("model") or {}
+    if model.get("token"):
+        exp = token_expiry(model["token"])
+        expired = exp is not None and exp < time.time()
+        print(f"模型     : {model.get('name', '?')}  uid={model.get('uid', '?')}")
+        print(f"           token {mask(model['token'])}  到期 {fmt_ts(exp)}{'  ⚠ 已过期' if expired else ''}")
+    else:
+        print("模型     : 未建立(python3 wp.py ensure-model --name <模型名>)")
+
+    tokens = client.bucket.get("access_tokens") or {}
+    if tokens:
+        print("access token:")
+        for uid, item in tokens.items():
+            exp = item.get("exp") or token_expiry(item.get("token", ""))
+            expired = exp is not None and exp < time.time()
+            book = item.get("book", 0)
+            scope = "全部 book" if book == 0 else f"book {book}"
+            name = item.get("channel_name") or ""
+            print(f"  {uid[:8]}… {name:<24} {scope:<10} 到期 {fmt_ts(exp)}{'  ⚠ 已过期' if expired else ''}")
+    else:
+        print("access token:无(python3 wp.py grant <channel>)")
+
+    if args.check:
+        try:
+            data = client.call("GET", "v2/auth/current", token=client.user_token)
+        except ApiError as exc:
+            raise explain_api_error(exc, "校验用户 token")
+        print(f"\n服务端确认:{data.get('nickName')} / realName={data.get('realName')}(studio_name 用它)")
+    return 0
+
+
+# ---------------------------------------------------------------------------
+# 子命令:ensure-model
+# ---------------------------------------------------------------------------
+
+
+def cmd_ensure_model(args):
+    client = make_client(args)
+    token = client.user_token
+
+    name = args.name or (client.bucket.get("model") or {}).get("name") or os.environ.get("WIKIPALI_MODEL_NAME")
+    if not name:
+        raise WpError(
+            "必须指定模型名:--name <模型标识>(如 claude-opus-5)。\n"
+            "该名字会成为句子的作者署名,不要用别的模型的名字。"
+        )
+
+    try:
+        current = client.call("GET", "v2/auth/current", token=token)
+    except ApiError as exc:
+        raise explain_api_error(exc, "取当前用户信息")
+    studio_name = current.get("realName")
+    if not studio_name:
+        raise WpError("服务端没有返回 realName,无法确定 studio_name。")
+
+    # 1) 按 studio + keyword 查,keyword 是模糊匹配,客户端自己做精确比对
+    try:
+        listed = client.call(
+            "GET", "v2/ai-model", token=token,
+            query={"view": "studio", "name": studio_name, "keyword": name},
+        )
+    except ApiError as exc:
+        raise explain_api_error(exc, "查询模型列表")
+    rows = (listed or {}).get("rows") or []
+    found = next((r for r in rows if r.get("name") == name), None)
+
+    if found:
+        print(f"已存在模型记录:{name}  uid={found['uid']}")
+    else:
+        body = {"name": name, "studio_name": studio_name, "privacy": args.privacy}
+        for field, value in (("model", args.model), ("url", args.url), ("description", args.description)):
+            if value is not None:
+                body[field] = value
+        try:
+            found = client.call("POST", "v2/ai-model", token=token, body=body)
+            print(f"已创建模型记录:{name}  uid={found['uid']}")
+        except ApiError as exc:
+            if exc.status != 409:
+                raise explain_api_error(exc, "创建模型记录")
+            # 并发或模糊匹配漏网:重查一次
+            listed = client.call(
+                "GET", "v2/ai-model", token=token,
+                query={"view": "studio", "name": studio_name, "keyword": name},
+            )
+            rows = (listed or {}).get("rows") or []
+            found = next((r for r in rows if r.get("name") == name), None)
+            if not found:
+                raise WpError(f"服务端说 {name} 已存在(409),但列表里查不到,无法继续。")
+            print(f"已存在模型记录:{name}  uid={found['uid']}")
+
+    # 2) 增量补字段(update 是增量的,未提交的字段保持原值)
+    patch = {}
+    for field, value in (("model", args.model), ("url", args.url), ("description", args.description)):
+        if value is not None and found.get(field) != value:
+            patch[field] = value
+    if args.privacy and found.get("privacy") != args.privacy:
+        patch["privacy"] = args.privacy
+    if patch:
+        try:
+            found = client.call("PUT", f"v2/ai-model/{found['uid']}", token=token, body=patch)
+            print(f"已更新字段:{', '.join(patch)}")
+        except ApiError as exc:
+            raise explain_api_error(exc, "更新模型记录")
+
+    # 3) 取模型身份 token
+    try:
+        issued = client.call("GET", f"v2/ai-model-token/{found['uid']}", token=token)
+    except ApiError as exc:
+        raise explain_api_error(exc, "签发模型身份 token")
+
+    client.bucket["model"] = {
+        "uid": issued["uid"],
+        "name": issued["name"],
+        "token": issued["token"],
+        "issued_at": iso_now(),
+    }
+    client.save()
+    exp = token_expiry(issued["token"])
+    print(f"模型身份 token 已缓存:{mask(issued['token'])}  到期 {fmt_ts(exp)}")
+    print(f"写入的句子将署名为该模型(editor_uid={issued['uid']})。")
+    return 0
+
+
+def cmd_revoke(args):
+    client = make_client(args)
+    model = client.bucket.get("model") or {}
+    uid = args.uid or model.get("uid")
+    if not uid:
+        raise WpError("没有可撤销的模型:请给 --uid <模型 uid>,或先跑 ensure-model。")
+    if not args.yes and not confirm(f"将撤销模型 {model.get('name', uid)} 已签出的全部 token,继续?"):
+        print("已取消。")
+        return 1
+    try:
+        data = client.call("DELETE", f"v2/ai-model-token/{uid}", token=client.user_token)
+    except ApiError as exc:
+        raise explain_api_error(exc, "撤销模型 token")
+    if model.get("uid") == uid:
+        client.bucket["model"] = {"uid": uid, "name": model.get("name")}
+        client.save()
+    print(f"已撤销 {data.get('name')} 的全部 token(token_version={data.get('token_version')})。")
+    print("本地缓存的模型 token 已清除,需要写入时请重跑 ensure-model。")
+    return 0
+
+
+# ---------------------------------------------------------------------------
+# 子命令:channels
+# ---------------------------------------------------------------------------
+
+
+def fetch_channels(client, search=None):
+    try:
+        data = client.call(
+            "GET", "v2/channel", token=client.user_token,
+            query={"view": "user-edit", "order": "updated_at", "dir": "desc", "limit": 200, "search": search},
+        )
+    except ApiError as exc:
+        raise explain_api_error(exc, "获取可编辑 channel 列表")
+    return (data or {}).get("rows") or []
+
+
+def cmd_channels(args):
+    client = make_client(args)
+    rows = fetch_channels(client, args.search)
+    if args.json:
+        print(json.dumps(rows, ensure_ascii=False, indent=2))
+        return 0
+    if not rows:
+        print("当前账号没有任何可编辑的 channel。")
+        return 1
+    print(f"可编辑 channel({len(rows)} 个,按更新时间倒序):")
+    for idx, ch in enumerate(rows, 1):
+        print(
+            f"  {idx:>2}) {ch.get('name', '')[:32]:<34} {str(ch.get('lang', '')):<6} "
+            f"{ch.get('uid', '')[:8]}…  {ch.get('role', '')}"
+        )
+    return 0
+
+
+def pick_channel(client, given, interactive=True):
+    """返回 (uid, name)。given 可以是 uid、序号或名字片段;为空则交互选择。"""
+    rows = fetch_channels(client)
+    if not rows:
+        raise WpError("当前账号没有任何可编辑的 channel,无法继续。")
+
+    if given:
+        for ch in rows:
+            if ch.get("uid") == given:
+                return ch["uid"], ch.get("name")
+        if given.isdigit() and 1 <= int(given) <= len(rows):
+            ch = rows[int(given) - 1]
+            return ch["uid"], ch.get("name")
+        matched = [c for c in rows if given.lower() in (c.get("name") or "").lower()]
+        if len(matched) == 1:
+            return matched[0]["uid"], matched[0].get("name")
+        if len(matched) > 1:
+            names = ", ".join(c.get("name", "") for c in matched[:5])
+            raise WpError(f"「{given}」匹配到多个 channel:{names}…… 请给完整 uid。")
+        # 不在可编辑列表里的 uid:直接用,但回显不出名字
+        if len(given) >= 32:
+            note(f"⚠ {given} 不在可编辑列表中,仍按 uid 使用——签发 access token 时可能返回 count: 0。")
+            return given, None
+        raise WpError(f"找不到 channel:{given}")
+
+    if not (interactive and sys.stdin.isatty()):
+        raise WpError("未指定 channel,且当前不是交互式终端。请先跑 `wp.py channels` 再用 --channel 指定。")
+
+    print("可编辑 channel:")
+    for idx, ch in enumerate(rows, 1):
+        print(f"  {idx:>2}) {ch.get('name', '')[:32]:<34} {str(ch.get('lang', '')):<6} {ch.get('uid', '')[:8]}…")
+    raw = input("选择序号:").strip()
+    if not raw.isdigit() or not (1 <= int(raw) <= len(rows)):
+        raise WpError("输入无效。")
+    ch = rows[int(raw) - 1]
+    return ch["uid"], ch.get("name")
+
+
+# ---------------------------------------------------------------------------
+# 子命令:grant
+# ---------------------------------------------------------------------------
+
+
+def cached_access_token(client, channel_uid, book):
+    item = (client.bucket.get("access_tokens") or {}).get(channel_uid)
+    if not item or not item.get("token"):
+        return None
+    # book 0 是「不限 book」,能覆盖任何请求;否则必须完全一致
+    if item.get("book", 0) != 0 and item.get("book") != book:
+        return None
+    exp = item.get("exp") or token_expiry(item["token"])
+    if exp and exp - time.time() < TOKEN_REFRESH_MARGIN:
+        return None
+    return item
+
+
+def grant_access_token(client, channel_uid, channel_name, book, force=False):
+    if not force:
+        cached = cached_access_token(client, channel_uid, book)
+        if cached:
+            return cached
+
+    # book 必须是整数:服务端用 !== 严格比较,"1" !== 1 恒真会导致鉴权失败
+    payload = [{"res_type": "channel", "res_id": channel_uid, "power": "edit", "book": int(book)}]
+    try:
+        data = client.call("POST", "v2/access-token", token=client.user_token, body={"payload": payload})
+    except ApiError as exc:
+        raise explain_api_error(exc, "签发 access token")
+    rows = (data or {}).get("rows") or []
+    if not rows:
+        # 无权时服务端静默跳过该条,rows 为空——等同 403,绝不能继续写
+        raise WpError(
+            f"签发 access token 返回 count: 0,说明当前账号对 channel {channel_uid} 没有编辑权。\n"
+            "不要继续写入。请确认选对了 channel,或让 owner 授予 ≥ editor 权限。"
+        )
+    row = rows[0]
+    item = {
+        "token": row["token"],
+        "book": int(book),
+        "exp": (row.get("payload") or {}).get("exp"),
+        "granted_at": iso_now(),
+    }
+    if channel_name:
+        item["channel_name"] = channel_name
+    client.bucket.setdefault("access_tokens", {})[channel_uid] = item
+    client.save()
+    return item
+
+
+def cmd_grant(args):
+    client = make_client(args)
+    uid, name = pick_channel(client, args.channel)
+    item = grant_access_token(client, uid, name, args.book, force=args.force)
+    scope = "全部 book" if item["book"] == 0 else f"book {item['book']}"
+    print(f"channel : {name or '(未知)'}  {uid}")
+    print(f"范围    : {scope}")
+    print(f"token   : {mask(item['token'])}  到期 {fmt_ts(item.get('exp'))}")
+    return 0
+
+
+# ---------------------------------------------------------------------------
+# 子命令:write
+# ---------------------------------------------------------------------------
+
+SENT_REQUIRED = ("book_id", "paragraph", "word_start", "word_end", "content")
+
+
+def load_sentences(args):
+    if args.file == "-":
+        raw = sys.stdin.read()
+    else:
+        try:
+            with open(args.file, "r", encoding="utf-8") as fh:
+                raw = fh.read()
+        except OSError as exc:
+            raise WpError(f"读不了输入文件:{exc}")
+    try:
+        data = json.loads(raw)
+    except ValueError as exc:
+        raise WpError(f"输入不是合法 JSON:{exc}")
+
+    default_channel = None
+    if isinstance(data, dict):
+        default_channel = data.get("channel_uid") or data.get("channel")
+        data = data.get("sentences")
+    if not isinstance(data, list) or not data:
+        raise WpError('输入必须是句子数组,或 {"channel_uid": ..., "sentences": [...]},且非空。')
+    return data, default_channel
+
+
+def normalize_sentences(rows, channel_uid, default_content_type):
+    out = []
+    for idx, row in enumerate(rows):
+        if not isinstance(row, dict):
+            raise WpError(f"第 {idx + 1} 条不是对象。")
+        missing = [f for f in SENT_REQUIRED if row.get(f) is None]
+        if missing:
+            raise WpError(f"第 {idx + 1} 条缺字段:{', '.join(missing)}")
+        try:
+            sent = {
+                "book_id": int(row["book_id"]),
+                "paragraph": int(row["paragraph"]),
+                "word_start": int(row["word_start"]),
+                "word_end": int(row["word_end"]),
+                "content": str(row["content"]),
+                "content_type": row.get("content_type") or default_content_type,
+                "channel_uid": row.get("channel_uid") or channel_uid,
+            }
+        except (TypeError, ValueError) as exc:
+            raise WpError(f"第 {idx + 1} 条字段类型不对:{exc}")
+        if not sent["channel_uid"]:
+            raise WpError(f"第 {idx + 1} 条没有 channel_uid,且未通过 --channel 指定。")
+        out.append(sent)
+    return out
+
+
+def sent_key(sent):
+    return (
+        int(sent["book_id"]),
+        int(sent["paragraph"]),
+        int(sent["word_start"]),
+        int(sent["word_end"]),
+        sent["channel_uid"],
+    )
+
+
+def row_key(row):
+    channel = row.get("channel") or {}
+    return (
+        int(row.get("book", -1)),
+        int(row.get("paragraph", -1)),
+        int(row.get("word_start", -1)),
+        int(row.get("word_end", -1)),
+        channel.get("uid"),
+    )
+
+
+def confirm(question):
+    if not sys.stdin.isatty():
+        return False
+    answer = input(f"{question} [y/N] ").strip().lower()
+    return answer in ("y", "yes")
+
+
+def iso_now():
+    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def cmd_write(args):
+    client = make_client(args)
+    # 先确认凭据齐备再解析输入:缺 token 时不该让用户看半张回显
+    client.user_token
+    model = client.model
+    rows, file_channel = load_sentences(args)
+
+    channel_hint = args.channel or file_channel
+    uid = name = None
+    if channel_hint or not any(r.get("channel_uid") for r in rows if isinstance(r, dict)):
+        uid, name = pick_channel(client, channel_hint)
+    sentences = normalize_sentences(rows, uid, args.content_type)
+
+    channels = sorted({s["channel_uid"] for s in sentences})
+    books = sorted({s["book_id"] for s in sentences})
+    names = {uid: name} if uid else {}
+    for cuid in channels:
+        if cuid not in names:
+            names[cuid] = channel_display_name(client, cuid)
+
+    # 写入前的确认:出问题时必须知道是哪一版代码、写进了哪个 channel
+    print("=" * 72)
+    print(f"API      : {client.api_note()}")
+    for cuid in channels:
+        print(f"channel  : {names.get(cuid) or '(未知)'}  {cuid}")
+    print(f"book     : {', '.join(str(b) for b in books)}")
+    print(f"模型身份 : {model.get('name')}  uid={model.get('uid')}")
+    print(f"句子数   : {len(sentences)}(每批 {args.batch})")
+    print("-" * 72)
+    for sent in sentences[: args.preview]:
+        summary = sent["content"].replace("\n", " ")
+        if len(summary) > 50:
+            summary = summary[:50] + "…"
+        print(f"  {sent['book_id']}-{sent['paragraph']}-{sent['word_start']}-{sent['word_end']}  {summary}")
+    if len(sentences) > args.preview:
+        print(f"  …… 其余 {len(sentences) - args.preview} 条")
+    print("-" * 72)
+    print("⚠ 相同位置(book/paragraph/word_start/word_end/channel)的已有句子将被覆盖。")
+    print("=" * 72)
+
+    if args.dry_run:
+        print("--dry-run:未发送任何请求。")
+        return 0
+    if not args.yes and not confirm("确认写入?"):
+        print("已取消,未写入任何内容。")
+        return 1
+
+    # 每个 channel 一张 access token(缓存命中就不重签)
+    tokens = {}
+    for cuid in channels:
+        book_scope = 0 if len(books) > 1 else books[0]
+        if args.book is not None:
+            book_scope = args.book
+        item = grant_access_token(client, cuid, names.get(cuid), book_scope)
+        tokens[cuid] = item["token"]
+
+    written = {}
+    failed = []
+    model_token = model["token"]
+    for start in range(0, len(sentences), args.batch):
+        batch = sentences[start : start + args.batch]
+        body = {
+            "sentences": [
+                {
+                    "book_id": s["book_id"],
+                    "paragraph": s["paragraph"],
+                    "word_start": s["word_start"],
+                    "word_end": s["word_end"],
+                    "channel_uid": s["channel_uid"],
+                    "content": s["content"],
+                    "content_type": s["content_type"],
+                    "access_token": tokens[s["channel_uid"]],
+                }
+                for s in batch
+            ]
+        }
+        try:
+            data = client.call("POST", "v2/sentence", token=model_token, body=body, timeout=WRITE_TIMEOUT)
+        except ApiError as exc:
+            if exc.status != 401:
+                raise explain_api_error(exc, "写入句子")
+            # 模型 token 过期或被撤销:重取一次再试,仍失败才提示重新登录
+            model_token = refresh_model_token(client)
+            try:
+                data = client.call("POST", "v2/sentence", token=model_token, body=body, timeout=WRITE_TIMEOUT)
+            except ApiError as retry_exc:
+                raise explain_api_error(retry_exc, "写入句子(已重签模型 token 后重试)")
+        returned = (data or {}).get("rows") or []
+        for row in returned:
+            written[row_key(row)] = row
+        got = len(returned)
+        print(f"批次 {start // args.batch + 1}: 提交 {len(batch)},服务端确认 {got}")
+        if got < len(batch):
+            # HTTP 200 不等于全部写入:逐句鉴权失败是静默 continue 掉的
+            for s in batch:
+                if sent_key(s) not in written:
+                    failed.append(s)
+
+    print("-" * 72)
+    print(f"合计提交 {len(sentences)} 条,确认写入 {len(written)} 条。")
+    sample = next(iter(written.values()), None)
+    if sample:
+        editor = (sample.get("editor") or {}).get("nickName") or (sample.get("editor") or {}).get("name")
+        print(f"署名核对:第一条的 editor = {editor}")
+    if failed:
+        print(f"⚠ 有 {len(failed)} 条未写入(服务端逐句鉴权失败会静默跳过):")
+        for s in failed[:10]:
+            print(f"  {s['book_id']}-{s['paragraph']}-{s['word_start']}-{s['word_end']}  channel={s['channel_uid'][:8]}…")
+        if len(failed) > 10:
+            print(f"  …… 其余 {len(failed) - 10} 条")
+        return 1
+    return 0
+
+
+def channel_display_name(client, uid):
+    try:
+        data = client.call("GET", f"v2/channel/{uid}", token=client.user_token)
+    except (ApiError, WpError):
+        return None
+    if isinstance(data, dict):
+        return data.get("name")
+    return None
+
+
+def refresh_model_token(client):
+    note("⚠ 模型 token 被拒(过期或已撤销),正在重新签发……")
+    model = client.bucket.get("model") or {}
+    if not model.get("uid"):
+        raise WpError("缓存里没有模型 uid,无法重签。请跑:python3 wp.py ensure-model --name <模型名>")
+    try:
+        issued = client.call("GET", f"v2/ai-model-token/{model['uid']}", token=client.user_token)
+    except ApiError as exc:
+        raise explain_api_error(exc, "重新签发模型 token")
+    model.update({"uid": issued["uid"], "name": issued["name"], "token": issued["token"], "issued_at": iso_now()})
+    client.bucket["model"] = model
+    client.save()
+    return issued["token"]
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+
+def build_parser():
+    parser = argparse.ArgumentParser(
+        prog="wp.py",
+        description="WikiPali 写入客户端(以 AI 模型身份写入句子)",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+    )
+    parser.add_argument("--api", help="本次调用使用的 API 地址(序号/简称/完整 url),不写回凭据文件")
+    sub = parser.add_subparsers(dest="command", required=True)
+
+    p = sub.add_parser("endpoint", help="查看 / 切换 API 地址")
+    p.add_argument("target", nargs="?", help="序号、简称(www/www.cc/next/next.cc/local)或完整 url")
+    p.set_defaults(func=cmd_endpoint)
+
+    p = sub.add_parser("whoami", help="显示当前凭据状态")
+    p.add_argument("--check", action="store_true", help="额外向服务端校验用户 token")
+    p.set_defaults(func=cmd_whoami)
+
+    p = sub.add_parser("ensure-model", help="幂等地建立模型记录并取模型身份 token")
+    p.add_argument("--name", help="模型标识,如 claude-opus-5(会成为句子作者署名)")
+    p.add_argument("--model", help="底层模型 id")
+    p.add_argument("--url", dest="url", help="模型服务地址")
+    p.add_argument("--description", help="描述")
+    p.add_argument("--privacy", choices=["private", "public"], default="private")
+    p.set_defaults(func=cmd_ensure_model)
+
+    p = sub.add_parser("revoke", help="撤销该模型已签出的全部 token")
+    p.add_argument("--uid", help="模型 uid,缺省用缓存里的")
+    p.add_argument("-y", "--yes", action="store_true")
+    p.set_defaults(func=cmd_revoke)
+
+    p = sub.add_parser("channels", help="列出当前账号可编辑的 channel")
+    p.add_argument("--search", help="按名字过滤")
+    p.add_argument("--json", action="store_true", help="输出原始 JSON")
+    p.set_defaults(func=cmd_channels)
+
+    p = sub.add_parser("grant", help="为某个 channel 签发 access token 并缓存")
+    p.add_argument("channel", nargs="?", help="channel uid / 列表序号 / 名字片段;省略则交互选择")
+    p.add_argument("--book", type=int, default=0, help="限定 book,0 表示不限(默认)")
+    p.add_argument("--force", action="store_true", help="即使缓存未过期也重新签发")
+    p.set_defaults(func=cmd_grant)
+
+    p = sub.add_parser("write", help="写入句子")
+    p.add_argument("file", help="句子 JSON 文件,- 表示从 stdin 读")
+    p.add_argument("--channel", help="目标 channel(uid / 序号 / 名字片段)")
+    p.add_argument("--book", type=int, help="access token 的 book 范围,缺省按句子推断")
+    p.add_argument("--batch", type=int, default=DEFAULT_BATCH, help=f"每批条数,默认 {DEFAULT_BATCH}")
+    p.add_argument("--content-type", default="markdown")
+    p.add_argument("--preview", type=int, default=5, help="确认时预览几条")
+    p.add_argument("--dry-run", action="store_true", help="只做校验与回显,不发请求")
+    p.add_argument("-y", "--yes", action="store_true", help="跳过交互确认(非交互环境必须显式给)")
+    p.set_defaults(func=cmd_write)
+
+    return parser
+
+
+def main(argv=None):
+    args = build_parser().parse_args(argv)
+    try:
+        return args.func(args)
+    except WpError as exc:
+        print(f"错误:{exc}", file=sys.stderr)
+        return 1
+    except KeyboardInterrupt:
+        print("\n已中断。", file=sys.stderr)
+        return 130
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 100 - 0
.claude/skills/wikipali-write/scripts/wp_login.py

@@ -0,0 +1,100 @@
+#!/usr/bin/env python3
+"""WikiPali 交互式登录——整个 Skill 里唯一接触密码的脚本。
+
+密码只经 getpass 读入内存,不落盘、不进日志、不进对话上下文。
+登录成功后只把 JWT 存进 ~/.wikipali/credentials.json(0600)。
+
+用法:
+    python3 wp_login.py                       # 登录当前默认站点
+    python3 wp_login.py --api next            # 只为本次登录换站点
+    python3 wp_login.py --username someone    # 免去输用户名一步
+
+在 Claude Code 中请用 `!` 前缀由用户本人执行,不要让模型代跑:
+    ! python3 .claude/skills/wikipali-write/scripts/wp_login.py
+"""
+
+import argparse
+import getpass
+import sys
+
+import wp
+
+
+def main(argv=None):
+    parser = argparse.ArgumentParser(prog="wp_login.py", description="登录 WikiPali 并缓存用户 token")
+    parser.add_argument("--api", help="本次登录使用的 API 地址(序号/简称/完整 url)")
+    parser.add_argument("--username", help="用户名或邮箱;省略则交互输入")
+    args = parser.parse_args(argv)
+
+    try:
+        client = wp.make_client(args)
+    except wp.WpError as exc:
+        print(f"错误:{exc}", file=sys.stderr)
+        return 1
+
+    print(f"登录站点:{client.api_note()}")
+    if client.bucket_name != "online":
+        print("注意:该站点的凭据与线上四站不通用。")
+
+    username = args.username
+    if not username:
+        if not sys.stdin.isatty():
+            print("错误:非交互式环境请用 --username 指定用户名。", file=sys.stderr)
+            return 1
+        username = input("用户名或邮箱:").strip()
+    if not username:
+        print("错误:用户名为空。", file=sys.stderr)
+        return 1
+
+    try:
+        password = getpass.getpass("密码(不会被保存):")
+    except (EOFError, KeyboardInterrupt):
+        print("\n已取消。", file=sys.stderr)
+        return 130
+    if not password:
+        print("错误:密码为空。", file=sys.stderr)
+        return 1
+
+    try:
+        token = client.call("POST", "v2/sign-in", body={"username": username, "password": password})
+    except wp.ApiError as exc:
+        # sign-in 失败时服务端返回 400 + 'invalid token',措辞会让人以为是 token 问题
+        if exc.status in (400, 401):
+            print("错误:用户名或密码不正确。", file=sys.stderr)
+        else:
+            print(f"错误:登录失败(HTTP {exc.status}):{exc}", file=sys.stderr)
+        return 1
+    except wp.WpError as exc:
+        print(f"错误:{exc}", file=sys.stderr)
+        return 1
+    finally:
+        del password
+
+    if not isinstance(token, str) or not token:
+        print("错误:服务端没有返回 token。", file=sys.stderr)
+        return 1
+
+    try:
+        current = client.call("GET", "v2/auth/current", token=token)
+    except wp.WpError as exc:
+        print(f"错误:token 拿到了但校验失败:{exc}", file=sys.stderr)
+        return 1
+
+    client.bucket["user"] = {
+        "uid": current.get("id"),
+        "username": current.get("realName"),
+        "nickname": current.get("nickName"),
+        "token": token,
+        "logged_in_at": wp.iso_now(),
+    }
+    client.save()
+
+    exp = wp.token_expiry(token)
+    print(f"登录成功:{current.get('nickName')}(realName={current.get('realName')},用作 studio_name)")
+    print(f"token {wp.mask(token)} 到期 {wp.fmt_ts(exp)},已写入 {wp.CREDS_PATH}(0600)")
+    print("下一步:python3 wp.py ensure-model --name <模型标识>")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 1 - 0
.gitignore

@@ -9,3 +9,4 @@
 *.md5
 
 /k8s/
+__pycache__/

+ 25 - 9
docs/wikipali-write-skill-design.md

@@ -4,7 +4,7 @@
 >
 > 分发路径:在本仓库开发调试,成熟后以**整目录复制**方式装到其他项目(§6.7)。不做独立仓库——API 仍需频繁修改,Skill 契约必须与 `api-v13` 同仓演进。
 >
-> 状态:设计已定案;服务端 P0 已完成(§5.1 端点 + §5.2 abdefg),Skill 尚未开始
+> 状态:设计已定案;服务端 P0 已完成(§5.1 端点 + §5.2 abdefg);Skill P1 已完成(`.claude/skills/wikipali-write/`),端到端实测待服务端部署
 > 对应后端:`api-v13`(Laravel 13,路由前缀 `/api/v2`)
 > 决策定案:2026-08-04(见 §9)
 
@@ -326,10 +326,13 @@ public function show(Request $request, AiModel $aiModel)
 │   └── api.md            # 本文 §2 的精简版:端点、字段、陷阱
 ├── scripts/
 │   ├── wp_login.py       # 交互式登录,仅此脚本接触密码
-│   └── wp.py             # 客户端:endpoint / ensure-model / channels / grant / write
+│   └── wp.py             # 客户端:endpoint / whoami / ensure-model / revoke /
+│                         #         channels / grant / write
 └── install.sh            # 复制自身到目标项目或 ~/.claude/skills/
 ```
 
+实现时比原计划多了两个子命令:`whoami`(一屏看清当前站点、三种 token 及其到期时间——排查「为什么 401」的第一步)与 `revoke`(§2.3b 的撤销端点,安全能力做了就该有入口)。`wp_login.py` 通过 `import wp` 复用 HTTP 与凭据代码,两个文件仍在同一目录内,不违反自包含约束。
+
 注意放在**仓库根**而非 `api-v13/.claude/skills/`:后者已有 `laravel-best-practices` 等目录级 skill,只在编辑 `api-v13/` 下文件时激活;而本 Skill 是对线上 API 的客户端操作,与当前编辑哪个子目录无关。
 
 ### 6.1.1 可分发性约束
@@ -437,9 +440,11 @@ $ python3 scripts/wp.py endpoint next
 
 1. `GET /v2/ai-model?view=studio&name={username}&keyword={modelName}`;
 2. 在 `rows` 中做 `name` **精确**比对;
-3. 命中 → 用其 `uid`;未命中 → `POST` 创建,再 `PUT` 补齐 `model` / `privacy` 等字段(在 §5.2d 落地前必须这么做两步)
+3. 命中 → 用其 `uid`;未命中 → `POST` 一次即可带全字段(§5.2d 已落地,不再需要「POST 再 PUT」两步)。`POST` 撞 409 → 回查列表取 uid(模糊匹配漏网或并发);已存在但字段有出入 → `PUT` 增量补
 4. `GET /v2/ai-model-token/{uid}` 取 modelToken,写入凭据文件缓存。
 
+`--name` 决定署名,故不设默认值:拿不到就报错要求显式指定,避免把句子挂到别的模型名下。
+
 ### 6.4 channel 的交互式选择
 
 §9 决策 3:**不要求用户手工提供 channel uid**,由 Skill 列出可编辑 channel 供选择。
@@ -509,6 +514,8 @@ ChannelController::index() 的 'user-edit' 分支:
 
 **先后顺序**:先在本仓库把流程跑通(P1 全部完成),再写 `install.sh`。过早打包会把未定型的 API 契约固化到副本里。
 
+2026-08-05 的实际情况:`install.sh` 已写好并验证(装出的副本能独立运行),但**分发要等到服务端部署 + 端到端实测通过之后**。打包机制本身不依赖 API 契约,先写好没有代价;真正会把未定型契约固化出去的是「复制给别的项目」这一步。
+
 ---
 
 ## 7. 安全考量
@@ -530,13 +537,14 @@ ChannelController::index() 的 'user-edit' 分支:
 | P0 | ✅ | 服务端安全修补:§5.2 (a)(b)(f) | — |
 | P0 | ✅ | 服务端:§5.2 (d)(e)(g) —— 从 P2 上提,否则 §6.3 的「POST 建档再 PUT 补字段」会被 (e) 的 null 覆盖打断 | — |
 | P0 | ✅ | 服务端:模型 token TTL 收到 30 天 + `token_version` 撤销机制 + `DELETE /v2/ai-model-token/{uid}`(推翻 §9 决策 2)| — |
-| P1 |  | Skill:`wp_login.py` + 凭据存储 + `auth/current` 校验 | P0 |
-| P1 |  | Skill:`ensure-model`(查/建/补字段/取 token) | P0 |
-| P1 |  | Skill:`channels`(`view=user-edit` 列表 + 交互选择) | P0 |
-| P1 | ⬜ | Skill:`grant`(签 access token,缓存) | `channels` |
-| P1 |  | Skill:`write`(分批 + 确认 + count 核对) | 以上全部 |
+| P1 |  | Skill:`wp_login.py` + 凭据存储 + `auth/current` 校验 | P0 |
+| P1 |  | Skill:`ensure-model`(查/建/补字段/取 token)、`revoke`、`whoami` | P0 |
+| P1 |  | Skill:`channels`(`view=user-edit` 列表 + 交互选择) | P0 |
+| P1 | ✅ | Skill:`grant`(签 access token,缓存,判 `count: 0`) | `channels` |
+| P1 |  | Skill:`write`(分批 + 确认 + count 核对 + 401 自动重签一次) | 以上全部 |
 | P2 | ⬜ | 服务端质量修补:§5.2 (c) | — |
-| P1 | ⬜ | Skill:`install.sh` + `VERSION`(打包分发,§6.7) | P1 全部跑通 |
+| P1 | ✅ | Skill:`install.sh` + `VERSION`(打包分发,§6.7) | P1 全部跑通 |
+| P1 | ⬜ | **端到端实测:对真实站点跑通登录 → 写入** | **服务端部署** |
 | P2 | ⬜ | Skill 扩展:读取能力(`GET /v2/sentence`、`sentences-in-chapter`)与 `sentpr` PR 提交 | P1 |
 
 (d)(e) 上提到 P0 的理由:§6.3 第 3 步在 (d) 落地前必须走「POST 创建 → PUT 补齐字段」两步,而 (e) 未修时那个 PUT 会把未传字段一律置 null,两个缺陷叠加使 ensure-model 无法可靠工作。
@@ -560,6 +568,14 @@ ChannelController::index() 的 'user-edit' 分支:
 sudo -u postgres createdb -O www mint_test
 ```
 
+#### Skill 的验证方式(2026-08-05)
+
+Skill 的验证不走 Pest——它是个纯客户端,测的是「对着服务端的响应形状与坑,客户端做对了没有」。做法是写一个模拟 API 的桩服务(复刻 `sign-in` 失败返回 400、`keyword` 模糊匹配、`access-token` 无权返回 `count: 0`、`sentence` 逐句静默跳过、返回字段名是 `book` 而非 `book_id` 这几处),把全流程跑一遍,验证点:
+
+登录(含密码错)、`ensure-model` 幂等复跑、`channels` 列表、`grant` 缓存命中不重签、`write` 的 dry-run / 分批 / 覆盖警告 / 非交互式无 `-y` 时拒绝写入、部分写入时列出漏掉的句子、`count: 0` 时中止、模型 token 被撤销后自动重签一次再重试、fallback 顺序(同版本另一域名 → 另一版本同域名,且绝不落到 `local`)、`endpoint` 切换与 `--api` 不写回、`install.sh` 装出的副本可独立运行。
+
+**尚未验证的是真实站点上的端到端写入**——四个线上地址目前都还没部署 P0(`POST /api/v2/ai-model-token/x` 返回 404 而非 405,说明路由不存在)。部署后要补跑一次真实写入,并断言 `editor_uid` 落成模型 uid。桩服务不进仓库:它编码的是「我以为服务端是这样」,留着会变成第二份契约来源,与 `references/api.md` 打架。
+
 ---
 
 ## 9. 决策记录