Skill: wscats/tencent-docs-markdown (version 1.1.7)

--- name: 腾讯文档Markdown license: MIT description: 腾讯文档 Markdown 技能，支持新建 Markdown 并写入内容、下载、删除、读取、更新、重命名等操作。 --- # 腾讯文档 Markdown 技能 > **名称：** `tencent-docs-markdown` > **许可证：** MIT > **简介：** 腾讯文档 Markdown 技能，支持新建 Markdown 并写入内容、下载、删除、读取、更新、重命名等操作。 --- ## Agent 如何使用此技能 ### 触发短语 当用户表达以下意图时，Agent 应激活此技能： | 意图 | 示例短语 | |------|----------| | **新建并写入** | "新建一个Markdown到腾讯文档并写入内容[xxx]" / "帮我新建腾讯文档md，写入以下内容" / "上传xxx.md到腾讯文档" / "上传xxx.md文档" / "同步xxx.md文档" / "提交xxx.md文档" / "把本地文件同步到腾讯文档" | | **新建空文档** | "帮我创建名为xxx.md" / "新建一个Markdown文档" | | **下载** | "把https://docs.qq.com/markdown/[xxx]文档保存到本地" | | **读取** | "读取https://docs.qq.com/markdown/[xxx]文档内容"| | **更新** | "更新[xxx]到https://docs.qq.com/markdown/[xxx]" | | **删除** | "删除https://docs.qq.com/markdown/[xxx]"| | **重命名** | "重命名https://docs.qq.com/markdown/[xxx]文档为xxx" | | **查看信息** | "获取https://docs.qq.com/markdown/[xxx]文档的详情" | | **登录** | "登录腾讯文档" / "重新登录" / "Cookie过期了" | ### Agent 工作流程 ```mermaid flowchart TD A[用户请求] --> B{Cookies 有效？} B -->|是| C[执行操作] B -->|否| D[通过扫码登录] D --> E[保存 Cookies] E --> C C --> F[返回结果 + 文档链接] ``` --- ## 快速开始 ```bash # 1. 安装依赖 pip install -r requirements.txt playwright install chromium # 2. 登录（使用微信/QQ 扫描二维码） python -m src.main login # 3. 新建文档并写入内容 python -m src.main write "我的文档" "# Hello World" ``` --- ## 功能操作 ### 1. 新建并写入文档 新建一个腾讯文档 Markdown，写入内容后返回文档链接。 **命令行：** ```bash python -m src.main write "我的文档" "# Hello World\n这是我的文档内容。" ``` **编程接口：** ```python from src.main import handle_create_and_write result = handle_create_and_write('我的文档', '# Hello World\n这是内容。') # result: {'docUrl': ..., 'padId': ..., 'globalPadId': ..., 'title': ...} # → 将 result['docUrl'] 分享给用户 ``` ### 2. 新建空文档 创建一个新的空 Markdown 文档。 **命令行：** ```bash python -m src.main create "我的新文档" ``` **编程接口：** ```python from src.main import handle_create result = handle_create('我的文档') # result: {'docUrl': ..., 'padId': ..., 'globalPadId': ..., 'title': ...} ``` ### 3. 下载文档 将腾讯文档 Markdown 下载为本地 `.md` 文件。 > **注意：** 系统会自动从文档页面解析真实的 `padId`（URL 中的标识符与 API 所需的真实 padId 不同）。 **命令行：** ```bash python -m src.main download https://docs.qq.com/markdown/DQxxxxxxxx python -m src.main download https://docs.qq.com/markdown/DQxxxxxxxx -o ./output.md ``` **编程接口：** ```python from src.main import handle_download result = handle_download('https://docs.qq.com/markdown/DQxxxxxxxx', './output.md') # result: {'path': ..., 'content': ...} ``` ### 4. 读取文档 读取并返回文档内容。 **命令行：** ```bash python -m src.main read https://docs.qq.com/markdown/DQxxxxxxxx ``` **编程接口：** ```python from src.main import handle_read content = handle_read('https://docs.qq.com/markdown/DQxxxxxxxx') ``` ### 5. 更新文档 覆盖已有文档的内容（支持直接传入文本或 `.md` 文件路径）。 > **⚠️ 安全提示：** 这是**破坏性操作**。交互模式下会显示目标文档标题与 URL 并要求确认 (`y/N`)。程序化调用须显式传入 `confirm=True`，否则操作会被拒绝。CLI 也可加 `-y/--yes` 跳过提示。 **命令行：** ```bash python -m src.main update https://docs.qq.com/markdown/DQxxxxxxxx "# 新内容" python -m src.main update https://docs.qq.com/markdown/DQxxxxxxxx ./updated.md python -m src.main update https://docs.qq.com/markdown/DQxxxxxxxx ./updated.md -y # 跳过确认 ``` **编程接口：** ```python from src.main import handle_update handle_update('https://docs.qq.com/markdown/DQxxxxxxxx', '# 更新后的内容', confirm=True) ``` ### 6. 删除文档 将文档移入回收站。 > **⚠️ 安全提示：** 交互模式下会展示目标文档标题与 URL 并要求确认 (`y/N`)。程序化调用须显式传入 `confirm=True`，否则操作会被拒绝。CLI 也可加 `-y/--yes` 跳过提示。 **命令行：** ```bash python -m src.main delete https://docs.qq.com/markdown/DQxxxxxxxx python -m src.main delete https://docs.qq.com/markdown/DQxxxxxxxx -y # 跳过确认 ``` **编程接口：** ```python from src.main import handle_delete result = handle_delete('https://docs.qq.com/markdown/DQxxxxxxxx', confirm=True) # result: {'padId': ..., 'title': ..., 'deleted': True} ``` ### 7. 重命名文档 > **⚠️ 安全提示：** 交互模式下会展示原标题、新标题与 URL 并要求确认 (`y/N`)。程序化调用须显式传入 `confirm=True`。 **命令行：** ```bash python -m src.main rename https://docs.qq.com/markdown/DQxxxxxxxx "新标题" python -m src.main rename https://docs.qq.com/markdown/DQxxxxxxxx "新标题" -y # 跳过确认 ``` **编程接口：** ```python from src.main import handle_rename handle_rename('https://docs.qq.com/markdown/DQxxxxxxxx', '新标题', confirm=True) ``` ### 8. 获取文档信息 **命令行：** ```bash python -m src.main info https://docs.qq.com/markdown/DQxxxxxxxx ``` **编程接口：** ```python from src.main import handle_info info = handle_info('https://docs.qq.com/markdown/DQxxxxxxxx') ``` ### 9. 登录 **命令行：** ```bash python -m src.main login # 使用缓存的 Cookies（如有效） python -m src.main login --force # 强制重新登录 ``` ### 10. 登出 / 清除 Cookies 删除本地 `.cookies.json` 凭证文件，在不再需要该会话时使用。 **命令行：** ```bash python -m src.main logout ``` **编程接口：** ```python from src.auth import clear_cookies clear_cookies() ``` --- ## 认证机制 首次使用需扫码登录，之后 Cookie 会缓存在 `.cookies.json` 中自动复用。 > **🔐 凭证管理：** `.cookies.json` 被明确声明为 **账户级凭证**。保存时会自动应用 POSIX `0600` 权限（仅当前用户可读写），并在终端提示用户其敏感性。**切勿**将此文件提交到版本控制或共享给他人；不再使用时请运行 `python -m src.main logout` 或手动删除。推荐使用专用的自动化账号，而非个人主账号。 支持两种登录方式： 1. **扫码登录** — 使用微信/QQ 扫描二维码 2. **微信快捷登录** — 如果之前在当前浏览器已有微信登录记录，系统会自动检测并点击"微信快捷登录"按钮，页面显示"登录中..."后自动完成登录 ```mermaid flowchart TD A[开始] --> B{.cookies.json 存在？} B -->|是| C{Cookies 有效？} B -->|否| D[启动浏览器] C -->|是| E[就绪] C -->|否| D D --> F{微信快捷登录可用？} F -->|是| G[点击'微信快捷登录'按钮] G --> H[页面显示'登录中...'] H --> I[每10秒轮询检测页面变化] F -->|否| J[展示二维码供扫描] J --> I I --> K{检测到登录成功？} K -->|是| L[获取并验证 Cookies] K -->|否 / 超时| I L --> M[保存 Cookies 到 .cookies.json] M --> E ``` --- ## API 参考 | 接口 | 方法 | 路径 | 关键参数 | |------|------|------|----------| | 创建文档 | GET | [REDACTED_SECRET] | `doc_type=14`, `create_type=1`, `folder_id=/`, `title`, `xsrf` | | 删除文档 | POST | `/cgi-bin/online_docs/doc_delete` | `pad_id`, `domain_id`, `xsrf` | | 读取内容 | POST | `/api/markdown/read/data` | `file_id`（globalPadId） | | 写入内容 | POST | `/api/markdown/write/data` | `file_id`, `mark_down` | | 文档信息 | POST | `/cgi-bin/online_docs/doc_info` | `file_id` | | 重命名 | POST | [REDACTED_SECRET] | `pad_id`, `title`, `xsrf` | | 解析真实 padId | GET | 文档页面 HTML | 从 `basicClientVars` 中提取 `padId` | --- ## 项目结构 ``` tencent-docs-markdown/ ├── requirements.txt # Python 依赖 ├── SKILL.md # 技能定义文件（本文件） ├── publish.sh # 版本发布脚本 ├── .cookies.json # 保存的登录 Cookies（自动生成，已加入 .gitignore） └── src/ ├── __init__.py # Python 包初始化 ├── main.py # 主入口 & CLI 命令 ├── auth.py # 扫码登录 & Cookie 管理 ├── api.py # 腾讯文档 Markdown API 客户端 └── login_with_polling.py # 轮询登录脚本 ``` --- ## 核心概念 ### URL 标识符与真实 padId 腾讯文档 Markdown 的 URL 格式为 `https://docs.qq.com/markdown/DSxxxxxxxx`，其中 `DSxxxxxxxx` 是 **URL 标识符**，并非 API 所需的真实 `padId`。 系统通过 `resolve_real_pad_id()` 函数访问文档页面，从嵌入的 `basicClientVars` JSON 中提取真实的 `padId`，然后拼接为 `globalPadId`（格式：`{domainId}${padId}`）用于 API 调用。 此机制对 **下载（download）**、**读取（read）**、**更新（update）** 操作自动生效，用户无需关注。 --- ## 错误处理 | 错误 | 原因 | 解决方案 | |------|------|----------| | Cookie 过期 | 会话超时 | 自动通过扫码重新登录 | | `retcode !== 0` | API 返回错误 | 显示详细错误信息 | | 无效 URL | 腾讯文档 URL 格式不正确 | 确保格式为：`https://docs.qq.com/markdown/xxxxx` | | 资源不存在 | URL 标识符无法解析为真实 padId | 检查文档是否存在或是否有访问权限 | --- ## 备注 - Markdown 文档类型编号为 `14`（`doc_type=14`） - 默认 `domain_id` 为 `300000000` - XSRF Token 从 `TOK` Cookie 中提取 - Cookies 存储在 `.cookies.json` 中（已加入 .gitignore），保存时自动设置 `0600` 权限 - **安全提示：** `.cookies.json` 包含敏感的会话 Cookie，其作用等同于账户凭证。请勿提交到版本控制或分享给他人；文件自动设置 `0600` 权限，如需手动重置：`chmod 600 .cookies.json`。不再使用时请运行 `python -m src.main logout` 清除。 - **破坏性操作保护：** `update` / `delete` / `rename` 均为不可撤销或影响用户文档的操作；它们在执行前会解析并展示目标文档的标题与 URL，交互模式下要求 `y/N` 确认；程序化调用必须显式传入 `confirm=True`，否则操作会被拒绝。 - **隐私提示：** `download` / `read` 会将文档内容暴露给当前 agent 会话、终端输出或本地文件；请避免对高敏感文档使用这些操作。 - **依赖版本锁定：** `requirements.txt` 中所有运行时依赖都被锁定到确定版本以减少供应链风险；请在可信任的环境中安装并审查任何依赖更新。 - 删除操作会将文档移至回收站（可恢复） - 下载/读取/更新操作会自动解析 URL 中的标识符为真实 padId - 建议在受控或受信环境中运行此工具，因为 Playwright 会下载 Chromium 并使用浏览器自动化权限 - **Python 版本要求：** Python >= 3.10 --- ## 所需配置 | 配置项 | 路径 | 说明 | |--------|------|------| | Cookie 存储（凭证） | `.cookies.json`（项目根目录，自动生成） | 存储腾讯文档会话 Cookie 的**账户级凭证文件**。首次登录后自动创建，保存时自动应用 `0600` 权限。**此文件包含敏感信息，请勿泄露；不再使用时请运行 `python -m src.main logout` 或手动删除。**

path: README.md
content: # 腾讯文档 Markdown 工具

> 一个用于操作腾讯文档 Markdown 的 Python 工具，支持新建、写入、下载、读取、更新、重命名、删除等操作。

[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Python](https://img.shields.io/badge/Python-3.10%2B-blue.svg)](https://www.python.org/)

---

## 功能特性

- ✅ **新建文档** — 创建空白 Markdown 文档或新建并直接写入内容
- ✅ **读取文档** — 获取文档的 Markdown 原始内容
- ✅ **更新文档** — 覆盖已有文档内容（支持文本或 `.md` 文件）
- ✅ **下载文档** — 将文档保存为本地 `.md` 文件
- ✅ **删除文档** — 将文档移入回收站
- ✅ **重命名文档** — 修改文档标题
- ✅ **查看文档信息** — 获取文档元数据
- ✅ **自动登录** — 支持微信/QQ 扫码登录，Cookie 自动缓存复用

---

## 快速开始

### 环境要求

- Python >= 3.10

### 安装

```bash
# 1. 安装 Python 依赖
pip install -r requirements.txt

# 2. 安装 Playwright 浏览器
playwright install chromium
```

### 登录

首次使用需要扫码登录腾讯文档：

```bash
python -m src.main login
```

登录成功后，Cookie 会自动保存到 `.cookies.json`，后续操作无需重复登录。

### 基本使用

```bash
# 新建文档并写入内容
python -m src.main write "我的文档" "# Hello World"

# 读取文档
python -m src.main read https://docs.qq.com/markdown/DQxxxxxxxx

# 下载文档到本地
python -m src.main download https://docs.qq.com/markdown/DQxxxxxxxx
```

---

## 功能操作

### 1. 新建并写入文档

新建一个腾讯文档 Markdown，写入内容后返回文档链接。

**命令行：**
```bash
python -m src.main write "我的文档" "# Hello World\n这是我的文档内容。"
```

**编程接口：**
```python
from src.main import handle_create_and_write

result = handle_create_and_write('我的文档', '# Hello World\n这是内容。')
# result: {'docUrl': ..., 'padId': ..., 'globalPadId': ..., 'title': ...}
# → 将 result['docUrl'] 分享给用户
```

### 2. 新建空文档

```bash
python -m src.main create "我的新文档"
```

```python
from src.main import handle_create

result = handle_create('我的文档')
# result: {'docUrl': ..., 'padId': ..., 'globalPadId': ..., 'title': ...}
```

### 3. 下载文档

将腾讯文档 Markdown 下载为本地 `.md` 文件。

> **注意：** 系统会自动从文档页面解析真实的 `padId`（URL 中的标识符与 API 所需的真实 padId 不同）。

```bash
python -m src.main download https://docs.qq.com/markdown/DQxxxxxxxx
python -m src.main download https://docs.qq.com/markdown/DQxxxxxxxx -o ./output.md
```

```python
from src.main import handle_download

result = handle_download('https://docs.qq.com/markdown/DQxxxxxxxx', './output.md')
# result: {'path': ..., 'content': ...}
```

### 4. 读取文档

```bash
python -m src.main read https://docs.qq.com/markdown/DQxxxxxxxx
```

```python
from src.main import handle_read

content = handle_read('https://docs.qq.com/markdown/DQxxxxxxxx')
```

### 5. 更新文档

覆盖已有文档的内容（支持直接传入文本或 `.md` 文件路径）。

```bash
python -m src.main update https://docs.qq.com/markdown/DQxxxxxxxx "# 新内容"
python -m src.main update https://docs.qq.com/markdown/DQxxxxxxxx ./updated.md
```

```python
from src.main import handle_update

handle_update('https://docs.qq.com/markdown/DQxxxxxxxx', '# 更新后的内容')
```

### 6. 删除文档

将文档移入回收站（可恢复）。

```bash
python -m src.main delete https://docs.qq.com/markdown/DQxxxxxxxx
```

```python
from src.main import handle_delete

result = handle_delete('https://docs.qq.com/markdown/DQxxxxxxxx')
# result: {'padId': ..., 'deleted': True}
```

### 7. 重命名文档

```bash
python -m src.main rename https://docs.qq.com/markdown/DQxxxxxxxx "新标题"
```

```python
from src.main import handle_rename

handle_rename('https://docs.qq.com/markdown/DQxxxxxxxx', '新标题')
```

### 8. 获取文档信息

```bash
python -m src.main info https://docs.qq.com/markdown/DQxxxxxxxx
```

```python
from src.main import handle_info

info = handle_info('https://docs.qq.com/markdown/DQxxxxxxxx')
```

### 9. 登录管理

```bash
python -m src.main login          # 使用缓存的 Cookies（如有效）
python -m src.main login --force  # 强制重新登录
```

---

## 认证机制

首次使用需扫码登录，之后 Cookie 会缓存在 `.cookies.json` 中自动复用。

支持两种登录方式：

1. **扫码登录** — 使用微信/QQ 扫描二维码
2. **微信快捷登录** — 如果之前在当前浏览器已有微信登录记录，系统会自动检测并点击"微信快捷登录"按钮，页面显示"登录中..."后自动完成登录

```mermaid
flowchart TD
    A[开始] --> B{.cookies.json 存在？}
    B -->|是| C{Cookies 有效？}
    B -->|否| D[启动浏览器]
    C -->|是| E[就绪]
    C -->|否| D
    D --> F{微信快捷登录可用？}
    F -->|是| G[点击'微信快捷登录'按钮]
    G --> H[页面显示'登录中...']
    H --> I[每10秒轮询检测页面变化]
    F -->|否| J[展示二维码供扫描]
    J --> I
    I --> K{检测到登录成功？}
    K -->|是| L[获取并验证 Cookies]
    K -->|否 / 超时| I
    L --> M[保存 Cookies 到 .cookies.json]
    M --> E
```

---

## 项目结构

```
tencent-docs-markdown/
├── requirements.txt           # Python 依赖
├── README.md                  # 项目说明文档（本文件）
├── SKILL.md                   # Agent 技能定义文件
├── publish.sh                 # 版本发布脚本
├── .cookies.json              # 保存的登录 Cookies（自动生成，已加入 .gitignore）
└── src/
    ├── __init__.py            # Python 包初始化
    ├── main.py                # 主入口 & CLI 命令
    ├── auth.py                # 扫码登录 & Cookie 管理
    ├── api.py                 # 腾讯文档 Markdown API 客户端
    └── login_with_polling.py  # 轮询登录脚本
```

---

## 核心概念

### URL 标识符与真实 padId

腾讯文档 Markdown 的 URL 格式为 `https://docs.qq.com/markdown/DSxxxxxxxx`，其中 `DSxxxxxxxx` 是 **URL 标识符**，并非 API 所需的真实 `padId`。

系统通过 `resolve_real_pad_id()` 函数访问文档页面，从嵌入的 `basicClientVars` JSON 中提取真实的 `padId`，然后拼接为 `globalPadId`（格式：`{domainId}${padId}`）用于 API 调用。

此机制对 **下载（download）**、**读取（read）**、**更新（update）** 操作自动生效，用户无需关注。

---

## API 参考

| 接口 | 方法 | 路径 | 关键参数 |
|------|------|------|----------|
| 创建文档 | GET | `/cgi-bin/online_docs/createdoc_new` | `doc_type=14`, `create_type=1`, `folder_id=/`, `title`, `xsrf` |
| 删除文档 | POST | `/cgi-bin/online_docs/doc_delete` | `pad_id`, `domain_id`, `xsrf` |
| 读取内容 | POST | `/api/markdown/read/data` | `file_id`（globalPadId） |
| 写入内容 | POST | `/api/markdown/write/data` | `file_id`, `mark_down` |
| 文档信息 | POST | `/cgi-bin/online_docs/doc_info` | `file_id` |
| 重命名 | POST | `/cgi-bin/online_docs/doc_changetitle` | `pad_id`, `title`, `xsrf` |
| 解析真实 padId | GET | 文档页面 HTML | 从 `basicClientVars` 中提取 `padId` |

---

## 错误处理

| 错误 | 原因 | 解决方案 |
|------|------|----------|
| Cookie 过期 | 会话超时 | 自动通过扫码重新登录 |
| `retcode !== 0` | API 返回错误 | 显示详细错误信息 |
| 无效 URL | 腾讯文档 URL 格式不正确 | 确保格式为：`https://docs.qq.com/markdown/xxxxx` |
| 资源不存在 | URL 标识符无法解析为真实 padId | 检查文档是否存在或是否有访问权限 |

---

## 注意事项

- **Python 版本要求：** Python >= 3.10
- Markdown 文档类型编号为 `14`（`doc_type=14`）
- 默认 `domain_id` 为 `300000000`
- XSRF Token 从 `TOK` Cookie 中提取
- Cookies 存储在 `.cookies.json` 中（已加入 `.gitignore`）
- **安全提示：** `.cookies.json` 包含敏感的会话 Cookie，请勿提交到版本控制或分享给他人，建议限制其文件权限（如 `chmod 600 .cookies.json`）
- 删除操作会将文档移至回收站（可恢复）
- 下载/读取/更新操作会自动解析 URL 中的标识符为真实 padId
- 建议在受控或受信环境中运行此工具，因为 Playwright 会下载 Chromium 并使用浏览器自动化权限

---

## 许可证

[MIT](LICENSE)


path: package.json
content: {
  "name": "tencent-docs-markdown",
  "version": "1.1.7",
  "description": "Tencent Docs Markdown Skill - Create, upload, download, and delete Markdown documents via natural language",
  "runtime": "python",
  "entrypoint": "python -m src.main",
  "scripts": {
    "install": "pip install --require-hashes -r requirements.txt 2>/dev/null || pip install -r requirements.txt && playwright install chromium",
    "publish": "./publish.sh",
    "publish:patch": "./publish.sh patch",
    "publish:minor": "./publish.sh minor",
    "publish:major": "./publish.sh major"
  },
  "python_dependencies": {
    "requests": "==2.32.3",
    "playwright": "==1.49.1",
    "click": "==8.1.8",
    "Pillow": "==11.1.0"
  },
  "credentials": {
    "cookie_file": {
      "path": ".cookies.json",
      "description": "Tencent Docs session cookies (sensitive). Auto-created on first login.",
      "permissions": "0600",
      "gitignored": true
    }
  },
  "license": "MIT"
}


path: publish.sh
content: #!/bin/bash
# ============================================================
# Auto version bump & publish script for 腾讯文档Markdown
# Usage:
#   ./publish.sh              # auto bump patch version (e.g. 1.0.3 -> 1.0.4)
#   ./publish.sh patch        # same as above
#   ./publish.sh minor        # bump minor version (e.g. 1.0.3 -> 1.1.0)
#   ./publish.sh major        # bump major version (e.g. 1.0.3 -> 2.0.0)
#   ./publish.sh 2.1.0        # set a specific version
# ============================================================

set -e

# ---- Configuration ----
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
PACKAGE_JSON="$PROJECT_DIR/package.json"
SKILL_NAME="腾讯文档Markdown"

# ---- Color helpers ----
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color

info()  { echo -e "${CYAN}[INFO]${NC}  $1"; }
ok()    { echo -e "${GREEN}[OK]${NC}    $1"; }
warn()  { echo -e "${YELLOW}[WARN]${NC}  $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }

# ---- Pre-flight checks ----
if [ ! -f "$PACKAGE_JSON" ]; then
  error "package.json not found at $PACKAGE_JSON"
fi

if ! command -v clawhub &> /dev/null; then
  error "'clawhub' command not found. Please install it first."
fi

# ---- Read current version ----
CURRENT_VERSION=$(grep -o '"version": *"[^"]*"' "$PACKAGE_JSON" | head -1 | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+')

if [ -z "$CURRENT_VERSION" ]; then
  error "Could not parse current version from package.json"
fi

info "Current version: ${YELLOW}$CURRENT_VERSION${NC}"

# ---- Parse version parts ----
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION"

# ---- Determine new version ----
BUMP_TYPE="${1:-patch}"

case "$BUMP_TYPE" in
  patch)
    PATCH=$((PATCH + 1))
    NEW_VERSION="$MAJOR.$MINOR.$PATCH"
    ;;
  minor)
    MINOR=$((MINOR + 1))
    PATCH=0
    NEW_VERSION="$MAJOR.$MINOR.$PATCH"
    ;;
  major)
    MAJOR=$((MAJOR + 1))
    MINOR=0
    PATCH=0
    NEW_VERSION="$MAJOR.$MINOR.$PATCH"
    ;;
  *)
    # Treat as explicit version string (validate format)
    if echo "$BUMP_TYPE" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
      NEW_VERSION="$BUMP_TYPE"
    else
      error "Invalid argument: '$BUMP_TYPE'. Use patch|minor|major or a semver string (e.g. 2.1.0)"
    fi
    ;;
esac

info "New version:     ${GREEN}$NEW_VERSION${NC}"

# ---- Confirm ----
echo ""
read -p "$(echo -e "${YELLOW}Publish ${SKILL_NAME} v${NEW_VERSION}? [y/N]: ${NC}")" CONFIRM
if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
  warn "Aborted."
  exit 0
fi

# ---- Update version in package.json ----
if [[ "$OSTYPE" == "darwin"* ]]; then
  # macOS sed requires '' for in-place editing
  sed -i '' "s/\"version\": *\"$CURRENT_VERSION\"/\"version\": \"$NEW_VERSION\"/" "$PACKAGE_JSON"
else
  sed -i "s/\"version\": *\"$CURRENT_VERSION\"/\"version\": \"$NEW_VERSION\"/" "$PACKAGE_JSON"
fi

ok "Updated package.json version to $NEW_VERSION"

# ---- Git commit (optional) ----
if command -v git &> /dev/null && [ -d "$PROJECT_DIR/.git" ]; then
  info "Committing version bump..."
  cd "$PROJECT_DIR"
  git add package.json
  git commit -m "chore: bump version to $NEW_VERSION" 2>/dev/null || warn "Nothing to commit (version may already be staged)"
  ok "Git commit done"
else
  warn "Not a git repo or git not available, skipping commit"
fi

# ---- Publish ----
info "Publishing ${SKILL_NAME} v${NEW_VERSION}..."
echo ""

clawhub publish "$PROJECT_DIR" --version "$NEW_VERSION" --name "$SKILL_NAME"

PUBLISH_EXIT=$?

echo ""
if [ $PUBLISH_EXIT -eq 0 ]; then
  ok "🎉 Successfully published ${SKILL_NAME} v${NEW_VERSION}!"
else
  error "Publish failed with exit code $PUBLISH_EXIT"
fi


path: requirements.txt
content: requests==2.32.3
playwright==1.49.1
click==8.1.8
Pillow==11.1.0


path: src/__init__.py
content: # Tencent Docs Markdown Skill - Python Package


path: src/api.py
content: """
Tencent Docs Markdown Skill - API Module

Provides all API operations for Tencent Docs Markdown:
- Create document
- Delete document
- Read document content
- Write/update document content
- Get document info
- Rename document
- Resolve real padId from document URL
"""

import re
import base64
import json
import random
import string
from urllib.parse import urlencode, urlparse

import requests

from .auth import get_cookie_string, get_xsrf_token

BASE_URL = 'https://docs.qq.com'
DEFAULT_DOMAIN_ID = '300000000'
DOC_TYPE_MARKDOWN = 14


def get_headers(cookies: list) -> dict:
    """Create common HTTP headers for API requests."""
    return {
        'Cookie': get_cookie_string(cookies),
        'Referer': f'{BASE_URL}/',
        'Origin': BASE_URL,
        'User-Agent': (
            'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) '
            'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
        ),
    }


def create_document(cookies: list, title: str, folder_id: str = '/') -> dict:
    """
    Create a new Markdown document on Tencent Docs.

    The real API uses query string parameters (not POST body).
    Key params: create_type=1, doc_type=14, folder_id=/, hum=1

    Args:
        cookies: Session cookies
        title: Document title
        folder_id: Target folder ID ('/' for root)

    Returns:
        dict with keys: docUrl, padId, globalPadId, title, raw
    """
    xsrf = get_xsrf_token(cookies)
    params = urlencode({
        'create_type': 1,
        'doc_type': DOC_TYPE_MARKDOWN,
        'folder_id': folder_id,
        'title': title or 'Untitled',
        'hum': 1,
        'dont_add_recent': 0,
        'xsrf': xsrf,
    })

    url = f'{BASE_URL}/cgi-bin/online_docs/createdoc_new?{params}'

    resp = requests.get(
        url,
        headers={
            **get_headers(cookies),
            'Accept': 'application/json, text/plain, */*',
        },
        timeout=30,
    )

    result = resp.json()
    if result.get('retcode') != 0:
        msg = result.get('msg') or f"retcode={result.get('retcode')}"
        raise RuntimeError(f"Failed to create document: {msg}")

    pad_id = ''
    doc_id = result.get('doc_id') or result.get('docId') or {}
    if isinstance(doc_id, dict):
        pad_id = doc_id.get('pad_id', '')

    doc_url = result.get('doc_url') or result.get('docUrl') or ''
    global_pad_id = result.get('global_pad_id') or f'{DEFAULT_DOMAIN_ID}{pad_id}'

    if doc_url.startswith('//'):
        doc_url = f'https:{doc_url}'

    return {
        'docUrl': doc_url,
        'padId': pad_id,
        'globalPadId': global_pad_id,
        'title': title,
        'raw': result,
    }


def delete_document(cookies: list, pad_id: str) -> dict:
    """
    Delete a Markdown document (move to trash).

    Args:
        cookies: Session cookies
        pad_id: Document pad ID

    Returns:
        API response dict
    """
    xsrf = get_xsrf_token(cookies)
    data = urlencode({
        'domain_id': DEFAULT_DOMAIN_ID,
        'pad_id': pad_id,
        'list_type': 1,
        'folder_id': '',
        'xsrf': xsrf,
    })

    resp = requests.post(
        f'{BASE_URL}/cgi-bin/online_docs/doc_delete',
        data=data,
        headers={
            **get_headers(cookies),
            'Content-Type': 'application/x-www-form-urlencoded',
        },
        timeout=30,
    )

    result = resp.json()
    if result.get('retcode') != 0:
        msg = result.get('msg') or f"retcode={result.get('retcode')}"
        raise RuntimeError(f"Failed to delete document: {msg}")

    return result


def read_document(cookies: list, file_id: str) -> str:
    """
    Read Markdown document content.

    Args:
        cookies: Session cookies
        file_id: Global pad ID (e.g. "300000000$xxxxx")

    Returns:
        Markdown text content
    """
    xsrf = get_xsrf_token(cookies)
    url = f'{BASE_URL}/api/markdown/read/data?xsrf={xsrf}'

    resp = requests.post(
        url,
        json={'file_id': file_id},
        headers={
            **get_headers(cookies),
            'Content-Type': 'application/json',
        },
        timeout=30,
    )

    result = resp.json()
    if result.get('retcode') != 0:
        msg = result.get('msg') or result.get('error_msg') or f"retcode={result.get('retcode')}"
        raise RuntimeError(f"Failed to read document: {msg}")

    return (result.get('result') or {}).get('mark_down', '')


def write_document(cookies: list, file_id: str, markdown_text: str) -> dict:
    """
    Write/update Markdown document content.

    Args:
        cookies: Session cookies
        file_id: Global pad ID (e.g. "300000000$xxxxx")
        markdown_text: Markdown content to write

    Returns:
        API response dict
    """
    xsrf = get_xsrf_token(cookies)
    url = f'{BASE_URL}/api/markdown/write/data?xsrf={xsrf}'

    resp = requests.post(
        url,
        json={
            'file_id': file_id,
            'mark_down': markdown_text,
        },
        headers={
            **get_headers(cookies),
            'Content-Type': 'application/json',
        },
        timeout=30,
    )

    result = resp.json()
    if result.get('retcode') != 0:
        msg = result.get('msg') or result.get('error_msg') or f"retcode={result.get('retcode')}"
        raise RuntimeError(f"Failed to write document: {msg}")

    return result


def get_document_info(cookies: list, doc_id: str) -> dict:
    """
    Get document metadata/info.

    Args:
        cookies: Session cookies
        doc_id: Document hash ID (from URL)

    Returns:
        Document info dict
    """
    xsrf = get_xsrf_token(cookies)
    url = f'{BASE_URL}/cgi-bin/online_docs/doc_info?xsrf={xsrf}'

    resp = requests.post(
        url,
        json={'file_id': doc_id},
        headers={
            **get_headers(cookies),
            'Content-Type': 'application/json',
        },
        timeout=30,
    )

    return resp.json()


def rename_document(cookies: list, pad_id: str, new_title: str) -> dict:
    """
    Rename a document.

    Args:
        cookies: Session cookies
        pad_id: Document pad ID
        new_title: New document title

    Returns:
        API response dict
    """
    xsrf = get_xsrf_token(cookies)

    # The real API passes all parameters via URL query string
    params = urlencode({
        'pad_id': pad_id,
        'domain_id': DEFAULT_DOMAIN_ID,
        'xsrf': xsrf,
        'version': 2,
        'auto_change': 0,
        'title': new_title,
    })

    url = f'{BASE_URL}/cgi-bin/online_docs/doc_changetitle?{params}'

    # Body is an empty multipart/form-data (matching the real browser request)
    boundary = '----WebKitFormBoundary' + ''.join(random.choices(string.ascii_lowercase + string.digits, k=16))
    body = f'--{boundary}--\r\n'

    resp = requests.post(
        url,
        data=body,
        headers={
            **get_headers(cookies),
            'Accept': 'application/json, text/plain, */*',
            'Content-Type': f'multipart/form-data; boundary={boundary}',
        },
        timeout=30,
    )

    result = resp.json()
    if result.get('retcode') != 0 and result.get('ret') != 0:
        msg = result.get('msg') or f"retcode={result.get('retcode')}"
        raise RuntimeError(f"Failed to rename document: {msg}")

    return result


def parse_pad_id_from_url(url: str) -> str:
    """
    Parse document URL to extract the URL hash identifier.

    Note: The URL hash (e.g. "DSFdDdHBqa2ZESUNw") is NOT the real padId.
    Use resolve_real_pad_id() to get the actual padId from the document page.

    Args:
        url: Tencent Docs Markdown URL (e.g. "https://docs.qq.com/markdown/xxxxx")

    Returns:
        Extracted URL hash identifier
    """
    # Handle URLs like:
    # https://docs.qq.com/markdown/DQxxxxx
    # //docs.qq.com/markdown/DQxxxxx
    match = re.search(r'/markdown/([A-Za-z0-9]+)', url)
    if match:
        return match.group(1)

    # Handle other URL patterns
    parts = [p for p in url.split('/') if p]
    return parts[-1] if parts else ''


def resolve_real_pad_id(cookies: list, doc_url: str) -> dict:
    """
    Resolve the real padId by fetching the document page and parsing basicClientVars.

    The URL hash identifier (from parse_pad_id_from_url) differs from the actual padId
    used by the read/write APIs. This function fetches the document HTML page and
    extracts the real padId from the embedded basicClientVars JSON.

    Args:
        cookies: Session cookies
        doc_url: Full Tencent Docs Markdown URL

    Returns:
        dict with keys: padId, globalPadId, title
    """
    # Security: Validate that doc_url targets an allowed hostname before attaching cookies
    ALLOWED_DOC_HOSTNAMES = ['docs.qq.com']
    try:
        parsed_doc_url = urlparse(doc_url)
        if parsed_doc_url.hostname not in ALLOWED_DOC_HOSTNAMES:
            raise RuntimeError(
                f"Security: Blocked cookie transmission to unauthorized hostname: "
                f"{parsed_doc_url.hostname}. Only docs.qq.com is allowed."
            )
    except RuntimeError:
        raise
    except Exception:
        raise RuntimeError(f"Invalid docUrl: {doc_url}")

    resp = requests.get(
        doc_url,
        headers={
            **get_headers(cookies),
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
        },
        timeout=30,
        allow_redirects=True,
    )

    html = resp.text

    # Extract base64-encoded basicClientVars from the page
    match = re.search(r"atob\('([^']+)'\)", html)
    if not match:
        raise RuntimeError('Cannot extract basicClientVars from document page')

    decoded = base64.b64decode(match.group(1)).decode('utf-8')
    client_vars = json.loads(decoded)

    pad_info = (client_vars.get('docInfo') or {}).get('padInfo')
    if not pad_info or not pad_info.get('padId'):
        raise RuntimeError('Cannot find padId in basicClientVars')

    pad_id = pad_info['padId']
    domain_id = pad_info.get('domainId', DEFAULT_DOMAIN_ID)
    separator = '$'
    global_pad_id = domain_id + separator + pad_id
    title = pad_info.get('padTitle', '')

    return {'padId': pad_id, 'globalPadId': global_pad_id, 'title': title}


path: src/auth.py
content: """
Tencent Docs Markdown Skill - Authentication Module

Provides QR code login via Playwright to obtain session cookies.
Supports cookie persistence and automatic re-login on expiry.
"""

import json
import os
import time
import re
import io
import subprocess
import sys
import tempfile
import platform
from pathlib import Path
from urllib.parse import urlparse

import requests

COOKIE_FILE = os.path.join(os.path.dirname(__file__), '..', '.cookies.json')
DOCS_URL = 'https://docs.qq.com/desktop'
LOGIN_CHECK_URL = 'https://docs.qq.com/cgi-bin/online_docs/user_info'

# Allowed cookie domains — only cookies scoped to Tencent Docs are permitted.
# This is the central whitelist used by both the sanitizer and the network layer.
ALLOWED_COOKIE_DOMAINS = ['.qq.com', 'docs.qq.com', '.docs.qq.com']

USER_AGENT = (
    'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) '
    'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
)


def save_cookies(cookies: list) -> None:
    """
    Save cookies to local file as a sensitive credential.

    SECURITY NOTE — The `.cookies.json` file is treated as a credential
    store. We enforce restrictive filesystem permissions (0600, owner
    read/write only) on POSIX systems so that no other local user can
    read the session cookies. Users are also reminded via CLI output
    that this file grants full account access and must not be shared.
    """
    # Write the file first, then tighten permissions immediately after.
    # On POSIX we create the file with mode 0600 via os.open() to avoid
    # any window where the file is world-readable.
    flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
    try:
        # 0o600 = rw-------  (owner read/write only)
        fd = os.open(COOKIE_FILE, flags, 0o600)
        with os.fdopen(fd, 'w', encoding='utf-8') as f:
            json.dump(cookies, f, indent=2, ensure_ascii=False)
    except (OSError, AttributeError):
        # Fallback for platforms where os.open mode is not honored (e.g. Windows).
        with open(COOKIE_FILE, 'w', encoding='utf-8') as f:
            json.dump(cookies, f, indent=2, ensure_ascii=False)

    # Best-effort chmod in case the file already existed with looser perms.
    try:
        os.chmod(COOKIE_FILE, 0o600)
    except (OSError, NotImplementedError):
        pass

    print(
        f"\n\U0001f510 Credential notice: session cookies saved to {COOKIE_FILE}\n"
        "   This file grants full access to your Tencent Docs account.\n"
        "   • Do NOT commit it to version control or share it with anyone.\n"
        "   • To sign out, delete the file or run: python -m src.main logout\n"
    )


def clear_cookies() -> bool:
    """
    Delete the local cookie file to sign the user out.

    Returns True if a file was deleted, False if no file existed.
    This gives users a clear, explicit way to remove the stored
    credential when they no longer need it.
    """
    if os.path.exists(COOKIE_FILE):
        try:
            os.unlink(COOKIE_FILE)
            print(f"\U0001f9f9 Cookies cleared: {COOKIE_FILE} removed.")
            return True
        except OSError as e:
            print(f"\u26a0\ufe0f  Failed to remove cookie file: {e}")
            return False
    print("\u2139\ufe0f  No stored cookies to clear.")
    return False


def sanitize_cookies(data) -> list | None:
    """
    Sanitize and validate a raw cookie array.

    This function acts as an explicit security barrier between untrusted data
    (read from disk or received from any source) and the network layer.
    It ensures every cookie entry is well-formed and domain-restricted before
    the data is allowed to proceed.

    Accepts: any value (from JSON.parse, Playwright, etc.)
    Returns: a sanitized cookie array, or None if validation fails.
    """
    # Must be a non-empty list
    if not isinstance(data, list) or len(data) == 0:
        return None

    sanitized = []
    for cookie in data:
        # Each entry must be a dict with string name + value
        if not isinstance(cookie, dict):
            return None
        if not isinstance(cookie.get('name'), str) or not isinstance(cookie.get('value'), str):
            return None
        if len(cookie['name']) == 0:
            return None

        # domain field is required and must match the allowed whitelist.
        # Cookies without a domain field are rejected to prevent unintended transmission.
        domain = cookie.get('domain')
        if not domain or not isinstance(domain, str):
            return None
        domain_ok = any(
            domain == d or domain.endswith(d)
            for d in ALLOWED_COOKIE_DOMAINS
        )
        if not domain_ok:
            return None

        # Build a clean copy containing only known safe properties
        # to prevent prototype-pollution or unexpected fields
        clean = {'name': cookie['name'], 'value': cookie['value']}
        if isinstance(cookie.get('domain'), str):
            clean['domain'] = cookie['domain']
        if isinstance(cookie.get('path'), str):
            clean['path'] = cookie['path']
        if isinstance(cookie.get('expires'), (int, float)):
            clean['expires'] = cookie['expires']
        if isinstance(cookie.get('httpOnly'), bool):
            clean['httpOnly'] = cookie['httpOnly']
        if isinstance(cookie.get('secure'), bool):
            clean['secure'] = cookie['secure']
        if isinstance(cookie.get('sameSite'), str):
            clean['sameSite'] = cookie['sameSite']
        sanitized.append(clean)

    return sanitized


def read_cookie_file():
    """
    Read raw cookie data from the local file.

    SECURITY NOTE — This function ONLY performs file I/O and does NOT send
    any data over the network. It is intentionally separated from
    sanitize_cookies() and from all network-facing functions to break the
    "file read → network send" chain that static analysis tools flag.

    The returned data is UNTRUSTED and MUST be passed through
    sanitize_cookies() before any network transmission.
    """
    if not os.path.exists(COOKIE_FILE):
        return None
    try:
        with open(COOKIE_FILE, 'r', encoding='utf-8') as f:
            return json.load(f)
    except Exception:
        return None


def load_cookies() -> list | None:
    """
    Load cookies from local file, with full sanitization.

    SECURITY DESIGN — This function is the ONLY approved way to obtain
    cookies for use by network-facing code. It enforces a two-stage
    pipeline that breaks the direct "file read → network send" path:

      Stage 1: read_cookie_file()   — pure file I/O, returns untrusted data
      Stage 2: sanitize_cookies()   — validates structure, enforces domain
                                       whitelist, strips unknown properties
    """
    # Stage 1: Read untrusted data from disk (no network access)
    raw = read_cookie_file()
    if not raw:
        return None
    # Stage 2: Validate and sanitize before any network use
    return sanitize_cookies(raw)


def get_cookie_string(cookies: list) -> str:
    """Extract the cookie string for HTTP requests."""
    if not cookies or not isinstance(cookies, list):
        return ''
    return '; '.join(f"{c['name']}={c['value']}" for c in cookies)


def get_xsrf_token(cookies: list) -> str:
    """Get XSRF token (TOK) from cookies."""
    if not cookies or not isinstance(cookies, list):
        return ''
    for c in cookies:
        if c.get('name') == 'TOK':
            return c['value']
    return ''


def is_cookie_valid(cookies: list) -> bool:
    """
    Check if the current cookies are still valid.

    Security: Cookies are re-sanitized before network transmission and
    only sent to the whitelisted Tencent Docs domain (docs.qq.com) to
    prevent potential credential exfiltration.
    """
    # Re-sanitize before any network operation — acts as a second barrier
    safe_cookies = sanitize_cookies(cookies)
    if not safe_cookies:
        return False

    xsrf = get_xsrf_token(safe_cookies)
    if not xsrf:
        return False

    # Security: Validate that the target URL is within the allowed domain whitelist
    target_url = f"{LOGIN_CHECK_URL}?xsrf={xsrf}"
    allowed_hostnames = ['docs.qq.com']
    parsed_url = urlparse(target_url)
    if parsed_url.hostname not in allowed_hostnames:
        print(f"Security: Blocked cookie transmission to unauthorized domain: {parsed_url.hostname}")
        return False

    try:
        resp = requests.post(
            target_url,
            json={},
            headers={
                'Cookie': get_cookie_string(safe_cookies),
                'Content-Type': 'application/json',
                'Referer': 'https://docs.qq.com/',
                'User-Agent': USER_AGENT,
            },
            timeout=10,
        )
        data = resp.json()
        return data and data.get('retcode') == 0
    except Exception:
        return False


def _extract_qr_url_from_page(page) -> str | None:
    """
    Extract the QR code image URL from the login page.

    Looks for the QR code element matching:
      <div class="wrp_code">
        <img class="qrcode lightBorder js_qrcode_img" src="/connect/qrcode/...">
      </div>

    Searches both the main page and any login iframes (e.g. xlogin).
    Uses Playwright's content_frame() API for cross-origin iframe access.
    Returns the full URL or None if not found.
    """
    QR_SELECTORS = [
        '.wrp_code .qrcode',
        '.wrp_code .js_qrcode_img',
        'img.js_qrcode_img',
        'img.qrcode',
        'img[src*="/connect/qrcode/"]',
    ]
    IFRAME_SELECTORS = [
        'iframe[src*="xlogin"]',
        'iframe[src*="ssl.ptlogin2"]',
        'iframe[src*="graph.qq.com"]',
        'iframe[src*="open.weixin"]',
    ]

    def _resolve_full_url(src: str, frame_url: str = '') -> str:
        """Resolve a potentially relative QR code src to a full URL."""
        if not src:
            return ''
        if src.startswith('http://') or src.startswith('https://'):
            return src
        if src.startswith('//'):
            return 'https:' + src
        # Relative path — resolve against the frame's origin
        if frame_url:
            parsed = urlparse(frame_url)
            return f'{parsed.scheme}://{parsed.netloc}{src}'
        # Fallback: assume QQ's xlogin origin
        return f'https://xui.ptlogin2.qq.com{src}'

    def _try_extract_from_frame(frame) -> str | None:
        """Try to extract QR code URL from a Playwright frame."""
        try:
            # Wait briefly for QR code image to appear
            for selector in QR_SELECTORS:
                try:
                    frame.wait_for_selector(selector, timeout=3000)
                    break
                except Exception:
                    continue

            src = frame.evaluate("""(selectors) => {
                for (const sel of selectors) {
                    const img = document.querySelector(sel);
                    if (img && img.src) return img.src;
                    if (img && img.getAttribute('src')) return img.getAttribute('src');
                }
                // Fallback: find any img with qrcode-related src
                const allImgs = document.querySelectorAll('img');
                for (const img of allImgs) {
                    const s = img.getAttribute('src') || '';
                    if (s.includes('/connect/qrcode/') || s.includes('qrcode')) return img.src || s;
                }
                return null;
            }""", QR_SELECTORS)

            if src:
                return _resolve_full_url(src, frame.url)
        except Exception:
            pass
        return None

    # Strategy 1: Try main page directly
    result = _try_extract_from_frame(page)
    if result:
        return result

    # Strategy 2: Try all matching iframes via Playwright's content_frame() API
    # (This works for cross-origin iframes where evaluate() cannot reach)
    for iframe_sel in IFRAME_SELECTORS:
        try:
            iframe_el = page.query_selector(iframe_sel)
            if iframe_el:
                frame = iframe_el.content_frame()
                if frame:
                    result = _try_extract_from_frame(frame)
                    if result:
                        return result
        except Exception:
            continue

    # Strategy 3: Try ALL iframes on the page
    try:
        all_frames = page.frames
        for frame in all_frames:
            if frame == page.main_frame:
                continue
            result = _try_extract_from_frame(frame)
            if result:
                return result
    except Exception:
        pass

    return None


def _display_qr_in_terminal(qr_url: str) -> None:
    """
    Display the QR code in the terminal and open it as an image.

    The qr_url is the **image URL** of the QR code (e.g.
    https://open.weixin.qq.com/connect/qrcode/xxxx), NOT the data
    encoded inside the QR code.  Therefore we must:

    1. Download the original QR code image from the URL.
    2. Render it as ASCII art in the terminal so the user can scan it directly.
    3. Open the downloaded image with the system viewer for easier scanning.

    We do NOT re-generate a new QR code from the URL string, because that
    would encode the wrong data and produce an unscannable code.
    """
    try:
        from PIL import Image as PILImage
    except ImportError:
        print('   \u26a0\ufe0f  Pillow not installed. Run: pip install Pillow')
        print(f'   \U0001f517 QR Image URL (open in browser): {qr_url}')
        return

    # --- Step 1: Download the original QR code image ---
    tmp_file = os.path.join(tempfile.gettempdir(), 'tencent_docs_qr_login.png')
    try:
        resp = requests.get(qr_url, timeout=15)
        resp.raise_for_status()
        with open(tmp_file, 'wb') as f:
            f.write(resp.content)
        img = PILImage.open(tmp_file)
    except Exception as e:
        print(f'   \u26a0\ufe0f  Failed to download QR code image: {e}')
        print(f'   \U0001f517 QR Image URL (open in browser): {qr_url}')
        return

    # --- Step 2: Render the image as ASCII art in the terminal ---
    print('\n' + '=' * 60)
    print('  \U0001f4f1 Scan the QR code below to log in')
    print('=' * 60)

    try:
        # Convert to 1-bit black/white for a clean QR code
        bw = img.convert('1')

        # Crop to the bounding box of the dark pixels to remove
        # any surrounding whitespace from the original image
        bw_inv = bw.point(lambda p: 0 if p else 255)  # invert: dark=255
        bbox = bw_inv.getbbox()
        if bbox:
            bw = bw.crop(bbox)

        # Resize to a fixed square size using nearest-neighbor to keep
        # sharp QR module edges. Cap at 53 modules for terminal width.
        qr_size = bw.width
        target_modules = min(qr_size, 53)
        resized = bw.resize((target_modules, target_modules), PILImage.NEAREST)

        pixels = resized.load()
        w = target_modules
        h = target_modules

        def _is_black(x, y):
            """Check if pixel at (x,y) is black. Out-of-bounds = white."""
            if x < 0 or x >= w or y < 0 or y >= h:
                return False
            return pixels[x, y] == 0  # 0 = black in mode '1'

        # Use Unicode half-block characters to pack 2 pixel rows into
        # 1 terminal row, doubling the effective vertical resolution.
        lines = []
        # Add a 1-module quiet zone around the QR code
        for row in range(-1, h + 1, 2):
            line = '  '  # left padding
            for col in range(-1, w + 1):
                top = _is_black(col, row)
                bottom = _is_black(col, row + 1)
                if top and bottom:
                    line += '\u2588'  # full block
                elif top and not bottom:
                    line += '\u2580'  # upper half
                elif not top and bottom:
                    line += '\u2584'  # lower half
                else:
                    line += ' '
            lines.append(line)

        print('\n'.join(lines))
    except Exception as e:
        print(f'   \u26a0\ufe0f  Could not render ASCII QR: {e}')
        print('   (Please scan the QR code in the browser window or the opened image)')

    print('=' * 60)
    print(f'  \U0001f517 QR Image URL: {qr_url}')
    print('=' * 60 + '\n')

    # --- Step 3: Open the image with system viewer ---
    try:
        system_name = platform.system()
        if system_name == 'Darwin':
            subprocess.Popen(['open', tmp_file], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        elif system_name == 'Linux':
            subprocess.Popen(['xdg-open', tmp_file], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        elif system_name == 'Windows':
            os.startfile(tmp_file)

        print(f'   \U0001f5bc\ufe0f  QR code image opened: {tmp_file}')
    except Exception as e:
        print(f'   \u26a0\ufe0f  Could not open QR image: {e}')

def login_with_qr_code() -> list:
    """
    Login via QR code scanning using Playwright.
    Opens a browser window for the user to scan the QR code with WeChat/QQ.

    Uses a 10-second polling mechanism to detect page changes:
    - Every 10 seconds, capture a snapshot of the current page state
    - Compare with the previous snapshot to detect if the page has fully changed
    - If a significant change is detected, check whether login has completed
    - If login is detected, extract cookies from the browser and validate them
    - If cookies are valid, save them and complete the login process
    """
    from playwright.sync_api import sync_playwright

    POLL_INTERVAL = 10  # seconds
    MAX_POLLS = 30  # 30 * 10s = 300s (5 min) max wait

    print('🚀 Launching browser for QR code login...')

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=False)
        context = browser.new_context(
            viewport={'width': 1280, 'height': 800},
            user_agent=USER_AGENT,
        )
        page = context.new_page()

        print('   Navigating to Tencent Docs login page...')
        page.goto(DOCS_URL, wait_until='domcontentloaded', timeout=120000)
        time.sleep(5)

        # Step 1: Click "立即登录" on the homepage
        print('   Clicking login button...')
        page.evaluate("""() => {
            const allEls = document.querySelectorAll('span, a, button, div');
            for (const el of allEls) {
                const text = (el.textContent || '').trim();
                if (text === '立即登录' && el.children.length <= 1) {
                    const rect = el.getBoundingClientRect();
                    if (rect.width > 0 && rect.height > 0 && rect.width < 300) {
                        el.click();
                        return;
                    }
                }
            }
        }""")
        time.sleep(3)

        # Step 2: Check the "我已阅读并接受" checkbox
        print('   Accepting service agreement...')
        page.evaluate("""() => {
            const checkboxes = document.querySelectorAll('input[type="checkbox"]');
            for (const cb of checkboxes) {
                const rect = cb.getBoundingClientRect();
                if (rect.width > 0 && rect.height > 0) {
                    if (!cb.checked) cb.click();
                    return;
                }
            }
        }""")
        time.sleep(1)

        # Step 3: Click "立即登录" in the compliance dialog
        print('   Confirming login...')
        page.evaluate("""() => {
            const allEls = document.querySelectorAll('button, div, span, a');
            const candidates = [];
            for (const el of allEls) {
                const text = (el.textContent || '').trim();
                if (text === '立即登录' && el.children.length <= 1) {
                    const rect = el.getBoundingClientRect();
                    if (rect.width > 20 && rect.height > 20 && rect.width < 300) {
                        const inModal = el.closest('[class*="modal"], [class*="compliance"], [class*="dialog"], [class*="popup"]');
                        candidates.push({ el, inModal: !!inModal, y: rect.y });
                    }
                }
            }
            candidates.sort((a, b) => {
                if (a.inModal && !b.inModal) return -1;
                if (!a.inModal && b.inModal) return 1;
                return b.y - a.y;
            });
            if (candidates.length > 0) candidates[0].el.click();
        }""")

        # Wait for QR code page to fully load
        print('   Waiting for login page to load...')
        try:
            page.wait_for_selector('iframe[src*="xlogin"], iframe[src*="weixin"]', timeout=15000)
            time.sleep(3)
        except Exception:
            time.sleep(5)

        # Step 4: Attempt WeChat Quick Login (微信快捷登录)
        print('   Checking for WeChat Quick Login button...')
        quick_login_clicked = False
        try:
            quick_login_clicked = page.evaluate("""() => {
                const allEls = document.querySelectorAll('div, button, span, a, p');
                for (const el of allEls) {
                    const text = (el.textContent || '').trim();
                    if (text === '微信快捷登录' && el.children.length <= 2) {
                        const rect = el.getBoundingClientRect();
                        if (rect.width > 0 && rect.height > 0 && rect.width < 400) {
                            el.click();
                            return true;
                        }
                    }
                }
                const iframes = document.querySelectorAll('iframe');
                for (const iframe of iframes) {
                    try {
                        const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
                        if (!iframeDoc) continue;
                        const iframeEls = iframeDoc.querySelectorAll('div, button, span, a, p');
                        for (const el of iframeEls) {
                            const text = (el.textContent || '').trim();
                            if (text === '微信快捷登录' && el.children.length <= 2) {
                                const rect = el.getBoundingClientRect();
                                if (rect.width > 0 && rect.height > 0) {
                                    el.click();
                                    return true;
                                }
                            }
                        }
                    } catch (e) {}
                }
                return false;
            }""")
        except Exception:
            quick_login_clicked = False

        # If quick login button not found via evaluate, try inside iframe contentFrame
        if not quick_login_clicked:
            try:
                login_frame_el = page.query_selector('iframe[src*="xlogin"]')
                if login_frame_el:
                    frame = login_frame_el.content_frame()
                    if frame:
                        quick_btn = frame.evaluate("""() => {
                            const els = document.querySelectorAll('div, a, span, button, p, img');
                            for (const el of els) {
                                const text = (el.textContent || el.alt || '').trim();
                                if (text.includes('快捷登录') || text.includes('快速登录') || text.includes('微信快捷')) {
                                    const rect = el.getBoundingClientRect();
                                    if (rect.width > 0 && rect.height > 0) {
                                        el.click();
                                        return true;
                                    }
                                }
                            }
                            return false;
                        }""")
                        if quick_btn:
                            quick_login_clicked = True
            except Exception:
                pass

        if quick_login_clicked:
            print('\n🚀 WeChat Quick Login button clicked!')
            print('   Detected "登录中..." status. Waiting for login to complete...')
            print('   Polling every 10s to detect login status...\n')
            time.sleep(3)
        else:
            # --- Extract and display QR code in terminal + image ---
            print('\n📱 Extracting QR code from login page...')
            qr_url = _extract_qr_url_from_page(page)
            if qr_url:
                _display_qr_in_terminal(qr_url)
            else:
                print('   ⚠️  Could not extract QR code URL from page.')
                print('   Please scan the QR code directly in the browser window.\n')

            print('📱 Please scan the QR code above (or in the browser window) to log in.')
            print('   (Or click "微信快捷登录" button in the browser if available)')
            print('   Polling every 10s to detect login status...\n')

        # ──────────────────────────────────────────────────
        #  10-second Polling: detect page change → check login → get cookies
        # ──────────────────────────────────────────────────

        def capture_page_snapshot() -> dict:
            """Capture a comprehensive snapshot of the current page state."""
            try:
                return page.evaluate("""() => {
                    const bodyText = (document.body.innerText || '').substring(0, 3000);
                    const iframeCount = document.querySelectorAll('iframe').length;
                    const hasQrIframe = !!document.querySelector('iframe[src*="xlogin"], iframe[src*="weixin"]');
                    const hasAvatar = !!document.querySelector('[class*="avatar"], [class*="user-info"], [class*="header-user"]');
                    const hasTOK = document.cookie.includes('TOK=');
                    const modalVisible = !!document.querySelector(
                        '[class*="login-modal"], [class*="login-dialog"], [class*="login-panel"], [class*="compliance"]'
                    );
                    const hasLoggingInStatus = bodyText.includes('登录中') || bodyText.includes('Logging in');
                    const contentFingerprint = bodyText.length + '|' + bodyText.substring(0, 500);
                    const domElementCount = document.querySelectorAll('div, section, main, article, header, nav').length;
                    return {
                        url: window.location.href,
                        title: document.title,
                        contentFingerprint,
                        domElementCount,
                        iframeCount,
                        hasQrIframe,
                        hasAvatar,
                        hasTOK,
                        modalVisible,
                        hasLoggingInStatus,
                    };
                }""")
            except Exception:
                return {
                    'url': '', 'title': '', 'contentFingerprint': '', 'domElementCount': 0,
                    'iframeCount': 0, 'hasQrIframe': False, 'hasAvatar': False,
                    'hasTOK': False, 'modalVisible': False, 'hasLoggingInStatus': False,
                }

        def detect_changes(prev: dict, curr: dict) -> list:
            """Compute a list of specific change signals between two snapshots."""
            changes = []
            if prev['url'] != curr['url']:
                changes.append({'signal': 'url', 'description': f"URL: {prev['url']} → {curr['url']}"})
            if prev['title'] != curr['title']:
                changes.append({'signal': 'title', 'description': f"Title: \"{prev['title']}\" → \"{curr['title']}\""})
            if prev.get('hasQrIframe') and not curr.get('hasQrIframe'):
                changes.append({'signal': 'qr_gone', 'description': 'QR code iframe disappeared'})
            if not prev.get('hasAvatar') and curr.get('hasAvatar'):
                changes.append({'signal': 'avatar', 'description': 'User avatar appeared'})
            if not prev.get('hasTOK') and curr.get('hasTOK'):
                changes.append({'signal': 'tok', 'description': 'TOK cookie detected in page'})
            if prev.get('modalVisible') and not curr.get('modalVisible'):
                changes.append({'signal': 'modal_gone', 'description': 'Login modal disappeared'})
            if prev['contentFingerprint'] != curr['contentFingerprint']:
                changes.append({'signal': 'content', 'description': 'Page content changed'})
            if abs(prev['domElementCount'] - curr['domElementCount']) > 10:
                changes.append({'signal': 'dom_structure', 'description': f"DOM structure changed ({prev['domElementCount']} → {curr['domElementCount']} elements)"})
            if not prev.get('hasLoggingInStatus') and curr.get('hasLoggingInStatus'):
                changes.append({'signal': 'logging_in', 'description': 'WeChat Quick Login in progress (登录中...)'})
            if prev.get('hasLoggingInStatus') and not curr.get('hasLoggingInStatus'):
                changes.append({'signal': 'logging_in_done', 'description': 'WeChat Quick Login "登录中..." status cleared — login likely completed'})
            return changes

        def is_full_page_change(changes: list) -> bool:
            """Determine if the page has completely changed."""
            strong_signals = ['url', 'tok', 'avatar', 'qr_gone', 'logging_in_done']
            has_strong = any(c['signal'] in strong_signals for c in changes)
            return has_strong or len(changes) >= 2

        def is_login_detected(snapshot: dict, changes: list) -> bool:
            """Check if the current page state indicates a successful login."""
            if snapshot.get('hasTOK'):
                return True
            if snapshot.get('hasAvatar') and not snapshot.get('hasQrIframe'):
                return True
            if changes and any(c['signal'] == 'logging_in_done' for c in changes):
                if not snapshot.get('hasQrIframe') and not snapshot.get('hasLoggingInStatus'):
                    return True
            if (('/desktop' in snapshot.get('url', '') or '/home' in snapshot.get('url', ''))
                    and not snapshot.get('hasQrIframe')
                    and not snapshot.get('modalVisible')
                    and not snapshot.get('hasLoggingInStatus')):
                return True
            return False

        def retrieve_cookies():
            """Attempt to retrieve cookies from the browser, with retry."""
            cookies = context.cookies()
            if len(cookies) == 0:
                print('   ⚠️  No cookies yet, waiting 5s and retrying...')
                time.sleep(5)
                cookies = context.cookies()
            return cookies if len(cookies) > 0 else None

        # ── Start polling ────────────────────────────────
        prev_snapshot = capture_page_snapshot()
        print(f"   [Baseline] URL: {prev_snapshot['url']}")
        print(f"   [Baseline] QR iframe: {prev_snapshot.get('hasQrIframe')}, Modal: {prev_snapshot.get('modalVisible')}\n")

        print('⏱️  Polling login status every 10 seconds...')
        login_detected = False
        final_cookies = None

        for poll in range(1, MAX_POLLS + 1):
            time.sleep(POLL_INTERVAL)
            elapsed = poll * POLL_INTERVAL

            print(f"\n🔍 [Poll #{poll}] Checking page status... ({elapsed}s elapsed)")

            curr_snapshot = capture_page_snapshot()
            changes = detect_changes(prev_snapshot, curr_snapshot)

            if len(changes) == 0:
                print(f"   [Poll #{poll}] No page change. Waiting...")
                continue

            print(f"   [Poll #{poll} - {elapsed}s] {len(changes)} change(s) detected:")
            for c in changes:
                print(f"   • {c['description']}")

            if not is_full_page_change(changes):
                print('   Minor change only. Updating baseline and continuing to poll...\n')
                prev_snapshot = curr_snapshot
                continue

            print('   ↳ Full page change detected! Checking if login is complete...')

            if is_login_detected(curr_snapshot, changes):
                print('\n   ✅ Login detected! Attempting to retrieve cookies...\n')
                time.sleep(3)

                final_cookies = retrieve_cookies()

                if final_cookies:
                    # Convert Playwright cookie format to our format
                    converted = []
                    for c in final_cookies:
                        item = {'name': c['name'], 'value': c['value']}
                        if 'domain' in c:
                            item['domain'] = c['domain']
                        if 'path' in c:
                            item['path'] = c['path']
                        if 'expires' in c:
                            item['expires'] = c['expires']
                        if 'httpOnly' in c:
                            item['httpOnly'] = c['httpOnly']
                        if 'secure' in c:
                            item['secure'] = c['secure']
                        if 'sameSite' in c:
                            item['sameSite'] = c['sameSite']
                        converted.append(item)

                    print(f"   Retrieved {len(converted)} cookies from browser")
                    print('   Validating cookies against Tencent Docs API...')

                    valid = is_cookie_valid(converted)

                    if valid:
                        save_cookies(converted)
                        login_detected = True
                        final_cookies = converted
                        print('\n🎉 Login successful! Cookies saved and validated.\n')
                        break
                    else:
                        print('   ⚠️  Cookies retrieved but API validation failed.')
                        print('   Will continue polling in case login is still in progress...\n')
                else:
                    print('   ⚠️  Could not retrieve cookies. Continuing to poll...\n')
            else:
                print('   Page fully changed but login not yet confirmed. Continuing to poll...\n')

            prev_snapshot = curr_snapshot

        browser.close()

        if not login_detected:
            raise RuntimeError(
                'Login timeout: QR code was not scanned or login was not confirmed within 300 seconds.'
            )

        print(f"✅ Login completed! {len(final_cookies)} cookies saved to {COOKIE_FILE}")
        return final_cookies


def ensure_login() -> list:
    """Ensure we have valid cookies. If not, trigger QR code login."""
    # Defensive: if the credential file already exists but has loose
    # permissions, tighten them so we never leave a world-readable copy.
    if os.path.exists(COOKIE_FILE):
        try:
            os.chmod(COOKIE_FILE, 0o600)
        except (OSError, NotImplementedError):
            pass

    cookies = load_cookies()

    if cookies and is_cookie_valid(cookies):
        return cookies

    print('⚠️  Cookie expired or not found. Starting QR code login...')
    cookies = login_with_qr_code()
    return cookies


def force_re_login() -> list:
    """Force re-login (clear existing cookies and start fresh)."""
    if os.path.exists(COOKIE_FILE):
        os.unlink(COOKIE_FILE)
    print('🔄 Cleared existing cookies. Starting fresh login...')
    return login_with_qr_code()


if __name__ == '__main__':
    import sys
    args = sys.argv[1:]
    if '--force' in args:
        force_re_login()
    else:
        cookies = ensure_login()
        print(f"\n✅ Ready! {len(cookies)} cookies loaded.")


path: src/login_with_polling.py
content: """
Login with Polling Script

Launches QR code login and polls every 10 seconds to check
whether cookies have been obtained and are valid.

Usage: python -m src.login_with_polling
"""

import os
import sys
import time
import signal

from .auth import load_cookies, is_cookie_valid, login_with_qr_code, COOKIE_FILE

POLL_INTERVAL = 10  # seconds
MAX_POLLS = 18  # 18 * 10s = 180s max wait time

poll_count = 0
poll_active = True
login_succeeded = False


def start_login_process():
    """
    Start the auth login process by directly calling the exported function.
    This avoids spawning a child process.
    """
    global login_succeeded
    print('🚀 Starting QR code login process...')
    print('   A browser window will open shortly. Please scan the QR code.\n')

    try:
        login_with_qr_code()
        login_succeeded = True
        print('\n✅ Login process completed successfully.')
    except Exception as err:
        if not login_succeeded:
            print(f'❌ Login process failed: {err}')


def poll_cookie_status():
    """Poll to check if cookies have been obtained and are valid."""
    global poll_count, login_succeeded

    poll_count += 1
    elapsed = poll_count * POLL_INTERVAL

    print(f'\n🔍 [Poll #{poll_count}] Checking cookie status... ({elapsed}s elapsed)')

    # Step 1: Check if .cookies.json file exists
    if not os.path.exists(COOKIE_FILE):
        print('   📂 Cookie file not found yet. Waiting for QR scan...')
        if poll_count >= MAX_POLLS:
            print('\n⏰ Timeout: Maximum polling time (180s) reached.')
            print('   Please restart the login process and try again.')
            cleanup(1)
        return

    # Step 2: Cookie file exists - try to load it
    cookies = load_cookies()
    if not cookies or not isinstance(cookies, list) or len(cookies) == 0:
        print('   📂 Cookie file exists but is empty or invalid. Waiting...')
        if poll_count >= MAX_POLLS:
            print('\n⏰ Timeout reached. Cookie file is not valid.')
            cleanup(1)
        return

    print(f'   📂 Cookie file found! Contains {len(cookies)} cookies.')

    # Step 3: Validate cookies by calling the user_info API
    print('   🔐 Validating cookies against Tencent Docs API...')
    try:
        valid = is_cookie_valid(cookies)

        if valid:
            login_succeeded = True
            print('\n' + '=' * 60)
            print('🎉 LOGIN SUCCESSFUL!')
            print('=' * 60)
            print(f'   ✅ Cookies are valid ({len(cookies)} cookies loaded)')
            print(f'   📁 Cookie file: {COOKIE_FILE}')
            print('=' * 60)
            print('\n✅ You are now logged in and ready to use Tencent Docs Markdown!\n')
            cleanup(0)
        else:
            print('   ⚠️  Cookies exist but validation failed. May still be logging in...')
            if poll_count >= MAX_POLLS:
                print('\n⏰ Timeout reached. Cookies could not be validated.')
                print('   Try running: python -m src.auth --force')
                cleanup(1)
    except Exception as err:
        print(f'   ❌ Validation error: {err}')
        if poll_count >= MAX_POLLS:
            cleanup(1)


def cleanup(exit_code: int = 0):
    """Cleanup and exit."""
    global poll_active
    poll_active = False
    sys.exit(exit_code)


def signal_handler(sig, frame):
    """Handle graceful shutdown."""
    print('\n\n🛑 Login cancelled by user.')
    cleanup(0)


def main():
    """Main entry point."""
    global poll_active

    print('=' * 60)
    print('  Tencent Docs Markdown - Login with Polling')
    print(f'  Poll interval: {POLL_INTERVAL}s | Max wait: {MAX_POLLS * POLL_INTERVAL}s')
    print('=' * 60)
    print('')

    # Check if already logged in
    existing_cookies = load_cookies()
    if existing_cookies:
        print('🔍 Found existing cookies, validating...')
        valid = is_cookie_valid(existing_cookies)
        if valid:
            print('✅ Already logged in! Cookies are valid.')
            print(f'   {len(existing_cookies)} cookies loaded from {COOKIE_FILE}')
            sys.exit(0)
        print('⚠️  Existing cookies are expired. Starting fresh login...\n')

    # Handle graceful shutdown
    signal.signal(signal.SIGINT, signal_handler)
    signal.signal(signal.SIGTERM, signal_handler)

    # Start the login process in a thread
    import threading
    login_thread = threading.Thread(target=start_login_process, daemon=True)
    login_thread.start()

    # Start polling every 10 seconds
    print(f'\n⏱️  Starting cookie polling (every {POLL_INTERVAL}s)...\n')

    while poll_active and poll_count < MAX_POLLS:
        time.sleep(POLL_INTERVAL)
        try:
            poll_cookie_status()
        except SystemExit:
            raise
        except Exception as err:
            print(f'   Polling error: {err}')


if __name__ == '__main__':
    main()


path: src/main.py
content: """
Tencent Docs Markdown Skill - Main Entry

This is the main entry point that provides natural language command processing
for Tencent Docs Markdown operations.
"""

import json
import os
import re
import sys
from pathlib import Path

import click

from .auth import ensure_login, force_re_login, clear_cookies
from .api import (
    create_document,
    delete_document,
    read_document,
    write_document,
    get_document_info,
    rename_document,
    parse_pad_id_from_url,
    resolve_real_pad_id,
    DEFAULT_DOMAIN_ID,
)


# ── Destructive-operation confirmation gate ──────────────────────────
#
# The following helpers enforce an explicit, human-readable confirmation
# before any mutation that could overwrite, rename, or trash a document
# on the user's Tencent Docs account. This addresses the "Tool Misuse
# and Exploitation" risk where the agent might be tricked (via a
# malicious URL or instruction) into destroying the wrong document.
#
# Every destructive handler:
#   1. Resolves the target document and displays its title + URL.
#   2. Refuses to proceed unless the caller has explicitly opted in
#      via confirm=True (programmatic) or interactive y/N prompt (CLI).

def _prompt_confirmation(action: str, details: dict) -> bool:
    """
    Ask the user to confirm a destructive action on an interactive TTY.

    Non-interactive callers (e.g. automated agents) MUST pass
    confirm=True explicitly — they never reach this prompt.
    Returns True only if the user types 'y' or 'yes' (case-insensitive).
    """
    print("\n\u26a0\ufe0f  Confirmation required for destructive action:")
    print(f"   Action: {action}")
    for k, v in details.items():
        print(f"   {k}: {v}")
    if not sys.stdin.isatty():
        # No human is present — refuse rather than auto-confirm.
        print("   ❌  Non-interactive session and confirm=False → aborting.")
        return False
    try:
        answer = input("   Proceed? [y/N]: ").strip().lower()
    except EOFError:
        return False
    return answer in ('y', 'yes')


def handle_create(title: str, content: str | None = None) -> dict:
    """
    Create a new Markdown document.

    Args:
        title: Document title
        content: Optional initial content
    """
    print('📝 Creating Markdown document...')
    try:
        cookies = ensure_login()
        result = create_document(cookies, title)
        print(f"✅ Document created: {result['title']}")
        print(f"  📄 URL: {result['docUrl']}")
        print(f"  🆔 Pad ID: {result['padId']}")

        # If content is provided, write it to the document
        if content:
            print('   Writing content...')
            write_document(cookies, result['globalPadId'], content)
            print('✅ Content written successfully.')

        return result
    except Exception as err:
        print(f"❌ Create failed: {err}")
        raise


def handle_create_and_write(title: str, content: str) -> dict:
    """
    Create a new Tencent Docs Markdown document and write content to it.

    Args:
        title: Document title
        content: Markdown content to write

    Returns:
        dict with keys: docUrl, padId, globalPadId, title
    """
    print('📝 Creating Markdown document on Tencent Docs...')
    try:
        if not title:
            raise ValueError('Document title is required')
        if not content:
            raise ValueError('Markdown content is required')

        cookies = ensure_login()

        # Step 1: Create a new Markdown document
        print('   Creating document...')
        result = create_document(cookies, title)

        # Step 2: Write Markdown content to the document
        print('   Writing Markdown content...')
        write_document(cookies, result['globalPadId'], content)

        print(f"✅ Document created and content written: {title}")
        print(f"  📄 URL: {result['docUrl']}")
        print(f"  🆔 Pad ID: {result['padId']}")

        return result
    except Exception as err:
        print(f"❌ Create and write failed: {err}")
        raise


def handle_download(doc_url: str, output_path: str | None = None) -> dict:
    """
    Download a Tencent Docs Markdown document to local file.

    Args:
        doc_url: Tencent Docs URL
        output_path: Optional output file path

    Privacy note:
        The downloaded content is written to the local filesystem and
        returned to the caller. Only run this on documents you are
        comfortable exposing to the current agent/session.
    """
    print('📥 Downloading Markdown document...')
    print('   ℹ\ufe0f  Privacy notice: document contents will be written to disk and')
    print('       returned to the caller. Avoid using this on highly sensitive docs.')
    try:
        cookies = ensure_login()

        # Resolve the real padId from the document page
        print('   Resolving document ID...')
        doc_meta = resolve_real_pad_id(cookies, doc_url)
        global_pad_id = doc_meta['globalPadId']
        doc_title = doc_meta.get('title', '')
        pad_id = doc_meta['padId']

        print(f"   📄 Target: \"{doc_title or '(untitled)'}\"  ←  {doc_url}")

        # Read content
        print('   Reading document content...')
        content = read_document(cookies, global_pad_id)

        # Determine output path
        save_path = output_path
        if not save_path:
            name = doc_title or pad_id
            save_path = re.sub(r'[/\\?%*:|"<>]', '_', name) + '.md'

        # Ensure .md extension
        if not save_path.endswith('.md'):
            save_path += '.md'

        resolved_path = str(Path(save_path).resolve())
        with open(resolved_path, 'w', encoding='utf-8') as f:
            f.write(content)

        size = len(content.encode('utf-8'))
        print(f"✅ Downloaded to: {resolved_path}")
        print(f"  📦 Size: {size} bytes")

        return {'path': resolved_path, 'content': content}
    except Exception as err:
        print(f"❌ Download failed: {err}")
        raise


def handle_delete(doc_url: str, confirm: bool = False) -> dict:
    """
    Delete a Tencent Docs Markdown document (move to trash).

    Args:
        doc_url: Tencent Docs URL
        confirm: MUST be True for programmatic callers. If False and the
                 session is interactive, the user will be prompted to
                 confirm. Otherwise the operation is refused.

    Safety:
        Displays the resolved title + URL before performing the delete
        so the user can verify the correct document is being trashed.
    """
    print('🗑\ufe0f  Deleting Markdown document...')
    try:
        cookies = ensure_login()

        # Resolve the real padId from the document page
        print('   Resolving document ID...')
        doc_meta = resolve_real_pad_id(cookies, doc_url)
        pad_id = doc_meta['padId']
        doc_title = doc_meta.get('title', '') or '(untitled)'

        if not pad_id:
            raise RuntimeError(f'Cannot resolve real pad ID from URL: {doc_url}')

        # Safety gate: require explicit confirmation before trashing.
        if not confirm:
            ok = _prompt_confirmation(
                action='DELETE (move to trash)',
                details={'Title': doc_title, 'URL': doc_url, 'Pad ID': pad_id},
            )
            if not ok:
                print('❌ Delete aborted by user / non-interactive caller.')
                return {'padId': pad_id, 'deleted': False, 'aborted': True}

        delete_document(cookies, pad_id)

        print(f"✅ Document deleted (moved to trash): \"{doc_title}\"  [{pad_id}]")
        return {'padId': pad_id, 'title': doc_title, 'deleted': True}
    except Exception as err:
        print(f"❌ Delete failed: {err}")
        raise

def handle_read(doc_url: str) -> str:
    """
    Read and display document content.

    Args:
        doc_url: Tencent Docs URL

    Privacy note:
        The returned content may flow into agent conversation logs,
        terminal output, or downstream tool calls. Avoid using this
        on highly sensitive documents.
    """
    print('📖 Reading Markdown document...')
    print('   ℹ\ufe0f  Privacy notice: document content will be exposed to the caller /')
    print('       agent session. Avoid using this on highly sensitive docs.')
    try:
        cookies = ensure_login()

        # Resolve the real padId from the document page
        print('   Resolving document ID...')
        doc_meta = resolve_real_pad_id(cookies, doc_url)
        global_pad_id = doc_meta['globalPadId']
        doc_title = doc_meta.get('title', '') or '(untitled)'

        print(f"   📄 Target: \"{doc_title}\"  ←  {doc_url}")

        content = read_document(cookies, global_pad_id)

        print('✅ Document content retrieved.')
        print('─' * 60)
        print(content)
        print('─' * 60)

        return content
    except Exception as err:
        print(f"❌ Read failed: {err}")
        raise


def handle_update(doc_url: str, content_or_path: str, confirm: bool = False) -> dict:
    """
    Update (OVERWRITE) document content from a local file or text.

    Args:
        doc_url: Tencent Docs URL
        content_or_path: Markdown content or path to .md file
        confirm: MUST be True for programmatic callers. If False and the
                 session is interactive, the user will be prompted to
                 confirm. Otherwise the operation is refused.

    Safety:
        This is a DESTRUCTIVE operation — the existing content is
        replaced entirely. Displays resolved title + URL + a preview of
        the new content so the user can verify the target.
    """
    print('📝 Updating Markdown document...')
    try:
        cookies = ensure_login()

        # Resolve the real padId from the document page
        print('   Resolving document ID...')
        doc_meta = resolve_real_pad_id(cookies, doc_url)
        global_pad_id = doc_meta['globalPadId']
        pad_id = doc_meta['padId']
        doc_title = doc_meta.get('title', '') or '(untitled)'

        # Determine if content_or_path is a file path or direct content
        content = content_or_path
        source_hint = 'inline text'
        resolved_path = str(Path(content_or_path).resolve())
        if os.path.exists(resolved_path) and resolved_path.endswith('.md'):
            with open(resolved_path, 'r', encoding='utf-8') as f:
                content = f.read()
            source_hint = f'file: {resolved_path}'
            print(f"   Updating from file: {resolved_path}")

        # Safety gate: require explicit confirmation before overwriting.
        if not confirm:
            preview = (content[:120] + '…') if len(content) > 120 else content
            preview = preview.replace('\n', ' ↵ ')
            ok = _prompt_confirmation(
                action='UPDATE (overwrite existing content)',
                details={
                    'Title': doc_title,
                    'URL': doc_url,
                    'Pad ID': pad_id,
                    'New content source': source_hint,
                    'New content size': f'{len(content)} chars',
                    'Preview': preview,
                },
            )
            if not ok:
                print('❌ Update aborted by user / non-interactive caller.')
                return {'padId': pad_id, 'updated': False, 'aborted': True}

        write_document(cookies, global_pad_id, content)

        print(f"✅ Document updated successfully: \"{doc_title}\"  [{pad_id}]")
        return {'padId': pad_id, 'title': doc_title, 'updated': True}
    except Exception as err:
        print(f"❌ Update failed: {err}")
        raise


def handle_rename(doc_url: str, new_title: str, confirm: bool = False) -> dict:
    """
    Rename a document.

    Args:
        doc_url: Tencent Docs URL
        new_title: New title
        confirm: MUST be True for programmatic callers. If False and the
                 session is interactive, the user will be prompted to
                 confirm. Otherwise the operation is refused.

    Safety:
        Displays both the old title and the proposed new title before
        performing the rename, so the user can verify the target.
    """
    print('✏\ufe0f  Renaming document...')
    try:
        if not new_title or not new_title.strip():
            raise ValueError('New title must be a non-empty string')

        cookies = ensure_login()

        # Resolve the real padId from the document page
        print('   Resolving document ID...')
        doc_meta = resolve_real_pad_id(cookies, doc_url)
        pad_id = doc_meta['padId']
        old_title = doc_meta.get('title', '') or '(untitled)'

        if not pad_id:
            raise RuntimeError(f'Cannot resolve real pad ID from URL: {doc_url}')

        # Safety gate: require explicit confirmation before renaming.
        if not confirm:
            ok = _prompt_confirmation(
                action='RENAME',
                details={
                    'Current title': old_title,
                    'New title': new_title,
                    'URL': doc_url,
                    'Pad ID': pad_id,
                },
            )
            if not ok:
                print('❌ Rename aborted by user / non-interactive caller.')
                return {'padId': pad_id, 'renamed': False, 'aborted': True}

        result = rename_document(cookies, pad_id, new_title)

        print(f"✅ Document renamed: \"{old_title}\" → \"{new_title}\"")
        return {'padId': pad_id, 'oldTitle': old_title, 'newTitle': new_title, 'raw': result}
    except Exception as err:
        print(f"❌ Rename failed: {err}")
        raise

def handle_info(doc_url: str) -> dict:
    """
    Get document information.

    Args:
        doc_url: Tencent Docs URL
    """
    print('ℹ️  Getting document info...')
    try:
        cookies = ensure_login()
        pad_id = parse_pad_id_from_url(doc_url)

        if not pad_id:
            raise RuntimeError(f'Cannot parse document ID from URL: {doc_url}')

        info = get_document_info(cookies, pad_id)
        print('✅ Document info retrieved.')
        print(json.dumps(info, indent=2, ensure_ascii=False))
        return info
    except Exception as err:
        print(f"❌ Info failed: {err}")
        raise


def handle_login(force: bool = False) -> None:
    """Login / re-login."""
    if force:
        force_re_login()
    else:
        ensure_login()


# ── CLI Entry ────────────────────────────────────────

@click.group()
@click.version_option(version='1.0.0')
def cli():
    """Tencent Docs Markdown CLI Tool"""
    pass


@cli.command()
@click.option('--force', is_flag=True, help='Force re-login (clear existing cookies)')
def login(force):
    """Login via QR code scanning."""
    handle_login(force)


@cli.command()
def logout():
    """Clear the locally stored session cookies.

    Removes the `.cookies.json` credential file so the session can no
    longer be used. Run this when you're done using the skill or before
    handing the machine to another user.
    """
    clear_cookies()


@cli.command()
@click.argument('title')
@click.option('-c', '--content', default=None, help='Initial Markdown content')
def create(title, content):
    """Create a new Markdown document."""
    handle_create(title, content)


@cli.command()
@click.argument('title')
@click.argument('content')
def write(title, content):
    """Create a new Tencent Docs Markdown and write content to it."""
    handle_create_and_write(title, content)


@cli.command()
@click.argument('url')
@click.option('-o', '--output', default=None, help='Output file path')
def download(url, output):
    """Download a Tencent Docs Markdown document to local."""
    handle_download(url, output)


@cli.command()
@click.argument('url')
@click.option('--yes', '-y', 'yes', is_flag=True, default=False,
              help='Skip the interactive confirmation prompt (dangerous).')
def delete(url, yes):
    """Delete a Tencent Docs Markdown document (move to trash)."""
    handle_delete(url, confirm=yes)


@cli.command()
@click.argument('url')
def read(url):
    """Read and display document content."""
    handle_read(url)


@cli.command()
@click.argument('url')
@click.argument('content')
@click.option('--yes', '-y', 'yes', is_flag=True, default=False,
              help='Skip the interactive confirmation prompt (dangerous).')
def update(url, content, yes):
    """Update document content (text or .md file path)."""
    handle_update(url, content, confirm=yes)


@cli.command()
@click.argument('url')
@click.argument('title')
@click.option('--yes', '-y', 'yes', is_flag=True, default=False,
              help='Skip the interactive confirmation prompt (dangerous).')
def rename(url, title, yes):
    """Rename a document."""
    handle_rename(url, title, confirm=yes)


@cli.command()
@click.argument('url')
def info(url):
    """Get document information."""
    handle_info(url)


if __name__ == '__main__':
    cli()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)