Metadata-Version: 2.4
Name: wxflywheel
Version: 0.14.0
Summary: Python CLI for wx-ipad automation.
Project-URL: Repository, https://github.com/vinsew/weixin
Project-URL: Issues, https://github.com/vinsew/weixin/issues
Project-URL: Documentation, https://github.com/vinsew/weixin/tree/main/cli
Project-URL: Changelog, https://github.com/vinsew/weixin/blob/main/cli/CHANGELOG.md
Keywords: wechat,automation,cli,wx-ipad
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: click<9,>=8.2
Requires-Dist: requests<3,>=2.28
Requires-Dist: packaging<26,>=23.0
Requires-Dist: filelock<4,>=3.12
Provides-Extra: dev
Requires-Dist: build<2,>=1.2; extra == "dev"
Requires-Dist: mypy<2,>=1.0; extra == "dev"
Requires-Dist: pytest<9,>=7.0; extra == "dev"
Requires-Dist: pytest-cov<8,>=4.0; extra == "dev"
Requires-Dist: ruff<1,>=0.15; extra == "dev"
Requires-Dist: twine<7,>=5.0; extra == "dev"
Requires-Dist: types-requests<3,>=2.28; extra == "dev"

# wxflywheel

Python CLI for wx-ipad automation.

## Install

Requirement: Python 3.10+

Published package:

```bash
pip install wxflywheel
```

Local development:

```bash
cd cli
make install-dev
```

## Authentication

```bash
export WXFLYWHEEL_KEY=your-api-key
export WXFLYWHEEL_HOST=http://host:8011
```

`WXFLYWHEEL_KEY` is required for API commands. Help output never requires a key.

## Usage

```bash
wxflywheel --help
wxflywheel login --help
wxflywheel login status
wxflywheel search article --keyword "微信"
wxflywheel search article --keyword "微信" --sort hot
wxflywheel search article --cursor "<next_cursor>"
wxflywheel search article-detail --url "http://mp.weixin.qq.com/s?__biz=..."
wxflywheel search sticker --keyword "咖啡"
wxflywheel search sticker --keyword "咖啡" --tab newest
wxflywheel search sticker --cursor "<next_cursor>"
wxflywheel search sticker-detail --url "http://mp.weixin.qq.com/s?__biz=..."
wxflywheel search video --keyword "美食"
wxflywheel search video --keyword "美食" --tab newest
wxflywheel search video --cursor "<next_cursor>"
wxflywheel search video-detail --export-id "export/...." --hash-doc-id "..."
wxflywheel related aggregate --keyword "社群运营"
wxflywheel search related --seed "社群运营"
wxflywheel wxindex query --keyword "社群运营"
wxflywheel wxindex related --keyword "社群运营"
python -m wxflywheel login status
```

## Current Command Surface

Curated commands are intentionally published one by one. The current public command surface is:

- `wxflywheel login status`
- `wxflywheel search article --keyword "<关键词>" [--sort default|newest|hot]`
- `wxflywheel search article --cursor "<next_cursor>"`
- `wxflywheel search article-detail --url "<完整文章链接>"`
- `wxflywheel search sticker --keyword "<关键词>" [--tab all|newest|hottest]`
- `wxflywheel search sticker --cursor "<next_cursor>"`
- `wxflywheel search sticker-detail --url "<完整贴图链接>"`
- `wxflywheel search video --keyword "<关键词>" [--tab default|newest]`
- `wxflywheel search video --cursor "<next_cursor>"`
- `wxflywheel search video-detail --export-id "<EXPORT>" --hash-doc-id "<HASH>"`
- `wxflywheel related aggregate --keyword "<关键词>"`
- `wxflywheel search related --seed "<种子词>"`
- `wxflywheel wxindex query --keyword "<关键词>"`
- `wxflywheel wxindex related --keyword "<关键词>"`
- `wxflywheel version` / `wxflywheel version show` / `wxflywheel version check`
- `wxflywheel self update`
- `wxflywheel mp article published-list|detail|content`
- `wxflywheel mp data article-stats|article-list-stats|daily-reads|hourly-reads|daily-shares|by-channel|finish-reads|mass-send-report`
- `wxflywheel mp comment articles-with-new|list|reply|delete-reply|elect`
- `wxflywheel mp material upload-image|upload-video`
- `wxflywheel mp draft create|update|delete|list|detail`
- `wxflywheel mp publish mass-send-check|mass-send-commit|direct`

## WeChat Official Account Management

`wxflywheel mp` wraps all 26 `/v1/mpoffice/*` routes as six command groups. These commands keep backend field names as-is and return the raw backend `data` payload inside the standard `{code, data, message}` envelope. The two real publish commands default to `--dry-run`, which intentionally returns `code=3132` until you opt in with `--live` and the server also allows real publish via `MPOFFICE_ALLOW_REAL_MASSSEND=true`.

- `mp article` → `/v1/mpoffice/Article/*`: `published-list`, `detail`, `content`
- `mp data` → `/v1/mpoffice/Data/*`: `article-stats`, `article-list-stats`, `daily-reads`, `hourly-reads`, `daily-shares`, `by-channel`, `finish-reads`, `mass-send-report`
- `mp comment` → `/v1/mpoffice/Comment/*`: `articles-with-new`, `list`, `reply`, `delete-reply`, `elect`
- `mp material` → `/v1/mpoffice/Material/*`: `upload-image`, `upload-video`
- `mp draft` → `/v1/mpoffice/Draft/*`: `create`, `update`, `delete`, `list`, `detail`
- `mp publish` → `/v1/mpoffice/Publish/*`: `mass-send-check`, `mass-send-commit`, `direct`

## WeChat Article Search

`wxflywheel search article` is a read-only official-account article-list command for AI Agents.

- First page: `wxflywheel search article --keyword "微信" [--sort default|newest|hot]`
- Next page: `wxflywheel search article --cursor "<next_cursor>"`

The command intentionally returns only:

- `keyword`
- `sort`
- `has_more`
- `next_cursor` (only when another page exists)
- `items[]`

Each article item keeps only:

- `article_id`
- `title`
- `summary`
- `source_name`
- `published_at_ts`
- `url`
- `cover_url`
- `read_count_text` (only when the live response includes reading-hotness text)
- `read_count` (only when a numeric reading count can be parsed from the live response)

The command does **not** fetch article bodies, comments, like/share/comment counts, or raw backend fields such as `report_extinfo_str`.

## WeChat Article Detail

`wxflywheel search article-detail` is the curated single-article detail command for AI Agents.

- Input: one full official-account article URL
- Output: one normalized payload with:
  - `article` (includes `source_ghid` workflow alias — feed to `profile list --ghid`)
  - `metrics`
  - `comments`
  - optional `comments_error`

Important detail:

- `comments.scope` is always `selected`
- `metrics.comment_count` is the total comment count
- `comments.items[]` are selected comments, not a fake "all comments" view
- The CLI caller does **not** manually chain two backend calls; it wraps the backend aggregation endpoint directly
- `article.source_ghid` is the public account's `gh_xxx` username (extracted from the protocol-level `content.user_name` field). Use it directly: `wxflywheel profile list --ghid <gh_xxx>` to fetch this account's full article history. Empty string means the backend could not extract it; agents should fall back gracefully.

## WeChat Sticker Search

`wxflywheel search sticker` is a read-only WeChat Search sticker-list command for AI Agents. Stickers (贴图) are image-centric official-account posts (`item_show_type=8`, similar to Xiaohongshu image notes), NOT emoji or GIF stickers — each sticker is an image gallery plus short body text published by an official account.

- First page: `wxflywheel search sticker --keyword "咖啡" [--tab all|newest|hottest]`
- Next page: `wxflywheel search sticker --cursor "<next_cursor>"`

`--tab` options:

- `all` (default): WeChat's default relevance ranking, no server-side filter
- `newest`: chronological newest first (`docSortType=["2"]`)
- `hottest`: highest engagement first (`docSortType=["4"]`)

`--keyword` and `--cursor` are mutually exclusive: use `--keyword` to start a new search, `--cursor` to fetch the next page. `--cursor` cannot be combined with a non-default `--tab` (the cursor already encodes the original tab).

The command intentionally returns only:

- `keyword`
- `tab`
- `has_more`
- `next_cursor` (only when another page exists)
- `items[]`

Each sticker item keeps 16 Agent-facing fields: `sticker_id`, `title`, `source_name`, `source_icon_url`, `published_at_ts`, `published_relative`, `cover_url`, `sticker_url`, `content_type_code`, `mp_doc_id`, `rank`, `report_id`, `display_style`, `source_type`, `social_tags`, `hide_control`.

The command does **not** fetch sticker detail pages, comments, image galleries, or engagement metrics — use `wxflywheel search sticker-detail` for the full 56-field sticker detail view.

## WeChat Sticker Detail

`wxflywheel search sticker-detail` is the curated single-sticker detail command for AI Agents.

- Input: one full WeChat sticker article URL (from `search sticker` result's `sticker_url` or `search article` result's `url` when `item_show_type=8`)
- Output: one normalized payload with:
  - `sticker_info` (title, account, location, publish date, originality, **source_ghid** workflow alias)
  - `pictures[]` (image gallery with width/height/dominant color/QR detection/attached products)
  - `engagement_metrics` (6-dim: read/like/watching/share/collect/comment counts)
  - `text_content`
  - `hashtags[]`
  - `topics[]` (with read counts and article counts)
  - `monetization` (tipping/ads/product window/paid subscription flags)
  - `comments` (selected comments with threaded replies)
  - optional `comments_error`

The returned fields are the web admin console's sticker detail drawer fields plus one deliberate Agent workflow alias: `sticker_info.source_ghid` (the public account's `gh_xxx` username, used to feed `wxflywheel profile list --ghid` for fetching this account's full sticker/article history). The backend raw field name `account_id` stays hidden; Agents should read `source_ghid`. This wraps the backend's `StickerDetail` aggregation endpoint which internally chains `BatchGetAppMsg(CGI 2594)` + `AppMsgCommentList(CGI 25246)`; the CLI caller does **not** manually chain these calls. The command strictly validates `item_show_type==8`; passing a long-form article URL returns an error asking the caller to use `search article-detail` instead.

## WeChat Video Search

`wxflywheel search video` is a read-only WeChat Search video-list command.

- First page: `wxflywheel search video --keyword "小龙虾" [--tab default|newest]`
- Next page: `wxflywheel search video --cursor "<next_cursor>"`

The command intentionally returns only:

- `keyword`
- `tab`
- `has_more`
- `next_cursor` (only when another page exists)
- `items[]`

Each video item keeps only:

- `video_id`
- `export_id`
- `hash_doc_id`
- `title`
- `cover_url`
- `account_name`
- `account_icon_url`
- `duration_text`
- `published_at_text`
- `published_at_ts`
- `show_type`
- `interaction_metrics`

`interaction_metrics` always contains four fields:

- `like_count`
- `comment_count`
- `forward_count`
- `thumb_count`

If a field is not present in backend `report_extinfo_str`, its value is `null` (not `0`). This keeps `null` semantics for "not reported" and still preserves literal `0` when the backend explicitly reports it as `0`.

The command does **not** fetch:

- video playback streams
- creator profiles
- comments
- detailed engagement analytics
- other tabs beyond `default` and `newest`

## WeChat Video Detail

`wxflywheel search video-detail` is the curated single Finder video detail command for AI Agents.

- Input: one `export_id` and `hash_doc_id` from a prior `wxflywheel search video` response
- Output: one normalized payload with:
  - `video`
  - `author`
  - `metrics`
  - `media`
  - `comments`
  - optional `agent_download_instructions`

`video` keeps:

- `video_id`
- `export_id`
- `hash_doc_id`
- `title` (from `videoInfo.description`, not search preview)
- `cover_url` (from `media.fullThumbUrl`)
- `width`
- `height`
- `duration_seconds`
- `published_at_ts` (unix seconds, int; no relative-time text is generated — Agents should compute locale-appropriate display from the raw timestamp)

`author` keeps:

- `nickname`
- `head_url`
- `username`
- `signature`

`metrics` keeps:

- `like_count`
- `comment_count`
- `forward_count`
- `friend_like_count`
- `thumb_count`（always `null` — the VideoDetailV2 backend does not return favorite/collect counts. If the Agent needs `thumb_count`, read it from the same video's `search video` result `interaction_metrics.thumb_count`）

`media` keeps:

- `full_video_url`
- `decode_key`

`comments` keeps:

- `total_count`
- `returned_count`
- `items[]`, each with `nickname`, `content`, `head_url`, `like_count`, `published_at_ts`, `published_at_text`, `reply_nickname`, `replies`

The command does **not** fetch `agent_download_instructions` when `full_video_url` is missing or empty — this matches frontend conditional rendering.

For downloadable videos, `agent_download_instructions` is a byte-for-byte copy of the frontend Drawer command template, including:

- exact newline count
- `decode_key` fallback `0` behavior
- no trailing newline

## WeChat Index Query

`wxflywheel wxindex query` is the curated full-data WeChat Index command for AI Agents.

- Input: one keyword via `--keyword`
- Output: the backend's full keyword payload without CLI trimming or re-interpretation
- Typical fields:
  - `keyword`
  - `current_value`
  - `trend`
  - `multichannel`
  - `channel_trend`
  - `updated_at`

Use this command when the Agent needs enough raw material to judge topic value, momentum, and channel mix. Use `wxflywheel wxindex related` only when the Agent wants short high-frequency expansion terms, not the full index dataset.

## Output Contract

Command execution emits JSON. Help output remains plain text.

- Success: stdout, exit code `0`
- Command/runtime errors: stderr, exit code `1`
- Click argument errors: stderr, exit code `2`

Schema:

```json
{
  "code": 200,
  "data": {},
  "message": "",
  "meta": {
    "cli": {
      "current_version": "0.3.0",
      "latest_version": "0.3.1",
      "update_available": true,
      "update_severity": "patch",
      "update_command": "pip install --upgrade wxflywheel",
      "self_update_command": "wxflywheel self update",
      "changelog_url": "https://pypi.org/project/wxflywheel/0.3.1/",
      "latest_release_date": "2026-04-10T12:00:00Z",
      "days_behind": 4,
      "cache_age_seconds": 120,
      "has_breaking": false,
      "python_version": "3.11.5"
    }
  }
}
```

## Version Notifications for AI Agents

**This CLI's primary users are AI Agents, not humans.** Every successful command response carries a `meta.cli` field with 12 version-related signals so Agents can autonomously detect and decide on upgrades without any out-of-band monitoring.

### How it works

1. **Every command emits `meta.cli`** — no special flag needed, it's always there.
2. **24-hour local cache** — version info is cached at `~/.cache/wxflywheel/version_check.json` to avoid hitting PyPI on every invocation.
3. **Background refresh** — when the cache is stale, a fire-and-forget subprocess worker refreshes it without blocking the current command. If subprocess spawning fails (sandboxed environments), falls back to an inline 500ms-timeout sync refresh.
4. **Breaking-change detection** — when a new version is found, the worker parses the CHANGELOG embedded in the PyPI `info.description` field (the release workflow splices `CHANGELOG.md` into `README.md` at build time so PyPI's long_description carries both). It marks `update_severity = "breaking"` if the new version's section contains `### Breaking`, `BREAKING CHANGE`, or `BREAKING:` markers. Otherwise severity is SemVer-derived: `patch` / `minor` / `major`. This is a single-request design: version lookup and CHANGELOG parsing share the same PyPI JSON API call — no second network round-trip, and no dependency on GitHub raw (the upstream repository is private and would return 404 to anonymous fetches).
5. **Stable schema** — even when offline or cache-empty, the `meta.cli` object has the same 12 keys with `null` for unknown values. Agents never need key-exists branches.

### Agent upgrade workflow

```python
# Pseudo-code for an Agent using wxflywheel
response = run_cli("wxflywheel login status")
cli_meta = response["meta"]["cli"]

if cli_meta["update_available"]:
    severity = cli_meta["update_severity"]
    if severity == "breaking":
        # Agent should read changelog first before auto-upgrading
        notify_user(
            f"wxflywheel has a BREAKING update to {cli_meta['latest_version']}. "
            f"See {cli_meta['changelog_url']}"
        )
    elif severity in ("patch", "minor"):
        # Safe to auto-upgrade
        run_cli(cli_meta["self_update_command"])  # == "wxflywheel self update"
        # NOTE: the new version only takes effect on the NEXT invocation;
        # the currently running Agent Python process keeps the old code in memory
```

### Manual version check

```bash
# Read from cache (fast, offline-OK)
wxflywheel version
wxflywheel version show

# Force refresh from PyPI (may take 1-10s)
wxflywheel version check
```

### Self-update

```bash
wxflywheel self update
```

Invokes `python -m pip install --upgrade wxflywheel` in a subprocess of the current Python interpreter. Protected by a filesystem lock (`~/.cache/wxflywheel/update.lock`) to prevent concurrent upgrades. On success, returns before/after version and a reminder that the new version only activates on the next invocation.

### Opting out (tests / CI)

Set `WXFLYWHEEL_NO_META=1` to make `meta` a deterministic minimal stub (`{"cli": {"current_version": "X.Y.Z"}}`). Useful for deterministic test snapshots. `wxflywheel`'s own test suite uses this via an autouse pytest fixture in `conftest.py`.

### Cache directory override

Set `WXFLYWHEEL_CACHE_DIR=/custom/path` to override the default `~/.cache/wxflywheel`. Useful for containerized environments where the home directory is read-only.

## Adding a Command Module

Curated commands are intentionally added one by one. Do not expose a backend route directly just because it exists.

Minimum standard for a new curated command:

- One command maps to one stable user intent.
- Read-only commands are preferred by default.
- State-changing behavior must be explicit in the command name or behind an opt-in flag.
- Output must stay on the standard `code/data/message` schema.
- Command logic must use shared helpers from [src/wxflywheel/commands/common.py](/Users/wangyiyang/Documents/jianguoyun/我的坚果云/code/ipad/cli/src/wxflywheel/commands/common.py).
- Every new command must ship with tests before registration.

Workflow:

1. Copy [src/wxflywheel/commands/_template.py](/Users/wangyiyang/Documents/jianguoyun/我的坚果云/code/ipad/cli/src/wxflywheel/commands/_template.py).
2. Replace module/action names and API parameters.
3. Add focused tests under [tests](/Users/wangyiyang/Documents/jianguoyun/我的坚果云/code/ipad/cli/tests).
4. Register the command in [src/wxflywheel/cli.py](/Users/wangyiyang/Documents/jianguoyun/我的坚果云/code/ipad/cli/src/wxflywheel/cli.py).
5. Run the release checks before merging.

## Release Checks

Repeatable release verification now lives in [Makefile](/Users/wangyiyang/Documents/jianguoyun/我的坚果云/code/ipad/cli/Makefile):

```bash
make release-check
```

This runs:

- `ruff`
- `mypy`
- `pytest`
- wheel/sdist build
- `twine check`
- editable-command help smoke
- built-wheel install smoke
- package rebuild from a clean `dist/` / `build/` state

The built package ships `py.typed`, so installed type checkers can treat `wxflywheel` as a typed package.

Optional live smoke against a real service:

```bash
export WXFLYWHEEL_HOST=http://host:8011
export WXFLYWHEEL_KEY=your-api-key
make smoke-live
make smoke-live-article KEYWORD=微信
make smoke-live-article-detail KEYWORD=微信
```

## Development

```bash
make install-dev
make release-check
```

## Release Management

- Package changelog: [CHANGELOG.md](/Users/wangyiyang/Documents/jianguoyun/我的坚果云/code/ipad/cli/CHANGELOG.md)
- Release procedure: [RELEASE.md](/Users/wangyiyang/Documents/jianguoyun/我的坚果云/code/ipad/cli/RELEASE.md)
- GitHub Actions:
  - [wxflywheel-ci.yml](/Users/wangyiyang/Documents/jianguoyun/我的坚果云/code/ipad/.github/workflows/wxflywheel-ci.yml)
  - [wxflywheel-release.yml](/Users/wangyiyang/Documents/jianguoyun/我的坚果云/code/ipad/.github/workflows/wxflywheel-release.yml)

---

## [0.14.0] - 2026-04-27

### Added

- `search article-detail` 输出 `article.source_ghid` —— 来源公众号的 `gh_xxx` username（取自 BatchGetAppMsg 协议响应的 `content.user_name`，fallback 路径从 H5 `var user_name = "..."` 抽出）。可直接喂 `wxflywheel profile list --ghid <gh_xxx>` 拉作者历史。
- `search sticker-detail` 输出 `sticker_info.source_ghid` —— 同上，桥接 search→profile 工作流。**drawer-only 契约扩展**：source_ghid 是 agent 工作流必需的"抽屉外例外字段"，后端原始字段名 `account_id` 仍被 strip（不暴露给 CLI 用户）。CLI 端做兼容映射：优先取后端 `source_ghid`，旧后端时 fallback 到 `account_id`。
- 工作流：`search article/sticker --keyword` → `search article-detail/sticker-detail --url` 读 `source_ghid` → `profile list --ghid <gh_xxx>`；视频工作流维持 `search video-detail` 拿 `author.username` → `profile videos --finder-username` 不变。

### Notes

- 后端 wx-ipad 同步加 `article.source_ghid` 字段；旧后端未同步部署时 CLI 输出空字符串（agent 据空判断"作者标识不可用"，可手动从 doc_url 抽 __biz 走 BatchGetAppMsg 拿 ghid 兜底）。

## [0.13.2] - 2026-04-27

### Fixed
- `profile list` 输出的 `account_name` 字段曾返回 ghid 而非中文 nickname
  （0.13.0/0.13.1 的"狗屎"现象，例如 `"gh_363b924965e9"` 而非 `"人民日报"`）。
  根因：`_extract_account_name` 启发式扫描 `accountInfo.field 1~4`，碰到
  field 1（恰是 ghid）就直接返回；真正的 nickname 在 `accountInfo.field 6`，
  从未被读到。
- 0.13.2 用真机抓包字节级验证后硬编码 `accountInfo.field 6` 为 nickname。
  完整 schema：
    field 1 (string) = ghid       ("gh_363b924965e9")
    field 5 (varint) = 0
    field 6 (string) = **nickname** ("人民日报") ← 取这里
    field 7 (string) = avatar URL

## [0.13.1] - 2026-04-27

### Fixed
- `profile list` 真机回归发现 0.13.0 解码 schema 误判：把 `articleTab` /
  `pictextTab` / `audioTab` / `videoList` 4 个字段当作 ``{repeated TabItem
  items = 2}``（与 `msgList` 共用 schema），导致这 4 个字段全部解出空 list。
  真机实测 schema 实际是 `{ uint32 type_flag = 1; message inner = 2 {
  repeated TabItem items = 1 } }` —— 多了一层 wrapper，且 items 是 inner
  的 field 1 而不是顶层 field 2。`msgList` 仍是无 wrapper 直接 ``repeated
  TabItem items = 1``。本版本通过 `_decode_tab_bytes(buf, has_wrapper=True/
  False)` 区分两种 schema 修复。
- 修复后真机回归：人民日报首屏返回 articles=10+ 条 / pictext=20+ 条 /
  videos=15+ 条 / msgList="全部"流可深翻。

## [0.13.0] - 2026-04-27

### Added
- `profile` command group — 按账号（ghid / finder username）维度拉历史内容，与 `search`（关键词维度）对称：
  - `profile list --ghid gh_xxx [--cursor xxx]` — 公众号主页（真机原生 BizProfileV2 协议，CGI 2899）。
    一次调用首屏返回 `articles` / `pictext` / `audios` / `videos` / `all_articles` 5 类初始数据；
    `--cursor` 仅用于"全部"TAB 翻页（msgList 累加）。返回归一化扁平 JSON：
    每个 item 含 `id` / `title` / `summary` / `url` / `cover_url` / `published_at_ts` / `item_type`，
    贴图项额外含 `multi_pic_count` / `picture_list[{url,width,height,thumb_url}]`。
  - `profile videos --finder-username v2_xxx@finder [--cursor xxx]` — 视频号主页历史
    （真机原生 FinderUserPageRequest 协议，CGI 3736）。完整翻页支持，返回 `feeds_count` 总数 +
    `items[]`（含 `video_id` / `title` / `created_at_ts` / `like_count` / `comment_count` 等）。
- `commands/profile.py` 内置轻量 protobuf wire-format 解析器（无 protobuf 库依赖）— 解
  BizProfileV2Resp 5 个 TAB raw bytes 字段（msgList/articleTab/pictextTab/audioTab/videoList）+
  TabItem.content.article 嵌套结构 + 贴图独有的 picture_list 多图 URL 列表。

### Notes
- Cursor 全部走 opaque base64 JSON + 版本号校验（与现有 `search article` 模式一致），
  Agent 透传不修改。
- 协议核对依据：`docs/account-history-capture-2026-04-27/COMPARISON.md` v3 SoT。

## [0.12.0] - 2026-04-24

### Added
- `mp` command group wrapping 26 routes under `/v1/mpoffice/*`:
  - `mp article` 3 cmds (published-list/detail/content)
  - `mp data` 8 cmds (article-stats/list-stats/daily/hourly/shares/by-channel/finish/mass-send-report)
  - `mp comment` 5 cmds (articles-with-new/list/reply/delete-reply/elect)
  - `mp material` 2 cmds (upload-image/upload-video with 6-layer local validation)
  - `mp draft` 5 cmds (create/update/delete/list/detail)
  - `mp publish` 3 cmds (mass-send-check/mass-send-commit/direct)
- `client.py`: extended `_request` / `_request_json` / `_request_json_once` signatures with `form=` / `files=` kwargs (backward-compatible, all default None)
- `client.py`: new public methods `post_form(allow_recovery)` (required kwarg) and `post_multipart` (strict once-only path, no recovery retry for large bodies)

### Changed
- `client.py`: query params merge order reversed so `self.key` cannot be overridden by user-supplied extra query (plan §3.2 V4-B)

### Security
- `mp material upload-*`: 6-layer local validation with path-stripping error messages to avoid leaking local filesystem paths to Agent logs
- `mp draft delete` / `mp comment delete-reply`: `--confirm` flag required
- `mp publish mass-send-commit` / `direct`: `--live` flag required for real send

## [0.11.2] - 2026-04-15

### Docs
- **`cli/README.md` 补齐 sticker 命令遗漏**：多维度规范审计发现整个 README 完全没有 `sticker` / `search sticker` / `search sticker-detail` 相关文档 —— v0.7.0（sticker-detail）和 v0.8.0（sticker list）发版时仅更新了代码和 CHANGELOG，漏了 README。Agent 读 README 会完全不知道 CLI 具备贴图搜索能力，违反 Agent First 表达铁律五触点之"文档宁多勿模糊"。本次补齐：① `Usage` 代码块新增 4 条 sticker 使用示例（含 default / newest tab / cursor / detail）② `Current Command Surface` 列表新增 3 条 sticker 命令行 ③ 新增 `WeChat Sticker Search` 完整段落（位于 `WeChat Article Detail` 之后 / `WeChat Video Search` 之前），说明 sticker 的语义（item_show_type=8 图文而非 emoji）、tab 选项、互斥规则、16 字段归一化清单 ④ 新增 `WeChat Sticker Detail` 完整段落，说明 8 个顶层字段、`StickerDetail` 聚合接口、`item_show_type==8` 硬校验行为。
- 纯文档修复，无代码行为改动；bump 为 0.11.2 以将更新的 README 同步到 PyPI `long_description`。

### Fixed
- **`search video` item schema 防御**：真机发现 WeChat Search BT=7 返回的视频 item 在部分关键词下（如"美食"）会把 `duration / dateTime / image / title / exportId / hashDocID` 字段置为 JSON `null`（livestream 场景或特定 schema 变种），0.11.0 及更早版本对这些字段用 `_require_string(..., allow_empty=True)` 不接受 None，会抛 "field is missing or not a string" 错误导致**整个 search video 请求失败返回 `items=[]`**。修复：对 6 个可选字符串字段用 `item.get(key) or ""` 统一 None → 空串兜底，对齐前端 `item.duration || ''` 防御模式。
- **`search video` item 的 `pubTime` / `showType` 改为 best-effort fallback 0**：后端对 livestream / 非标准视频可能不返这两个字段，原先 `_coerce_intlike` 硬校验会抛错，改为 `_try_coerce_intlike(...) or 0`（对齐前端 `?? 0` 防御和 `video.width/height/duration_seconds` 已有模式），不再阻断整条视频列表。`published_at_ts == 0` 的视频表示发布时间未知。
- 新增 regression tests: `test_video_normalize_item_with_null_optional_strings`（6 字段 None + pubTime 缺失 + showType null 场景）和 `test_video_normalize_missing_pub_time_falls_back_to_zero`（非数字 pubTime fallback 验证）。

### Added
- **`search video-detail` command**：单视频详情命令，wrap 后端 `GET /v1/Finder/VideoDetailV2` 聚合接口，一次拿到视频元数据 + 创作者信息 + 完整评论（含递归子评论）+ **`agent_download_instructions` 字段**（当视频可下载时字节级对齐前端 `VideoDetailDrawer.tsx` 的下载指令模板，教 Agent 用 `wxipad-video` 包解密）。字段规则严格对齐前端 drawer 渲染语义：标题用后端 `videoInfo.description`（真实创作者文案）而非搜索 preview；收藏数在本接口不可得（后端不返），需从 `search video` 的 `interaction_metrics.thumb_count` 获取；发布时间只返 `published_at_ts` (int)，不做相对文本格式化；评论时间格式化为 `YYYY/MM/DD HH:MM` 对齐前端 `formatTime`。视频已下架时 `agent_download_instructions` 字段不出现（对齐前端条件渲染）。

## [0.10.1] - 2026-04-15

### Fixed
- **`search video` command 协议漂移紧急修复**：0.10.0 基于 2026-04-03 的陈旧 probe 数据实现，视频 group filter 写死 `type=79` 直挂 items。但 2026-04-15 真机验证发现 WebSearch BT=7 返回结构已变为 `type=107` outer group + `subBoxes[type=84]` 嵌套，导致 0.10.0 所有 `search video` 请求返回 `items=[]`。本次修复按 live probe 更新 group filter 和 item 字段映射。
- **字段 schema 更新**：`cover_url` 从 `item.imageUrl` 改为 `item.image`（字段名后端已改）。删除不再存在的 `watch_num_text`（`item.watchNum`）和 `tags`（`item.tags`）。新增 `hash_doc_id`（`item.hashDocID`，后端已 string 化无 uint64 精度问题）、`duration_text`（`item.duration`，如 "02:56"）、`published_at_text`（`item.dateTime`，如 "31分钟前"）、`published_at_ts`（`item.pubTime`，unix timestamp int）。每 item 归一化字段总数从 10 改为 12。
- `interaction_metrics` 解析逻辑**未变**（`report_extinfo_str` 4 维字段 `like_cnt/comment_cnt/forward_cnt/thumb_cnt` 后端结构稳定）。

### Process lessons
- probe 数据有保质期。Plan 基于 probe 开发时，必须在真机验证阶段用 live probe 再次对齐 —— 不能在 unit test 通过就宣布完成。本次 Plan 的三轮 code-reviewer 审查基于 probe 做事实核查，但 probe 本身已陈旧，所有链路下游都继承了这个陈旧事实。
- 未来所有涉及外部协议的精品命令，merge 前最后一步必须跑一次 live API dump 对照 Plan §2 事实表的每一条常量值。

## [0.10.0] - 2026-04-15

### Added
- **`search video` command**: WebSearch (BT=7, Scene=101) 视频列表搜索，支持 `default` (不限) / `newest` (最新) 两个 TAB 与 cursor 翻页。每个 item 归一化为 10 个字段 (video_id/export_id/title/cover_url/account_name/account_icon_url/watch_num_text/show_type/tags/interaction_metrics)，含来自 `report_extinfo_str` 的 4 维互动数据（like/comment/forward/thumb，`null` 表示未上报区分"真 0"）。严格对齐前端 `web/ui/src/config/searchConfig.ts` 与 `VideoCard.tsx` 实现，无新协议常量。
- `search` group docstring 同步从 "four search intents" 修正为 "six search intents"，补齐现有的 `search sticker` 条目和新的 `search video` 条目（历史陈旧状态修复）。

## [0.9.0] - 2026-04-10

### Changed
- **Request timeout defaults raised** to tolerate proxy tunnel jitter and align with the new server-side read-request retry. `DEFAULT_REQUEST_TIMEOUT_SECONDS` is now `60.0` (was `30.0`); `RECOVERY_WINDOW_SECONDS` is now `65.0` (was `45.0`). The 5-second buffer between window and single timeout prevents the `clamp_request_timeout` logic from being silently truncated by ε-scale clock drift between `recovery_scope` deadline capture and the first request's `remaining` read. Worst-case end-to-end budget for a single command is now ~125s (first attempt 60s + recovery wait 65s), well within the recommended Agent Bash tool timeout of 300000ms. No breaking API changes; callers that explicitly pass `timeout=` still override the default.
- **Recovery notice** now says "最长65秒" instead of "最长45秒" to match the new window.

### Server-side companion change (not in this package)
- The Go backend (`srv/wxlink/wxreqinvoker.go`) now retries read-only search requests (`SendWebSearchRequest`, `SendBatchGetAppMsgRequest`, `SendWebFinderSearchRequest`) exactly once on network timeout (`context.DeadlineExceeded` / `net.Error.Timeout()` / `"Client.Timeout exceeded"` / `"proxy dial timeout"`) using a fresh connection. Write-type requests (send message, post comment, etc.) are explicitly excluded to prevent duplicate side effects. This shifts the worst-case single-request duration on the server from ~35s to ~50s (pacer 20s + first attempt 15s + retry 15s), which is why the CLI single-request timeout had to rise from 30s to 60s.

## [0.8.0] - 2026-04-09

### Added
- **`wxflywheel search sticker --keyword "<关键词>" [--tab all|newest|hottest]`** — a new curated sticker list search command that wraps `POST /v1/Finder/WebSearch` with `BusinessType=33562628, Scene=101`. Stickers (贴图) are photo-essay posts on WeChat official accounts (item_show_type=8), similar to Xiaohongshu image-text notes. The command parses the nested WebSearch response structure (`groups[type=107] → subBoxes[type=84] → items[]`, a two-column layout) and normalizes each item into 16 Agent-readable fields: `sticker_id`, `title`, `source_name`, `source_icon_url`, `published_at_ts`, `published_relative`, `cover_url`, `sticker_url`, `content_type_code`, `mp_doc_id`, `rank`, `report_id`, `display_style`, `source_type`, `social_tags`, `hide_control`. Three sub-tabs are exposed: `all` (default relevance), `newest` (most recent), `hottest` (most engagement). Pagination uses the same opaque base64 cursor pattern as `search article` — `{keyword, tab, has_more, next_cursor?, items[]}`. When the server returns `data: null` (e.g., no matching results), the command gracefully returns an empty list instead of raising an error.

## [0.7.0] - 2026-04-09

### Added
- **`wxflywheel wxindex query --keyword "<关键词>"`** — a new curated full-data WeChat Index command that wraps `GET /api/wxindex/{keyword}` and returns the backend's full keyword payload without CLI trimming or opinionated summarization. The intended caller is an AI Agent that needs enough raw material to judge a topic's value, momentum, recent fluctuation, and channel mix. The typical payload includes `keyword`, `current_value`, `trend`, `multichannel`, `channel_trend`, and `updated_at`.
- **`wxflywheel search sticker-detail --url "<贴图URL>"`** — a new curated single-sticker detail command that wraps `GET /v1/Gh/StickerDetail`. Stickers (贴图) are photo-essay posts on WeChat official accounts (item_show_type=8, page_type=2), similar to Xiaohongshu image-text notes — they carry an image gallery instead of a long-form HTML body. The command returns a normalized Agent-facing payload with seven stable blocks 100% aligned with the web management console's sticker detail drawer: `sticker_info`, `pictures`, `engagement_metrics`, `text_content` + `hashtags`, `topics`, `monetization`, and `comments`, plus optional `comments_error`. Use `search article-detail` for long-form articles; use this command for sticker/photo-essay posts.

### Changed
- Shared validation helpers (`_require_dict`, `_require_string`, `_require_bool`, `_coerce_intlike`) now use generic "payload" suffix in error messages instead of "article result list", since they are shared across article and sticker normalizers. The `field_name` parameter already carries the domain-specific context (e.g., `StickerDetail sticker_info.title`).

## [0.6.0] - 2026-04-09

### Added
- **`wxflywheel search article-detail`** — a new curated single-article detail command that wraps `GET /v1/Gh/ArticleDetailV2`. The command takes one full official-account article URL and returns a normalized Agent-facing payload with three stable top-level blocks: `article`, `metrics`, and `comments`, plus optional `comments_error` when the backend's selected-comment补全失败 but the article body and counters still succeeded. The command does not expose backend raw fields such as `comment_id`, `segment_comment_id`, `extra_comment_id`, or the original `BatchGetAppMsg` content blob.
- **`make smoke-live-article-detail`** — a dedicated live smoke target for the new command. It verifies `login status`, uses `search article --sort hot` to collect 3 real article URLs, then runs `search article-detail` against each and asserts the normalized `article/metrics/comments` shape plus `meta.cli` presence.

### Changed
- **Article detail now has a formal aggregated backend contract**. The CLI no longer needs to think in terms of "BatchGetAppMsg first, AppMsgCommentList second". `search article-detail` is bound to the new backend aggregation endpoint `ArticleDetailV2`, which internally still composes the raw steps but returns one stable response to the caller.
- **Comments are now explicitly labeled as selected comments everywhere in the CLI contract**. `comments.scope` is fixed to `"selected"`, `metrics.comment_count` is the total comment count, and `comments.items[]` is the selected-comment subset. This removes the previous ambiguity where the backend could return a preloaded batch while the UI phrasing looked like "all comments".
- CLI docs and release docs now include the article-detail command and the new live smoke target, keeping the published command surface aligned with the actual curated strategy.

## [0.5.0] - 2026-04-07

### Added
- **`wxflywheel search article`** — a new read-only curated command that wraps `POST /v1/Finder/WebSearch` for WeChat official-account article lists using the live-verified article search baseline (`BusinessType=2`, `Scene=101`). The command intentionally exposes only three server-verified orders (`default`, `newest`, `hot`) and returns a normalized Agent-facing payload: `{keyword, sort, has_more, next_cursor?, items[]}`. Each article item keeps only `article_id`, `title`, `summary`, `source_name`, `published_at_ts`, `url`, `cover_url`, plus `read_count_text` / `read_count` when the live response actually includes reading-hotness metadata. It does **not** fetch article bodies, comments, like/share/comment counts, or raw backend fields.
- **Opaque article paging cursor** — article pagination no longer exposes raw `offset`, `search_id`, or `cookies` to the caller. The command now returns `next_cursor`, a base64url-encoded JSON blob carrying `{v, keyword, sort, offset, search_id, cookies}`. This design is driven by live paging verification: `default` uses server-returned offsets such as `23 -> 42` (not simple `+20`), and `newest` paging breaks if `cookies` are dropped.
- **`make smoke-live-article`** — a dedicated live smoke target that validates `login status`, first-page article search for `default/newest/hot`, second-page paging for `default/newest`, forbidden-field absence, and `meta.cli` presence against a real backend.

### Changed
- CLI docs now treat `search article` as part of the intentional curated command surface. `README.md`, `RELEASE.md`, `CLAUDE.md`, and project memory are updated to describe the new command, its paging model, and the live-smoke entrypoint.
- `cli/RELEASE.md` now matches the actual publish path: GitHub Actions OIDC Trusted Publisher, not a manually managed `PYPI_API_TOKEN`.

## [0.4.3] - 2026-04-06

### Fixed
- **`parse_breaking_from_description` no longer false-positives on CHANGELOG sections that describe the breaking-detection feature**. Discovered during v0.4.2 end-to-end verification: when the parser finally ran against a real PyPI `info.description` (for the first time in 3 releases, after v0.4.2 broke the silent fallback), it flagged the v0.3.0 section as `has_breaking=True`. The reason was that v0.3.0's CHANGELOG entry documents the breaking-detection feature by naming all three marker tokens inline (`` `### Breaking` headers, `BREAKING CHANGE` phrases, or `BREAKING:` prefixes ``), and the token scanner treated these literal references as declarations. v0.4.3 adds a `_strip_markdown_code_spans` preprocessor that removes fenced code blocks (```` ```...``` ````) and inline code (`` `...` ``) from the section body before keyword scanning. Real ``### Breaking`` headings and `BREAKING CHANGE` / `BREAKING:` tokens in regular prose are still detected; only tokens visually rendered as code literals are ignored.
- **`## [Unreleased]` section no longer pollutes PyPI project page**. Release workflow now uses `awk '/^## \[[0-9]/{found=1} found'` instead of `cat CHANGELOG.md` when splicing into `README.md`, emitting lines only from the first numeric version header onward. Empty `[Unreleased]` WIP placeholders stay out of the published `long_description`.

### Added
- 4 new regression tests covering markdown code-span awareness: backtick-wrapped marker reference (false positive guard), real heading coexisting with backtick reference (must still detect), fenced code block containing marker example (must ignore), and verbatim reproduction of v0.3.0's original prose as a pinned regression fixture.
- 197 → 201 tests; 100% branch coverage preserved.

### Notes
- The v0.3.0 self-description trap existed from v0.3.0 and was silently masked by the GitHub-raw 404 fallback for 3 releases. This is a textbook "silent fallback hides real bugs" case — v0.4.2's fix to one problem (data source) immediately surfaced a dormant problem (parser not markdown-aware). Both fixes ship in consecutive patches rather than being bundled because v0.4.2 needed to exist on PyPI before end-to-end verification could run.
- Memory rule reinforced: `except X: return False` is a reverse-debugging trap. Use `Optional[T]` to distinguish "unknown" from "confirmed-no"; if `bool` is mandatory, at least emit a debug log so failures are observable.

## [0.4.2] - 2026-04-06

### Fixed
- **Breaking-change detection is no longer silently broken**. From v0.3.0 through v0.4.1 the `fetch_changelog_breaking` function fetched `raw.githubusercontent.com/vinsew/weixin/main/cli/CHANGELOG.md` to look for BREAKING markers, but the upstream repository is private and anonymous raw access returns 404 — meaning `has_breaking` has been permanently returning `False` regardless of real CHANGELOG contents since v0.3.0. Agents consuming `meta.cli.has_breaking` and `meta.cli.update_severity == "breaking"` have effectively been receiving incorrect (always-non-breaking) signals for every release. v0.4.2 replaces the GitHub raw fetch with local parsing of the PyPI `info.description` field, which the release workflow (`wxflywheel-release.yml`) now splices with README + CHANGELOG before `twine upload`. Zero new network requests — the PyPI JSON API call was already being made for version lookup, so the CHANGELOG arrives in the same response.
- TD-015 closed. The original technical-debt entry described a pre-emptive concern about unbounded CHANGELOG file growth with `stream=True` / 100KB cap as the remediation. Investigation showed the real problem was not file size but permanent 404 fallback under private repositories — the "file too large" scenario could never occur because the fetch had never succeeded in the first place. TD-015 is closed as fixed (data source swap), not as implemented-as-described.

### Changed
- **`meta.cli.changelog_url` now points to PyPI project page** (`https://pypi.org/project/wxflywheel/X.Y.Z/`) instead of the GitHub release tag URL (`https://github.com/vinsew/weixin/releases/tag/wxflywheel-vX.Y.Z`). Agents can now actually follow this URL to read the full long_description including the embedded CHANGELOG. The GitHub release URL was previously unresolvable to anonymous callers because the repository is private.
- **`fetch_pypi_latest` renamed to `fetch_pypi_meta`** and extended to return a 3-tuple `(latest_version, upload_time_iso, description)` instead of a 2-tuple. `fetch_changelog_breaking` deleted outright. New function `parse_breaking_from_description(description, version)` performs the BREAKING scan as a pure local operation (no network). `refresh_cache_from_pypi` now makes exactly one PyPI API call and parses everything from the single response.
- Release workflow (`.github/workflows/wxflywheel-release.yml`) acquires a new step between tag validation and `make release-check`: `printf '\n---\n\n' >> README.md && cat CHANGELOG.md >> README.md`. This modifies only the CI working directory — local developers continue to see the original `README.md`.

### Notes
- This patch fixes a latent functional regression that existed since v0.3.0. The regression was silent because `requests.get(...).raise_for_status()` caught the 404 and the function returned `False` (no BREAKING detected), which is indistinguishable at the cache/meta layer from "CHANGELOG says no breaking changes". Agents relying on `has_breaking` to auto-upgrade safely were technically receiving a false-negative guarantee for every release since v0.3.0 — though in practice no v0.3.x/v0.4.x release contained real BREAKING markers, so the gap had no observable incident.
- The v0.4.2 fix is discovered while investigating why TD-015 felt like "an optimization that would never trigger" — which led to recognizing the actual failure mode (private repo 404) vs. the theoretical one (file too large).

## [0.4.1] - 2026-04-06

### Fixed
- **Matrix CI coverage gap on Windows runners**. v0.4.0's `try_trigger_background_refresh` contains a `if sys.platform == "win32" ... else: start_new_session=True` branch. On macOS / Linux runners the `else` branch is taken natively so its line is counted as covered; on Windows runners the `if` branch is taken natively so `start_new_session=True` is never executed, causing branch coverage to drop to 99.76% and the 100% coverage gate to fail. v0.4.0 had `test_try_trigger_background_refresh_windows_creationflags` which monkey-patches `sys.platform = "win32"` to force the Windows branch on POSIX runners, but lacked the symmetric counterpart. v0.4.1 adds `test_try_trigger_background_refresh_posix_start_new_session` which monkey-patches `sys.platform = "linux"` to force the POSIX branch on Windows runners. Both branches are now explicitly covered on every matrix cell; 3 OS × 4 Python = 12 jobs all reach 100% branch coverage.
- **No functional change on any platform**. v0.4.0 was already functionally correct on Windows (all 190 tests passed); this patch only fixes the coverage measurement gap so the matrix CI release gate can be truly green.

### Notes
- This release was motivated by the matrix CI's FIRST Windows run revealing the gap — validating the entire v0.4.0 cross-platform strategy: we caught a Windows-specific issue without owning or testing on any Windows machine. The issue turned out to be a test-coverage methodology gap rather than a code bug, which is the best possible outcome for a first matrix run.
- Release workflow (`wxflywheel-release.yml`) is still independent of matrix CI (`wxflywheel-ci.yml`) — v0.4.0 was successfully published to PyPI despite the matrix CI failure. A future v0.4.2 may wire matrix CI as a `needs:` dependency of the release workflow to make the gate truly blocking, but this requires either merging both workflows or using `workflow_call`; logged as a consideration for next iteration.

## [0.4.0] - 2026-04-06

### Added
- **`wxflywheel doctor` diagnostic command** — runs a 6-subsystem self-check (Python runtime / platform identity / required dependencies / cache directory writability / PyPI+GitHub network reachability / subprocess spawn capability) and returns a single JSON verdict. Designed as the FIRST command an Agent should run after `pip install wxflywheel` on a new machine: surfaces missing deps, broken networks, read-only home directories, wrong Python versions, and sandbox restrictions in one shot rather than letting them emerge one-by-one during real business commands. Requires no API key and does not call any wx-ipad backend — purely a local probe safe in air-gapped environments.
- **Automatic environment fingerprint on all error responses** — every error JSON (code/data/message envelope) now carries a compact 10-field platform snapshot under `data._environment` (platform_system / platform_release / platform_machine / platform_version / python_version / python_implementation / python_executable / locale_preferred_encoding / stdout_encoding / filesystem_encoding). This lets an Agent (or the upstream wx-ipad maintainer) diagnose cross-platform failures without having to round-trip "what OS are you on?" questions. The fingerprint is compact (<300 bytes), contains no PII (no username / hostname / MAC / IP / home path), and only appears on error responses — success responses remain unchanged to minimize bandwidth cost.
- **New module `wxflywheel.environment`** — centralized environment fingerprint collection with defensive `_safe` wrapper (any attribute lookup that fails returns the string `"unknown"` instead of None, keeping the schema stable for Agent JSON parsers). Used by both `doctor` command and error payload injection.
- **GitHub Actions matrix CI (release gate)** — on `wxflywheel-v*.*.*` tag push, the CI workflow now runs the full release-check on **3 operating systems × 4 Python versions = 12 combinations** (ubuntu-latest, windows-latest, macos-latest × Python 3.10, 3.11, 3.12, 3.13). This acts as a hard gate before any PyPI publish. Regular `main` branch pushes continue to use a single fast job (ubuntu + Python 3.11, ~2 min) to preserve quick feedback and avoid burning CI minutes on every dev commit.

### Changed
- `error_payload` signature is unchanged but behavior changed: when `data` is None, the returned dict now contains `{"_environment": {...}}` instead of None. When `data` is a dict, `_environment` is merged in as an additional key. When `data` is a non-dict (list / str / primitive), it becomes `{"details": original, "_environment": {...}}`. This is technically a JSON envelope change for error responses, which is why v0.4.0 is a minor bump (not patch). Agents that only inspect `code/message` are unaffected; Agents that inspect `data` fields should see their existing keys preserved.
- `test_missing_key_exits_1_with_json_stderr` (in `test_entrypoint.py`) updated to assert the new `data._environment` shape instead of `data is None`.

### Fixed
- (Layer 1 audit) Verified that no file I/O, subprocess call, JSON serialization, path manipulation, or string encoding in the wxflywheel source depends on the system locale, path separator, line ending convention, or timezone. All `open()` / `Path.open()` calls explicitly pass `encoding="utf-8"`; all `subprocess.run` / `Popen` calls use list-form args (no `shell=True`); all `json.dumps` uses `ensure_ascii=False`; all datetimes use `timezone.utc`. No fixes needed in this release — the audit confirmed v0.3.1 was already cross-platform clean at the source level.

### Notes
- This release completes the **5-layer cross-platform compatibility strategy** for wxflywheel:
  1. Code layer: no implicit platform dependencies (verified by audit in v0.4.0)
  2. CI matrix: 3 OS × 4 Python on release gate
  3. Runtime diagnostic: `wxflywheel doctor` command
  4. Passive telemetry: automatic `_environment` on error responses
  5. (Optional) Alpine docker test — logged but not implemented
- Agents can now rely on wxflywheel working on any combination of the 3 major OSes × 4 supported Python versions, and can use `wxflywheel doctor` to probe edge environments (sandboxes, containers, minimal Python distros) before committing to use the CLI.

## [0.3.1] - 2026-04-06

### Fixed
- `self update` docstring now documents error code `-3` (cache directory creation failure) which was previously undocumented, leaving Agents unable to interpret the error. Also corrected the docstring's claim that pip failures return `success=false` on stdout — in reality pip failures (code `-7`) are emitted on stderr with exit code 1, matching the standard error contract. The rewritten docstring tells Agents explicitly: "There is no success=false path on stdout — any failure uses stderr + exit 1 + code/data/message envelope; Agents parsing output must check exit code first and read stderr on non-zero."
- `self update` subprocess calls (`pip install --upgrade wxflywheel` and `pip show wxflywheel`) now use `encoding="utf-8", errors="replace"` instead of `text=True`. The previous `text=True` relied on `locale.getpreferredencoding(False)`, which on Windows CP1252 / CP936 / other non-UTF-8 locales could raise an uncaught `UnicodeDecodeError` when pip's output contained characters outside the local encoding — breaking the JSON output contract. With explicit UTF-8 + replace, non-decodable bytes become U+FFFD and the JSON envelope survives.
- `try_trigger_background_refresh` now wraps the fire-and-forget `subprocess.Popen` call in a `warnings.catch_warnings()` block that locally suppresses `ResourceWarning`. CPython 3.12+ emits `ResourceWarning: subprocess <pid> is still running` when a `Popen` wrapper is garbage collected before `wait()` is called, even when the child process is detached (which is exactly our design). The suppression is local to this single call site; no other warnings are affected.
- `test_all_existing_commands_still_return_three_field_envelope` no longer deletes the `WXFLYWHEEL_NO_META` environment variable. The previous test body had a reversed comment claiming "use stub meta via conftest default" while `monkeypatch.delenv` actually did the opposite — removing the stub flag set by the autouse conftest fixture and causing `build_meta` to hit the real code path, which in CI (where no cache file exists) would spawn an actual version-check worker subprocess and leave side effects. The test now keeps the autouse fixture's `WXFLYWHEEL_NO_META=1` setting so `build_meta` returns a deterministic stub, preserving test isolation.

### Notes
- No behavioral changes visible to existing users on the success path. v0.3.0 and v0.3.1 are interchangeable for Agents already using the `meta.cli` field in the success case; v0.3.1 is strictly more robust on the error path (Windows locales, Python 3.12+, CI isolation).
- All fixes originated from a post-release rigorous code review against the project-level rules (`memory/ai-era-development-philosophy.md`, `memory/agent-first-expression.md`). The review surfaced 2 Important issues and 3 Suggestions; 4 of the 5 are fixed in this patch; the 5th (CHANGELOG size unbounded fetch) is logged to `docs/tech-debt.md` as a pre-emptive concern with no current symptom.

## [0.3.0] - 2026-04-06

### Added
- **Agent-aware version notification system**. Every command response now includes a `meta.cli` field with 12 version-related signals (`current_version`, `latest_version`, `update_available`, `update_severity`, `update_command`, `self_update_command`, `changelog_url`, `latest_release_date`, `days_behind`, `cache_age_seconds`, `has_breaking`, `python_version`). Agents using wxflywheel can read this field from any command's JSON response to autonomously detect update availability and decide whether to upgrade.
- **`wxflywheel version` command group**. New `version` subcommand (alias: `version show`) displays the cli meta schema from the local 24-hour cache (fast, offline-tolerant). New `version check` subcommand forces a fresh synchronous PyPI query that bypasses the cache for Agents that need up-to-the-second data.
- **`wxflywheel self update` command**. New `self` command group with `self update` subcommand that invokes `python -m pip install --upgrade wxflywheel` in a subprocess of the currently running Python interpreter. Protected by a cross-process file lock (`~/.cache/wxflywheel/update.lock`) with a 60-second acquisition timeout to prevent concurrent upgrades from corrupting the package. Returns before/after version, pip stdout/stderr tails, and a hint that the new version only takes effect on the next invocation (current process keeps old code in memory).
- **Background cache refresh worker**. A fire-and-forget subprocess (`python -m wxflywheel._version_check_worker`) is spawned automatically when the 24-hour cache is expired or missing. On platforms where subprocess spawning fails (OSError), falls back to an inline synchronous refresh with a 500ms hard timeout. The main command is never blocked on network I/O by the version-check subsystem.
- **Breaking-change detection from CHANGELOG**. When a new version is detected, the worker fetches the GitHub-hosted CHANGELOG and searches the new version's section for `### Breaking` headers, `BREAKING CHANGE` phrases, or `BREAKING:` prefixes. If found, `update_severity` is set to `"breaking"` instead of the SemVer-derived `patch/minor/major`.

### Changed
- New runtime dependencies: `packaging>=23.0,<26` (for PEP 440 version comparison) and `filelock>=3.12,<4` (for cross-process update lock). Both are small, widely-used PyPA-maintained packages.
- `emit_payload` now accepts an `include_meta: bool = True` keyword argument. Meta injection is wrapped in a broad except so any version-check failure (network, filesystem, parsing) is silently downgraded to an absent `meta` field — the primary `code/data/message` response contract is never broken by version-check internals.
- Ruff `per-file-ignores` now also covers `version_check.py` and `_version_check_worker.py` for the same Agent First expression-rule reasons as `cli.py` and `commands/`.

### Compatibility
- The `code/data/message` three-field JSON envelope is unchanged and fully backwards compatible. `meta` is an additive optional field. Agents that parse only the original three fields continue to work without modification. Agents that parse the new `meta` field get a stable 12-key schema where unknown values are `null` (not missing keys) for deterministic parsing.

## [0.2.1] - 2026-04-05

### Changed
- Rewrote all 8 command and group docstrings (4 groups + 4 actions) to comply with the project-level **Agent First expression rule**. Every docstring now includes: domain marker (WeChat), action verb, data source, concrete input/output example, and a contrasting qualifier that disambiguates from sibling commands. No behavioral changes — CLI surface, arguments, JSON output contract, and exit codes are identical to v0.2.0.
- `login status` now discloses the full login state enum (`online / reconnecting / offline / not_login / logout / unknown`) and response fields (`loginState, loginErrMsg, loginJournal, targetIp`).
- `wxindex related` vs `search related` now carry explicit cross-references telling Agents which to pick: WeChat Index returns short high-frequency terms (`咖啡机 / 咖啡色`), WeChat Search returns long-tail user queries (`附近咖啡店 / 星巴克咖啡`), and the two sources are non-overlapping in practice.
- `related aggregate` docstring now documents all 8 response fields (`related / from_search / from_wxindex / both_sources / search_only / wxindex_only / errors`) and partial-failure resilience behavior.

## [0.2.0] - 2026-04-05

### Added
- Added `wxflywheel related aggregate --keyword "<关键词>"`, which merges Search and WxIndex related keywords with cross-source deduplication (both → search-only → wxindex-only order).
- Added `wxflywheel search related --seed "<种子词>"`, which calls the `RelatedKeywords` aggregation API and returns `{seed, related[]}` inside the standard JSON envelope.
- Added `wxflywheel wxindex related --keyword "<关键词>"`, which calls the `wxindexsug` endpoint and returns `{keyword, related[]}` inside the standard JSON envelope.

### Fixed
- `main()` now normalizes non-integer `SystemExit` values into standard JSON stderr output instead of silently returning exit code `1`.
- Click parser errors are now normalized through the CLI entrypoint and no longer bypass the `code/data/message` contract.
- `make release-check` now rebuilds from a clean package artifact state, so stale wheels in `dist/` no longer break wheel smoke tests.
- The published wheel now includes `wxflywheel/py.typed`, matching the package's typed metadata.

## [0.1.0] - 2026-04-04

### Added
- Standardized `wxflywheel` package foundation with `src/` layout and installable console entry point.
- Unified JSON output contract on `code/data/message`.
- Shared command authoring helpers and repeatable release checks.
- Buildable sdist/wheel release flow and repository CI coverage for the CLI package.
