Metadata-Version: 2.5
Name: tytcli
Version: 0.2.0
Summary: 投研通 (TYT) HTTP API CLI, designed for agent + skill driven use
Project-URL: Homepage, https://tyt.1234567.com.cn/
License-Expression: LicenseRef-Proprietary
License-File: LICENSE.txt
Keywords: tyt,天天基金投研通,投研通
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3.15
Requires-Python: >=3.11
Requires-Dist: click>=8.1
Requires-Dist: httpx>=0.27
Description-Content-Type: text/markdown

# tytcli

投研通 (TYT) HTTP API 的命令行客户端，为 **agent + skill** 场景设计：默认输出 JSON、退出码稳定、绝不交互式提问，便于 agent 解析结果并自主扩展源码。

## 安装与运行

```bash
uv tool install tytcli      # 推荐：隔离安装，自带 `tytcli upgrade` 自升级
# 或
pip install tytcli
```

开发期直接从源码运行：

```bash
uv sync
uv run tytcli --help
uv run tytcli whoami
```

## 环境

面向使用者的环境是 `prod`，也是默认值：

| 环境 | base URL |
|---|---|
| `prod`（默认） | `https://tytapi.1234567.com.cn` |

端点在代码里一律写成相对于 base URL 的形式（如 `Login/GetLoginInfoV2`）。

```bash
tytcli config show  # 当前生效配置（Token 脱敏）
tytcli config path  # 配置文件在哪、是否存在
tytcli whoami       # Token 是否有效（成功即返回登录身份摘要）
```

> 除 `prod` 外还存在一个**仅供开发**使用的环境，它不会出现在 `--help` 或常规输出里，
> 文档也不介绍它。能用 `TYTCLI_ENV` 显式指定它是给开发者留的口子，不是常规用法。

## 配置

默认路径：`$XDG_CONFIG_HOME/tytcli/config.toml`，未设置 `XDG_CONFIG_HOME` 时为 `~/.config/tytcli/config.toml`。

```bash
tytcli config path          # 打印路径与是否存在
tytcli config init          # 创建 0600 权限的模板
tytcli config init --force  # 覆盖
```

模板（**注意里面没有 `base_url`**）：

```toml
active_env = "prod"
timeout_seconds = 15.0

[env.prod]
tyt_token = "…你的 token…"
```

**Token 与环境绑定**：一个环境的 Token 拿到另一个环境会直接返回 `AUTH_EXPIRED`。

写 Token 最简单的方式是 `auth` 子命令，而不是手改文件：

```bash
tytcli auth --token <TOKEN>                   # 写入当前生效环境
tytcli auth --token <TOKEN> --verify          # 写入后立即联网校验
echo "$TOK" | tytcli auth --token -           # 从 stdin 读，不进 shell 历史
tytcli auth --env <ENV> --token <TOKEN>       # 写入指定环境（默认：当前生效环境）
```

`auth` 会按需从模板创建配置文件（权限 `0600`），写入后**回读校验**确认落盘无误。

### 环境地址固定在代码中

两个 base URL 是 `src/tytcli/config.py` 里的 `BASE_URLS` 常量，**不可通过配置文件或环境变量覆盖**：

- 配置文件里的 `base_url` 如果与内置地址**不一致** → 直接报错 `CONFIG_BASE_URL_NOT_CONFIGURABLE`（而不是静默忽略——被忽略的覆盖项极难排查）。若只是**复述**内置值（旧版模板生成的配置就是这样）则容忍并忽略，以免老配置无法加载。
- `TYTCLI_BASE_URL` 已移除，设了也无效。
- 因为地址不可配，自定义环境名会被 `CONFIG_UNKNOWN_ENV` 拒绝。

### 覆盖优先级

```
环境变量 > 配置文件 > 内置默认值
```

| 环境变量 | 覆盖内容 |
|---|---|
| `TYTCLI_CONFIG` | 配置文件路径（文件不存在直接报错，不回退） |
| `TYTCLI_ENV` | 当前环境 |
| `TYTCLI_TOKEN` | 当前环境的 Token |

刻意的设计：

- **不自动加载 CWD 下的配置或 `.env`**，避免 agent 换目录时误用凭证。
- **显式设置为空的 `TYTCLI_TOKEN` 会报错**，不会静默回退到文件里的值。
- **`auth --env X` 只写 X 的 section，不会顺手切换 `active_env`。** 如果你给非当前环境写了 Token，下一次调用会明确告诉你“哪个环境已有 Token、怎么切过去”，而不是含糊地报“缺少 Token”。

> ⚠️ **`auth --token` 的安全权衡**：Token 作为命令行参数会进入 shell 历史，并短暂出现在 `ps` 进程列表里。交互式使用请优先用 `--token -` 从 stdin 管道传入。agent 调用时 Token 本来就在上下文里，风险相对可控，但仍应避免把它写进会被持久化的日志。

## 输出与退出码

**stdout 恰好一个 JSON 文档，命令不往 stderr 写任何东西**——结果不会在两个通道里各出现一份。
（唯一例外：隐式自更新钩子的警告，以及裸调 `tytcli` 时路由到 stderr 的 help 文本。
`--help` / `--version` 按通用惯例输出纯文本。）

```bash
tytcli whoami | jq .
tytcli --pretty whoami        # 缩进输出，全局参数需放在子命令前
```

成功（`whoami`）：

```json
{"ok":true,"env":"prod","base_url":"https://tytapi.1234567.com.cn","http_status":200,"identity":{"UserId":"9000001","UserName":"…","CompanyName":"…","MenuCount":6},"data":{"Success":true,"ErrorCode":0,"Error":"","Data":{}}}
```

失败：

```json
{"ok":false,"error":{"code":"AUTH_EXPIRED","message":"登录超时，请重新登录！","details":{"env":"prod","error_code":-100,"hint":"…"}}}
```

- `data` 是服务端响应的**原样透传**（PascalCase），不重命名、不裁剪。`whoami` 的 `Data.Menus` 是完整权限菜单树（包含各模块路由），体积较大，也是后续加子命令时的接口地图。
- `identity` 是关键字段的**摘要**，方便 agent 不必吞下整棵 `Menus` 树。**它刻意不包含 `Phone`**（手机号是 PII，而摘要是最容易被记进日志的部分）；需要时仍可从 `data` 里取。

> ⚠️ `whoami` 的响应包含个人信息：真实姓名、手机号、工号、公司/部门。转发输出或写日志时注意脱敏。

| 退出码 | 含义 | 典型 code |
|---|---|---|
| 0 | 成功 | — |
| 1 | API / 业务错误 | `API_BUSINESS_ERROR`, `API_BAD_REQUEST`, `HTTP_ERROR`, `NON_JSON_RESPONSE`, `UNEXPECTED_SHAPE`, `INDEX_HTTP_ERROR`, `UPDATE_NOT_PUBLISHED`, `UPDATE_FAILED`, `UPDATE_TIMEOUT`, `UPDATE_UNSUPPORTED`, `INTERNAL_ERROR` |
| 2 | 用法或配置错误 | `USAGE`, `USAGE_STDIN_REQUIRED`, `CONFIG_NOT_FOUND`, `CONFIG_PARSE_ERROR`, `CONFIG_MISSING_TOKEN`, `CONFIG_UNKNOWN_ENV`, `CONFIG_EMPTY_TOKEN`, `CONFIG_BASE_URL_NOT_CONFIGURABLE`, `AUTH_EMPTY_TOKEN`, `AUTH_MALFORMED_TOKEN`, `CONFIG_EXISTS`, `CONFIG_WRITE_VERIFY_FAILED` |
| 3 | 认证失败 | `AUTH_EXPIRED` |
| 4 | 网络错误或超时 | `NETWORK_TIMEOUT`, `NETWORK_ERROR` |

Agent 应按 `error.code` 分支，而不是解析 `message`。

## 命令

```
tytcli whoami          查询当前 Token 对应的登录身份
tytcli auth            把 Token 写入配置文件
  --token TOKEN|-        TOKEN 或 `-`（从 stdin 读）
  --env ENV              写入哪个环境（默认：当前生效环境）
  --verify               写入后立即调用 whoami 校验
tytcli config show     显示当前生效配置（Token 脱敏）
tytcli config path     显示配置文件路径
tytcli config init     创建配置模板
tytcli upgrade         检查包索引并升级到新版
  --check                只检查，不升级
  --force                忽略版本比较，强制重装
tytcli wechat articles 公众号文章列表
  --view ALL|SUBSCRIBED|FAVORITE   默认 ALL
  --fake-id ID           只看某个公众号
  --page N               页码，从 1 开始
  --page-size N          1–100，默认 20
  --fields A,B,C         只输出这些字段（与 --full 互斥）
  --full                 输出服务端全量字段（与 --fields 互斥）
tytcli wechat article  单篇文章详情
  --article-id HEX64     列表返回的 articleId（必填）
tytcli sentiment dates 列出有日报的日期
tytcli sentiment image 下载某天的日报图片
  --date YYYY-MM-DD      日报日期（必填）
  --output PATH          保存路径（默认当前目录 tytcli-sentiment-<日期>.png）
tytcli roadshow search   按时间/行业/机构/主讲人查路演
  --title T --institution I --speaker S
  --theme CODE|NAME      1权益 2固收+ 3纯债 4互认 5策略 6热点
  --start YYYY-MM-DD --end YYYY-MM-DD   注意 --end 不含当天
  --page N --page-size N --fields A,B,C --full
tytcli roadshow status   某一场我能否预约/取消
  --app-id ID            必填，来自 search
tytcli roadshow summary  公开 AI 纪要
  --app-id ID            必填
  --fields A,B,C / --full   默认简略版，--full 详细版
tytcli roadshow book     预约（写操作）
  --app-id ID --yes      不带 --yes 只做只读预检，不写入
tytcli roadshow cancel   取消预约（写操作，同上）
  --app-id ID --yes
tytcli roadshow bookings 我的预约
  --state BOOKED|ENDED   不传为全部未取消的预约
  --meeting-start/--meeting-end YYYY-MM-DD      含结束日全天
  --book-start/--book-end "YYYY-MM-DD HH:MM:SS"
  --page N --page-size N --fields A,B,C --full
tytcli help [TOPIC]      打印命令用法 + 随包发布的 skill 文档
```

### 文档随包发布：`tytcli help`

skill 文档（`SKILL.md` + `references/`）随 wheel 一起安装（`tytcli/skill/`），
与仓库里的 `skills/tytcli/` **同源**（构建时复制，不是两份维护）。
CLI 自己就能打印它们：

```
tytcli help              命令摘要 + SKILL.md（路由文档）
tytcli help wechat       wechat 用法 + references/wechat.md
tytcli help output       输出契约 / 退出码 / 错误码
```

每个命令的 `--help` 末尾都有一行 `完整文档：tytcli help <topic>` 指针。
`--help` 保持精简（只指路），全文走 `help`——这样用法摘要不会被文档淹没，
也不会让 14 个 help 面随文档改动而漂移。

不用 `tytcli <cmd> help` 形式：它会和位置参数冲突——任何接受位置参数的命令都会
把 `help` 当成参数值处理，而不是打印文档。

### 路演中心：写操作默认只预检

`roadshow book` / `roadshow cancel` 会改变服务端状态，而且**这些写入是真实生效的**：
一次误调用就会在账号里留下一条真实预约记录。

所以两条写命令**不带 `--yes` 时不发写请求**，只调一次状态查询做预检，报告
`would_write` 与 `next_step`。带 `--yes` 时还有三层保护：

- 已处于目标状态 → `already_booked` / `already_cancelled`，跳过写入
- 资格不允许 → `ROADSHOW_NOT_BOOKABLE` / `ROADSHOW_NOT_CANCELLABLE`（exit 1），
  带服务端原文 `reason`，跳过写入
- **写入超时 → 回读状态再决定结果**。超时不等于失败，请求可能已经落地；直接报错会让
  调用方重试，而重试就是重复预约。已生效则报 `recovered_after_timeout:true` + exit 0，
  未生效则如实报超时并附对账后的状态

服务端本身也是幂等的（重复预约不会改变已存在的 `bookTime`，重复取消同样成功），但能
不发写请求就不发。

### 两个列表接口的日期语义相反

| 命令 | 入参 | 服务端格式 | `--end` 语义 |
|---|---|---|---|
| `roadshow search` | `YYYY-MM-DD` | `yyyy/M/d` | 截止该日 **00:00:00**，不含当天 |
| `roadshow bookings` | `YYYY-MM-DD` | `yyyy-MM-dd` | **含**当天全天 |

CLI 统一收 ISO 日期并在内部转译，因为让调用方记两种格式必然出错。**转译不是装饰**：
服务端对**无法解析**的日期是**静默忽略**——筛选不生效、照旧返回全量结果，看起来像
"这个区间有这么多场路演"。所以 `search` 的输出会回显 `filters.start_sent_as`
与 `end_includes_that_day`，让调用方能确认筛选真的被应用了。

### 情绪日报：图片走文件，不走 stdout

`sentiment image` 的响应是**图片本身**（数 MiB 的二进制），不是 JSON 信封。为守住
"stdout 恰好一个 JSON 文档"的契约，图片写入文件，stdout 只输出元数据：
`path` / `bytes` / `format` / `sha256` / `content_type` / `overwritten`。

两条会影响调用方的行为：

* `-101`（服务器繁忙）在该模块是**兜底码**：日期格式错、当天没有日报、**以及有效日期
  但图片尚未渲染完成**都会返回它，前两种重试无用、最后一种重试能成功。所以命令在
  客户端先校验日期格式与真实性（`USAGE_DATE_INVALID`，exit 2），并对 `-101` 做有限
  重试（2 次，间隔 2s）；重试耗尽时 `details.retries_exhausted` 说明已尽力。
* 图片的扩展名按**魔数**判定而不是固定 `.png`；魔数不认识的字节**拒绝落盘**
  （`UNEXPECTED_SHAPE`），以免产生一个撒谎的 `.png` 文件。

### 公众号模块：三件必须先知道的事

1. **该模块并非在所有环境都已部署。** 命令不会预先拦截：未部署时服务端返回
   HTTP 404（`exit 1`），错误的 `details.hint` 会说明原因——那不是路由拼错，
   重试也没用。部署上线后命令自动可用，无需改代码。
2. **列表默认是精简索引。** 20 条原样是 66.8 KiB，其中 `cleanBody`（正文）一个字段
   就占 74%——而正文是详情接口的职责。所以默认只给
   `title / nickname / summary / articleId / publishTime`（约 5 KiB），并在输出里放一个
   `__tytcli_hint__` 说明本次还返回了哪些字段、怎么用 `--fields` / `--full` 取到。
   详情则默认全量原样（单篇约 14 KiB）。
3. **`data` 是服务端的 `Data` 成员，不是整个信封。** 与 `whoami` 不同（后者的 `data`
   是整个响应原样）。因为 `Success`/`ErrorCode`/`Error` 已由 `ok` 和错误文档表达，
   而 `data` 在 `--fields` / `--full` 下保持稳定形状对 agent 更有用。

## 自动更新

**每条命令启动时会自动做一次版本检查**：若包索引上有更新版本，先升级再重新执行原命令（re-exec），所以你拿到的总是新版结果。

检查哪个索引，按这个顺序取第一个非空值：

1. `TYTCLI_UPDATE_INDEX_URL`（tytcli 自己的覆盖）
2. `PIP_INDEX_URL`——你已经为 pip 配好的镜像（**只看环境变量**，不读 `pip config` 文件）
3. `https://pypi.org/simple/`

继承 `PIP_INDEX_URL` 的意义是：本地镜像通常比 pypi.org 快得多，而"快"正是这个检查
唯一的开销。两者都是 PEP 503 simple 索引。`PIP_INDEX_URL` 只是**默认值的来源**，
不是 tytcli 的开关；要指定一个只用于更新检查的索引，用 `TYTCLI_UPDATE_INDEX_URL`。
注意：如果那个镜像上没有 tytcli（只代理公网包的内网镜像常见），检查会安静地
变成 no-op——它把"索引上没这个包"当成"不需要升级"。

具体行为：

- **默认 10 分钟最多查一次**，结果缓存在 `$XDG_CACHE_HOME/tytcli/update-check.json`（未设 `XDG_CACHE_HOME` 时为 `~/.cache/…`）。量级：首次调用约 1s（含一次网络往返），缓存命中时可忽略不计。
- **失败开放**：索引不可达、超时、权限不足、升级命令失败 —— 全部只在 stderr 警告，然后用当前版本继续执行，退出码仍由业务结果决定。离线时也能正常用。
- **失败也会被缓存**，所以离线时不会每条命令都卡在超时上；一个 TTL 周期内只警告一次。
- **只接受纯数字版本**（`0.2.0`）。`1.0rc1` 之类的预发布版本绝不会被自动安装。
- **自动探测安装方式**：uv tool 安装 → `uv tool upgrade`；带 pip 的 venv → `pip install -U`；uv 创建的不带 pip 的 venv → `uv pip install`。**系统/共享 site-packages 拒绝自动升级**（需要提权或会污染环境），只提示手动命令。

相关环境变量：

| 变量 | 作用 |
|---|---|
| `TYTCLI_UPDATE_CHECK_TIMEOUT` | 单次检查超时（默认 5s） |
| `TYTCLI_UPDATE_INDEX_URL` | 覆盖包索引地址（优先级最高） |
| `PIP_INDEX_URL` | 未设上一项时，自动用它作为索引默认值 |

**没有关闭开关。**检查间隔固定 10 分钟，不可配置；产品契约就是“始终运行最新版”。
曾经存在的 `TYTCLI_NO_UPDATE_CHECK` / `TYTCLI_UPDATE_CHECK_INTERVAL` 已删除——
一个可调的间隔只会让安装悄悄过期而看起来健康。需要知道版本情况时用
`tytcli upgrade --check`（它绕过缓存直接联网，且不受任何开关影响）。

对自动化的含义：命令**可能**在中途升级并 re-exec，并往 stderr 写一行警告
（这是 stderr 唯一合法的写入者）。**看到警告不等于命令失败**；stdout 仍是
恰好一个 JSON 文档，退出码仍由业务结果决定。

安全约束：版本检查**绝不跟随重定向**，只信任来自包索引的 HTTP 200，3xx 一律当作
“这个索引上没有该包”。跟随重定向等于把版本判断交给索引之外的第三方：一旦有人在
别处抢注了同名包，任何基于它做的“自动升级”就会拉取并执行来路不明的代码。

## API 契约

以下是客户端依赖的服务端约定（`src/tytcli/client.py` 顶部注释是同源的一份，更详细）：

1. 只接受 `POST`；`GET` 返回 `ErrorCode -1`。
2. **必须携带 JSON body，但 `{}` 就足够**；完全不带 body 返回 `ErrorCode -1`。不需要 `userId` / `passportId` / `deviceid` / `version` / `product` / `plat`。
3. 认证只需要 `TYT_Token` Cookie；`Origin` / `Referer` / `User-Agent` / `sec-ch-ua*` / `Sec-Fetch-*` 都不需要。
4. **失败时 HTTP 状态码仍是 200**，必须读 body 里的 `Success` 字段判断成败。
5. 响应信封为 PascalCase：`Success` / `ErrorCode` / `Error`，prod 可能多出 `TotalCount` 等字段。
6. **成功时 `ErrorCode` 为 `0`、`Error` 为空字符串**（不是 `null`），业务数据在 `Data` 下。
7. **Token 与环境绑定**：同一个 Token 换一个环境会直接返回 `-100`，绝不跨环境携带。

`GetLoginInfoV2` 成功响应的 `Data` 结构：

```text
UserId, UserName, JjtId, CompanyId, CompanyName, Department, Duty,
Salesman, Phone, AccountType, HasPassword, VersionId, EmpNo,
EstPerm, EmotionPerm, Menus[]
```

`Menus` 是权限菜单树，每项带 `MenuName` / `Url` / `SubMenuList`，当前有 6 个一级菜单：基金筛选、基金研究、研报中心、路演中心、组合管理、基金工具。**它是拓展子命令时的路由地图**（如 `/fundSelect/fundFilter`、`/fundResearch/fundDetail`、`/ManageGroup/storeAsserts`），但那些是前端路由，只能作为端点线索。

已知 `ErrorCode`：

| 值 | 含义 | CLI 处理 |
|---|---|---|
| `0` | 成功（配合 `Success: true`） | 退出码 0 |
| `-100` | 登录超时，请重新登录 | `AUTH_EXPIRED`，退出码 3 |
| `-1` | 地址错误或未按规定访问 | `API_BAD_REQUEST`，退出码 1 |

## 静态检查

代码必须同时通过 **ruff** 和 **basedpyright**，两者都要 0 error：

```bash
uv run ruff check src tests scripts
uv run basedpyright
```

basedpyright 基线是 `typeCheckingMode = "strict"`，`pythonVersion = "3.11"`，覆盖 `src` 和 `tests`。

**刻意不用 basedpyright 默认的 `recommended` 档**：它会全面禁用 `Any`（`reportAny` / `reportExplicitAny`）。TYT 返回的是任意 PascalCase JSON 并且原样透传，`dict[str, Any]` 才是诚实的类型；强行改成 `object` 只会到处塞 cast，把真 bug 埋进噪音里。

`tests/` 通过 `executionEnvironments` 放宽了与 pytest fixture 惯用法冲突的规则（fixture 参数按名字注入，逐个标注毫无收益），但**仍然是 strict 级别**——`typeCheckingMode` 不是 `executionEnvironments` 的合法键，basedpyright 会警告并忽略它。

`tests/test_compat.py` 会校验这些配置没被降级（仍是 strict、仍覆盖 src+tests、dev group 里确实有两个工具）。

具体的类型写法约定：对 `Any` 做 `isinstance` 窄化后**必须紧跟一次 `cast`** 把类型
确定下来，否则 `Unknown` 会一路扩散出大量 `reportUnknown*` 噪声；**不要用
`# type: ignore` 批量压制**，多余到可以忽略的运行时检查应该被删掉，而不是用 ignore
盖住（basedpyright 的 `reportUnnecessaryIsInstance` 会告诉你哪些是死代码）。

## 测试

```bash
uv run pytest                 # 当前解释器，全程离线（respx mock），< 1s
./scripts/test-matrix.sh      # 静态检查 + 3.11~3.15 全版本，收工前必跑
```

## Python 兼容性

支持 **Python 3.11 – 3.15**。3.11 是硬下限，因为配置解析直接用标准库 `tomllib`（3.11 才引入），不引第三方 TOML 依赖。

跑全矩阵：

```bash
./scripts/test-matrix.sh                 # 3.11 3.12 3.13 3.14 3.15
./scripts/test-matrix.sh 3.11 3.15       # 只跑指定版本
PY_VERSIONS="3.12 3.13" ./scripts/test-matrix.sh
./scripts/test-matrix.sh -- -k whoami    # `--` 之后的参数透传给 pytest
```

每个版本都跑在 `uv run --isolated` 的**一次性环境**里，不会动项目的 `.venv`；解释器缺失时 uv 会自动下载。环境有缓存，重跑约 0.3s/版本。

矩阵开头会先跑一次 ruff 和 basedpyright——两者都与宿主解释器无关（ruff 是独立二进制，basedpyright 按配置里的 `pythonVersion` 分析，固定钉在 3.11 下限），所以没必要每版重复。ruff 的 `target-version = "py311"` 同样钉住了语法下限。

### 多处版本声明必须一致

| 位置 | 作用 |
|---|---|
| `pyproject.toml` `requires-python` + `classifiers` | 对外声明的支持范围 |
| `pyproject.toml` `[tool.basedpyright] pythonVersion` | 静态分析针对的语言版本 |
| `scripts/test-matrix.sh` `DEFAULT_VERSIONS` | 实际跑过的范围 |
| `scripts/test-matrix.sh` `STATIC_FLOOR` | basedpyright 解析依赖用的解释器 |
| `tests/test_compat.py` `SUPPORTED` + `FLOOR` | 上述所有的校验基准 |
| `.github/workflows/*.yml` 的 `python-version` 矩阵 | CI 实际跑的版本 |

`tests/test_compat.py` 会把这几处交叉校验，任何一处漂移都会让测试失败——避免"声明支持 3.11 但没人真的在 3.11 上跑过"。增删受支持版本时，**以上全部都要改**。

CI 在 GitHub Actions：`ci.yml` 在每次 push 上跑静态检查 + 3.11~3.15 矩阵 + 制品审计；
`release.yml` 在推送 `v*.*.*` tag 时先校验 tag / `pyproject.toml` / `__init__.py` 三处
版本号一致，再复跑审计与矩阵，最后经 Trusted Publishing（OIDC，**没有长期 token**）
发布到 pypi.org。

CI 只在推送之后给答案；**本地矩阵仍是提交前的门禁**——`./scripts/test-matrix.sh` 能
提前给出同样的结论。

该文件还会用 `ast.parse(..., feature_version=(3, 11))` 编译所有源码，拒绝 3.12+ 才有的语法（如 PEP 695 的 `type X = ...`）。**但它管不了 API 层面的兼容性**——某个标准库函数或第三方库在旧版本不存在，只有真的在那个解释器上跑一遍才能发现，所以矩阵脚本不可省。

### Windows

未测试。矩阵目前只在 macOS (arm64) 上跑过，Linux 也未单独验证。已知两处平台差异已在测试中做了 guard：POSIX 权限位（`0600`/`0700`）在 Windows 上语义不同，以及 `Path.home()` 在 Windows 读 `USERPROFILE` 而非 `HOME`。运行时代码本身用 `contextlib.suppress(OSError)` 包住 `chmod`，在 Windows 上不会崩，但**不保证配置文件真的被收紧到 0600**——在 Windows 上存放 Token 需要另行注意。

## 安全约定

- Token 不出现在源码、仓库、日志、错误详情或 stdout/stderr 中；`auth` 的输出只显示首尾各 4 位的掩码（`token_hint`）。
- 始终校验 TLS 证书，不提供跳过校验的开关。
- 不自动跟随重定向，避免凭证被带到非预期主机。
- 真实配置文件权限 `0600`，目录 `0700`。
