Skill: wscats/weiyun-skills (version 1.0.7)

--- name: weiyun-management license: MIT metadata: version: 1.0.7 source: https://github.com/enoyao/weiyun-skills homepage: https://github.com/enoyao/weiyun-skills install: type: instruction-only requirements: "Python >= 3.8 and the packages pinned in requirements.txt (requests==2.31.0, qrcode==7.4.2, tabulate==0.9.0, optionally Pillow==10.3.0)" spec: | This skill is instruction-only: installation means cloning/unpacking the repository and running `pip install -r requirements.txt`. No remote installer is executed and no post-install hook runs on the host. credentials: - name: weiyun-session-cookies kind: password-equivalent stored_at: cookies.json (mode 0600, owner-read/write only) scope: > Full access to the authenticated user's Tencent Weiyun account, including listing, uploading, downloading, modifying, deleting and publicly sharing any file in that account. notes: > The skill persists Weiyun session cookies (uin / skey / p_skey / pt4_token etc.) locally. Anyone who can read cookies.json can act on the user's behalf until the cookies expire (~24h). Required handling rules (these are rules, not suggestions): (1) Use a dedicated or non-primary Weiyun account whenever possible – do NOT log in with an account that also holds other sensitive assets. (2) cookies.json is already listed in .gitignore and in the publish.py IGNORED_RELATIVE_PATHS allow-list, so it can never be committed to git nor shipped to ClawHub. Keep it that way; never move, copy or symlink cookies.json into a synced / shared / backed-up folder (iCloud, OneDrive, Dropbox, Git, Docker images, CI artifacts, log bundles, chat uploads, etc.). (3) An AI agent running this skill MUST NOT read, print, log, screenshot, base64-encode, transmit or otherwise exfiltrate the contents of cookies.json; it may only invoke the CLI which consumes the file internally. (4) Run `rm cookies.json` as soon as you are done with the skill, or whenever you will not use it again within 24h. (5) If you suspect leakage, immediately revoke the session by logging out of Weiyun on all devices, then re-login to invalidate the stolen cookies. capabilities: mutating: - upload_file - upload_folder - delete_file - move_file - copy_file - rename_file - create_folder - restore_file - clear_recycle_bin exposure: - create_share - cancel_share safeguards: > Defense in depth – the skill enforces safety at three layers: (Layer 1, CLI) Every handler that mutates the user's Weiyun account or exposes content externally routes through the same `_confirm()` gate before any API call. This covers: upload, upload-folder, delete (including --permanent), move, copy, rename, mkdir, restore, clear-recycle, share and unshare. `_confirm()` requires an interactive y/yes reply on a TTY; in non-TTY / agent contexts it fails closed unless the user passes `--yes`. `clear-recycle` additionally requires `--confirm`. `upload` / `upload-folder` print an extra warning when `--overwrite` is on, because overwriting a remote file silently destroys its previous contents. (Layer 2, agent use-policy – BINDING for any AI agent that loads this skill) The CLI gates above are necessary but not sufficient, because an agent could in principle be tricked into approving operations in bulk. Therefore, when executing this skill an agent MUST additionally obey all of the following rules: (a) Per-action approval: each mutating or exposure command must be initiated by an explicit, current user instruction that names the exact target (e.g. "delete /foo/bar.txt"). The agent must NOT chain or batch mutating commands on its own initiative, and must NOT "clean up", "reorganise" or "optimise" the user's Weiyun account unprompted. (b) No silent --yes: the agent must NOT pass `--yes` (or any other confirmation-bypassing flag) unless the user has, in this same session, explicitly granted permission to bypass the prompt for this specific operation. Blanket pre-approval such as "always say yes" must be refused. (c) No destructive retries: if a mutating command fails, the agent must NOT automatically retry with escalated flags (e.g. switch `delete` to `delete --permanent`, or add `--overwrite` on retry) – it must surface the failure to the user and ask. (d) No exfiltration of credentials: the agent must NOT read, print, log, transmit or otherwise surface the contents of cookies.json, and must NOT include cookies.json in tarballs, commits, uploads, screenshots or tool outputs. (e) Scope discipline: the agent should only touch paths the user mentioned. Operations at or near the Weiyun root (`/`) and recursive folder operations require a second, explicit confirmation from the user that names the root path. (Layer 3, host) cookies.json is created with mode 0600, listed in .gitignore, and explicitly excluded from the publish artifact by publish.py (see IGNORED_RELATIVE_PATHS). The user is instructed to keep it out of synced / backed-up folders and to `rm` it when done. description: > This skill should be used ONLY when the user explicitly asks to perform a specific Tencent Weiyun cloud-storage operation – e.g. a named file upload, download, share, rename, move, delete, or a targeted space / recycle-bin query. It is NOT a general-purpose "manage my cloud drive" agent: the skill holds password-equivalent session cookies and can read, modify, delete or publicly share any file in the authenticated account, so every mutating or exposure command must be tied to an explicit, per-action user instruction (see `capabilities.safeguards` for the binding agent use-policy). Typical in-scope user requests are "upload FILE to weiyun", "download FILE from weiyun", "share FILE on weiyun", "check my weiyun space", "login to weiyun", plus their Chinese equivalents ("上传到微云", "从微云下载", "微云分享", "微云空间", "扫码登录", etc.). Trigger phrases: "upload to weiyun", "download from weiyun", "weiyun share", "weiyun space", "manage weiyun files", "weiyun login", "scan QR code", "微云管理", "微云上传", "微云下载", "微云分享", "微云空间", "扫码登录", "文件管理", "云存储管理", "微云文件", "weiyun files", "cloud storage". --- # SKILL.md — 腾讯微云管理 Skills 定义 > **使用方法**：本文档定义了所有可用的腾讯微云管理 Skills。AI Agent 或开发者可根据此文档调用 Python 脚本完成云存储操作。 > > **认证方式**（二选一）： > ```bash > # Method 1: QR code login (recommended) > python weiyun_skills/login.py --method qrcode > > # Method 2: Copy cookies from browser > python weiyun_skills/login.py --method cookies --cookies "uin=o012345678; skey=@abcdef1234; ..." > ``` > > **调用方式**： > ```bash > # CLI > python weiyun_skills/main.py <command> [args] [options] > > # Python SDK > from weiyun_skills.client import WeiyunClient > client = WeiyunClient() > client.<skill_name>(**params) > ``` > > **统一返回格式**： > ```json > { "success": true, "data": { ... }, "message": "ok" } > ``` --- ## 目录 - [SKILL.md — 腾讯微云管理 Skills 定义](#skillmd--腾讯微云管理-skills-定义) - [目录](#目录) - [1. 认证 Skills](#1-认证-skills) - [1.1 qrcode\_login — 扫码登录](#11-qrcode_login--扫码登录) - [1.2 cookies\_login — Cookies 登录](#12-cookies_login--cookies-登录) - [2. 文件管理 Skills](#2-文件管理-skills) - [2.1 list\_files — 列出文件](#21-list_files--列出文件) - [2.2 upload\_file — 上传文件](#22-upload_file--上传文件) - [2.3 upload\_folder — 上传文件夹](#23-upload_folder--上传文件夹) - [2.4 download\_file — 下载文件](#24-download_file--下载文件) - [2.5 download\_folder — 下载文件夹](#25-download_folder--下载文件夹) - [2.6 delete\_file — 删除文件](#26-delete_file--删除文件) - [2.7 move\_file — 移动文件](#27-move_file--移动文件) - [2.8 copy\_file — 复制文件](#28-copy_file--复制文件) - [2.9 rename\_file — 重命名](#29-rename_file--重命名) - [2.10 create\_folder — 创建文件夹](#210-create_folder--创建文件夹) - [2.11 search\_files — 搜索文件](#211-search_files--搜索文件) - [3. 分享管理 Skills](#3-分享管理-skills) - [3.1 create\_share — 创建分享](#31-create_share--创建分享) - [3.2 cancel\_share — 取消分享](#32-cancel_share--取消分享) - [3.3 list\_shares — 列出分享](#33-list_shares--列出分享) - [4. 空间管理 Skills](#4-空间管理-skills) - [4.1 get\_space\_info — 空间信息](#41-get_space_info--空间信息) - [4.2 get\_recycle\_bin — 回收站](#42-get_recycle_bin--回收站) - [4.3 restore\_file — 恢复文件](#43-restore_file--恢复文件) - [4.4 clear\_recycle\_bin — 清空回收站](#44-clear_recycle_bin--清空回收站) - [附录 A：统一错误码](#附录-a统一错误码) - [附录 B：Cookies 关键字段说明](#附录-bcookies-关键字段说明) --- ## 1. 认证 Skills ### 1.1 qrcode_login — 扫码登录 **描述**：生成腾讯微云登录二维码，用户使用微信/QQ 扫码完成认证。登录成功后自动保存 Cookies 到 `cookies.json`。 **CLI**： ```bash python weiyun_skills/login.py --method qrcode ``` **Python**： ```python from weiyun_skills.login import qrcode_login cookies = qrcode_login() # Terminal will display QR code, scan with WeChat/QQ # After success, cookies are saved to cookies.json ``` **输入参数**： | 参数名 | 类型 | 必填 | 默认值 | 说明 | |--------|------|------|--------|------| | `save_path` | `string` | ❌ | `cookies.json` | Cookies 保存路径 | **输出参数**： | 参数名 | 类型 | 说明 | |--------|------|------| | `success` | `boolean` | 是否登录成功 | | `uin` | `string` | 用户 UIN | | `nickname` | `string` | 用户昵称 | | `cookies_str` | `string` | Cookies 字符串 | | `save_path` | `string` | Cookies 保存路径 | **流程**： ``` ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │ Request QR │────▶│ Display QR │────▶│ User scans │ │ code URL │ │ in terminal │ │ with WeChat │ └─────────────┘ └──────────────┘ └──────┬──────┘ │ ┌─────────────┐ ┌──────────────┐ │ │ Save to │◀────│ Get cookies │◀────────────┘ │ cookies.json│ │ from server │ └─────────────┘ └──────────────┘ ``` --- ### 1.2 cookies_login — Cookies 登录 **描述**：使用从浏览器复制的 Cookies 字符串完成登录认证。 **CLI**： ```bash python weiyun_skills/login.py --method cookies --cookies "uin=o012345678; skey=@abcdef1234; p_uin=o012345678; pt4_token=xxxxx; p_skey=xxxxx" ``` **Python**： ```python from weiyun_skills.login import cookies_login cookies = cookies_login( cookies_str="uin=o012345678; skey=@abcdef1234; ..." ) ``` **如何获取 Cookies**： 1. 打开浏览器访问 https://www.weiyun.com/ 并登录 2. 按 `F12` 打开开发者工具 3. 切换到 `Network`（网络）标签页 4. 刷新页面，点击任意一个请求 5. 在 `Headers`（请求头）中找到 `Cookie` 字段 6. 复制完整的 Cookie 值 **输入参数**： | 参数名 | 类型 | 必填 | 默认值 | 说明 | |--------|------|------|--------|------| | `cookies_str` | `string` | ✅ | - | 从浏览器复制的 Cookie 字符串 | | `save_path` | `string` | ❌ | `cookies.json` | Cookies 保存路径 | **输出参数**： | 参数名 | 类型 | 说明 | |--------|------|------| | `success` | `boolean` | 是否验证成功 | | `uin` | `string` | 用户 UIN | | `nickname` | `string` | 用户昵称 | | `save_path` | `string` | Cookies 保存路径 | --- ## 2. 文件管理 Skills ### 2.1 list_files — 列出文件 **描述**：列出微云指定目录下的所有文件和文件夹。 **CLI**： ```bash python weiyun_skills/main.py list / python weiyun_skills/main.py list /我的文档 --sort size --order desc ``` **Python**： ```python files = client.list_files("/我的文档", sort_by="size", sort_order="desc") ``` **输入参数**： | 参数名 | 类型 | 必填 | 默认值 | 说明 | |--------|------|------|--------|------| | `remote_path` | `string` | ❌ | `/` | 目录路径，默认根目录 | | `sort_by` | `string` | ❌ | `name` | 排序字段：`name`/`size`/`time` | | `sort_order` | `string` | ❌ | `asc` | 排序方向：`asc`/`desc` | | `page` | `integer` | ❌ | `1` | 分页页码 | | `page_size` | `integer` | ❌ | `100` | 每页数量 | **输出参数**： | 参数名 | 类型 | 说明 | |--------|------|------| | `files` | `array` | 文件列表 | | `files[].file_id` | `string` | 文件唯一 ID | | `files[].name` | `string` | 文件名 | | `files[].type` | `string` | `file` 或 `folder` | | `files[].size` | `integer` | 大小（字节） | | `files[].size_str` | `string` | 可读大小（如 `2.5 MB`） | | `files[].path` | `string` | 完整路径 | | `files[].updated_at` | `string` | 最后修改时间 | | `total` | `integer` | 总数量 | **示例输出**： ```json { "success": true, "data": { "files": [ { "file_id": "f_abc123", "name": "report.pdf", "type": "file", "size": 2621440, "size_str": "2.5 MB", "path": "/我的文档/report.pdf", "updated_at": "2026-03-15 10:30:00" }, { "file_id": "d_folder01", "name": "照片", "type": "folder", "size": 0, "size_str": "-", "path": "/我的文档/照片", "updated_at": "2026-03-14 08:00:00" } ], "total": 2 }, "message": "ok" } ``` --- ### 2.2 upload_file — 上传文件 **描述**：将本地文件上传到微云指定目录。支持大文件分片上传。 **CLI**： ```bash python weiyun_skills/main.py upload ./report.pdf /我的文档/ python weiyun_skills/main.py upload ./big_video.mp4 /视频/ --overwrite ``` **Python**： ```python result = client.upload_file("./report.pdf", "/我的文档/report.pdf", overwrite=True) ``` **输入参数**： | 参数名 | 类型 | 必填 | 默认值 | 说明 | |--------|------|------|--------|------| | `local_path` | `string` | ✅ | - | 本地文件路径 | | `remote_path` | `string` | ✅ | - | 微云目标路径 | | `overwrite` | `boolean` | ❌ | `false` | 是否覆盖同名文件 | **输出参数**： | 参数名 | 类型 | 说明 | |--------|------|------| | `file_id` | `string` | 上传后的文件 ID | | `name` | `string` | 文件名 | | `size` | `integer` | 文件大小 | | `remote_path` | `string` | 云端路径 | | `md5` | `string` | 文件 MD5 | | `uploaded_at` | `string` | 上传时间 | --- ### 2.3 upload_folder — 上传文件夹 **描述**：将本地文件夹递归上传到微云，自动创建对应的目录结构。跳过隐藏文件和 `__pycache__` 等缓存目录。 **CLI**： ```bash # Upload folder to Weiyun root python weiyun_skills/main.py upload-folder ./my_docs/ # Upload folder to a specific remote directory python weiyun_skills/main.py upload-folder ./my_docs/ /目标文件夹/ # Overwrite existing files python weiyun_skills/main.py upload-folder ./my_docs/ / --overwrite ``` **Python**： ```python # Upload to root result = client.upload_folder("./my_docs/") # Upload to a specific folder, overwrite existing result = client.upload_folder("./my_docs/", remote_path="/目标文件夹/", overwrite=True) ``` **输入参数**： | 参数名 | 类型 | 必填 | 默认值 | 说明 | |--------|------|------|--------|------| | `local_path` | `string` | ✅ | - | 本地文件夹路径 | | `remote_path` | `string` | ❌ | `/` | 微云目标路径（`/` 表示根目录） | | `overwrite` | `boolean` | ❌ | `false` | 是否覆盖同名文件 | **输出参数**： | 参数名 | 类型 | 说明 | |--------|------|------| | `folder_name` | `string` | 上传的文件夹名称 | | `uploaded_files` | `array` | 成功上传的文件列表 | | `uploaded_files[].name` | `string` | 文件名 | | `uploaded_files[].size` | `integer` | 文件大小（字节） | | `uploaded_files[].size_str` | `string` | 可读大小 | | `uploaded_files[].instant_upload` | `boolean` | 是否秒传 | | `failed_files` | `array` | 上传失败的文件列表 | | `failed_files[].name` | `string` | 文件名 | | `failed_files[].error` | `string` | 错误信息 | | `uploaded_count` | `integer` | 成功上传数量 | | `failed_count` | `integer` | 失败数量 | | `total_size_str` | `string` | 总上传大小 | | `elapsed` | `float` | 上传耗时（秒） | **示例输出**： ```json { "success": true, "data": { "folder_name": "upload_file", "uploaded_files": [ { "name": "abc.txt", "size": 6, "size_str": "6.00 B", "instant_upload": false } ], "failed_files": [], "uploaded_count": 1, "failed_count": 0, "total_size_str": "6.00 B", "elapsed": 2.35 }, "message": "ok" } ``` --- ### 2.4 download_file — 下载文件 **描述**：从微云下载文件到本地。 **CLI**： ```bash python weiyun_skills/main.py download /我的文档/report.pdf ./downloads/ python weiyun_skills/main.py download /我的文档/report.pdf ./downloads/ --overwrite ``` **Python**： ```python result = client.download_file("/我的文档/report.pdf", "./downloads/report.pdf") ``` **输入参数**： | 参数名 | 类型 | 必填 | 默认值 | 说明 | |--------|------|------|--------|------| | `remote_path` | `string` | ✅ | - | 微云文件路径 | | `local_path` | `string` | ✅ | - | 本地保存路径 | | `overwrite` | `boolean` | ❌ | `false` | 是否覆盖本地文件 | **输出参数**： | 参数名 | 类型 | 说明 | |--------|------|------| | `local_path` | `string` | 本地保存路径 | | `size` | `integer` | 文件大小 | | `md5` | `string` | MD5 校验值 | | `elapsed` | `float` | 下载耗时（秒） | --- ### 2.5 download_folder — 下载文件夹 **描述**：从微云下载整个文件夹到本地。支持两种模式：递归下载（保留目录结构）和打包下载（下载为 zip 文件）。 **CLI**： ```bash # Recursive download (preserves folder structure) python weiyun_skills/main.py download-folder QQ ./downloads/ python weiyun_skills/main.py download-folder QQ ./downloads/ --overwrite # Download as zip python weiyun_skills/main.py download-folder QQ ./downloads/ --zip ``` **Python**： ```python # Recursive download result = client.download_folder("QQ", "./downloads/") # Download as zip result = client.download_folder("QQ", "./downloads/", as_zip=True) ``` **输入参数**： | 参数名 | 类型 | 必填 | 默认值 | 说明 | |--------|------|------|--------|------| | `folder_name` | `string` | ✅ | - | 微云上的文件夹名称 | | `local_path` | `string` | ✅ | - | 本地保存目录（或 zip 文件路径） | | `overwrite` | `boolean` | ❌ | `false` | 是否覆盖已存在的本地文件 | | `as_zip` | `boolean` | ❌ | `false` | 是否打包为 zip 下载 | **输出参数（递归模式）**： | 参数名 | 类型 | 说明 | |--------|------|------| | `folder_name` | `string` | 文件夹名称 | | `local_path` | `string` | 本地保存路径 | | `downloaded_files` | `array` | 成功下载的文件列表 | | `downloaded_files[].name` | `string` | 文件名 | | `downloaded_files[].local_path` | `string` | 本地路径 | | `downloaded_files[].size` | `integer` | 文件大小（字节） | | `downloaded_files[].size_str` | `string` | 可读大小 | | `failed_files` | `array` | 下载失败的文件列表 | | `failed_files[].name` | `string` | 文件名 | | `failed_files[].error` | `string` | 错误信息 | | `downloaded_count` | `integer` | 成功下载数量 | | `failed_count` | `integer` | 失败数量 | | `total_size_str` | `string` | 总下载大小 | | `elapsed` | `float` | 下载耗时（秒） | **输出参数（zip 模式）**： | 参数名 | 类型 | 说明 | |--------|------|------| | `local_path` | `string` | zip 文件本地路径 | | `size` | `integer` | 文件大小（字节） | | `size_str` | `string` | 可读大小 | | `md5` | `string` | MD5 校验值 | | `elapsed` | `float` | 下载耗时（秒） | **示例输出（递归模式）**： ```json { "success": true, "data": { "folder_name": "QQ", "local_path": "./downloads/QQ", "downloaded_files": [ { "name": "report.pdf", "local_path": "./downloads/QQ/report.pdf", "size": 8663503, "size_str": "8.26 MB" } ], "failed_files": [], "downloaded_count": 6, "failed_count": 0, "total_size_str": "33.11 MB", "elapsed": 5.88 }, "message": "ok" } ``` --- ### 2.6 delete_file — 删除文件 **描述**：删除微云文件或文件夹（移入回收站）。 **CLI**： ```bash python weiyun_skills/main.py delete /我的文档/old_file.pdf python weiyun_skills/main.py delete /我的文档/old_file.pdf --permanent ``` **Python**： ```python result = client.delete_file("/我的文档/old_file.pdf", permanent=False) ``` **输入参数**： | 参数名 | 类型 | 必填 | 默认值 | 说明 | |--------|------|------|--------|------| | `remote_path` | `string` | ✅ | - | 文件/文件夹路径 | | `permanent` | `boolean` | ❌ | `false` | 是否永久删除（跳过回收站） | **输出参数**： | 参数名 | 类型 | 说明 | |--------|------|------| | `deleted_path` | `string` | 已删除的路径 | | `is_permanent` | `boolean` | 是否永久删除 | | `deleted_at` | `string` | 删除时间 | --- ### 2.7 move_file — 移动文件 **描述**：将文件或文件夹移动到另一个目录。 **CLI**： ```bash python weiyun_skills/main.py move /我的文档/report.pdf /归档/2026/ ``` **Python**： ```python result = client.move_file("/我的文档/report.pdf", "/归档/2026/") ``` **输入参数**： | 参数名 | 类型 | 必填 | 默认值 | 说明 | |--------|------|------|--------|------| | `source_path` | `string` | ✅ | - | 源路径 | | `target_path` | `string` | ✅ | - | 目标目录路径 | **输出参数**： | 参数名 | 类型 | 说明 | |--------|------|------| | `source_path` | `string` | 原路径 | | `target_path` | `string` | 新路径 | --- ### 2.8 copy_file — 复制文件 **描述**：复制文件或文件夹到另一个目录。 **CLI**： ```bash python weiyun_skills/main.py copy /我的文档/report.pdf /备份/ ``` **Python**： ```python result = client.copy_file("/我的文档/report.pdf", "/备份/") ``` **输入参数**： | 参数名 | 类型 | 必填 | 默认值 | 说明 | |--------|------|------|--------|------| | `source_path` | `string` | ✅ | - | 源路径 | | `target_path` | `string` | ✅ | - | 目标目录路径 | **输出参数**： | 参数名 | 类型 | 说明 | |--------|------|------| | `source_path` | `string` | 源路径 | | `target_path` | `string` | 副本路径 | | `new_file_id` | `string` | 副本文件 ID | --- ### 2.9 rename_file — 重命名 **描述**：重命名文件或文件夹。 **CLI**： ```bash python weiyun_skills/main.py rename /我的文档/report.pdf "年度报告.pdf" ``` **Python**： ```python result = client.rename_file("/我的文档/report.pdf", "年度报告.pdf") ``` **输入参数**： | 参数名 | 类型 | 必填 | 默认值 | 说明 | |--------|------|------|--------|------| | `remote_path` | `string` | ✅ | - | 文件当前路径 | | `new_name` | `string` | ✅ | - | 新文件名 | **输出参数**： | 参数名 | 类型 | 说明 | |--------|------|------| | `old_path` | `string` | 原路径 | | `new_path` | `string` | 新路径 | --- ### 2.10 create_folder — 创建文件夹 **描述**：在微云上创建文件夹，支持递归创建多级目录。 **CLI**： ```bash python weiyun_skills/main.py mkdir /工作/2026/Q1/报告 ``` **Python**： ```python result = client.create_folder("/工作/2026/Q1/报告") ``` **输入参数**： | 参数名 | 类型 | 必填 | 默认值 | 说明 | |--------|------|------|--------|------| | `remote_path` | `string` | ✅ | - | 文件夹路径 | **输出参数**： | 参数名 | 类型 | 说明 | |--------|------|------| | `folder_id` | `string` | 文件夹 ID | | `path` | `string` | 完整路径 | | `created_at` | `string` | 创建时间 | --- ### 2.11 search_files — 搜索文件 **描述**：按关键词搜索微云中的文件。 **CLI**： ```bash python weiyun_skills/main.py search "报告" python weiyun_skills/main.py search "报告" --type document ``` **Python**： ```python results = client.search_files("报告", file_type="document") ``` **输入参数**： | 参数名 | 类型 | 必填 | 默认值 | 说明 | |--------|------|------|--------|------| | `keyword` | `string` | ✅ | - | 搜索关键词 | | `file_type` | `string` | ❌ | `all` | 类型过滤：`all`/`document`/`image`/`video`/`audio` | | `page` | `integer` | ❌ | `1` | 分页页码 | | `page_size` | `integer` | ❌ | `50` | 每页数量 | **输出参数**： | 参数名 | 类型 | 说明 | |--------|------|------| | `results` | `array` | 搜索结果列表 | | `results[].file_id` | `string` | 文件 ID | | `results[].name` | `string` | 文件名 | | `results[].type` | `string` | 类型 | | `results[].size_str` | `string` | 可读大小 | | `results[].path` | `string` | 路径 | | `total` | `integer` | 匹配总数 | --- ## 3. 分享管理 Skills ### 3.1 create_share — 创建分享 **描述**：为文件或文件夹创建分享链接，支持设置密码和有效期。 **CLI**： ```bash python weiyun_skills/main.py share /我的文档/report.pdf python weiyun_skills/main.py share /我的文档/report.pdf --expire 7 --password abc1 ``` **Python**： ```python share = client.create_share( "/我的文档/report.pdf", expire_days=7, password="abc1" ) print(share["share_url"]) ``` **输入参数**： | 参数名 | 类型 | 必填 | 默认值 | 说明 | |--------|------|------|--------|------| | `remote_path` | `string` | ✅ | - | 文件/文件夹路径 | | `password` | `string` | ❌ | `null` | 分享密码（4 位） | | `expire_days` | `integer` | ❌ | `0` | 有效天数，0 为永久 | **输出参数**： | 参数名 | 类型 | 说明 | |--------|------|------| | `share_id` | `string` | 分享 ID | | `share_url` | `string` | 分享链接 | | `password` | `string` | 分享密码 | | `expire_at` | `string` | 过期时间 | | `created_at` | `string` | 创建时间 | **示例输出**： ```json { "success": true, "data": { "share_id": "s_abc123", "share_url": "https://share.weiyun.com/xxxx", "password": "abc1", "expire_at": "2026-03-22 21:00:00", "created_at": "2026-03-15 21:00:00" }, "message": "ok" } ``` --- ### 3.2 cancel_share — 取消分享 **描述**：取消已创建的分享链接。 **CLI**： ```bash python weiyun_skills/main.py unshare s_abc123 ``` **Python**： ```python result = client.cancel_share("s_abc123") ``` **输入参数**： | 参数名 | 类型 | 必填 | 默认值 | 说明 | |--------|------|------|--------|------| | `share_id` | `string` | ✅ | - | 分享 ID | **输出参数**： | 参数名 | 类型 | 说明 | |--------|------|------| | `share_id` | `string` | 已取消的分享 ID | | `cancelled_at` | `string` | 取消时间 | --- ### 3.3 list_shares — 列出分享 **描述**：列出当前用户所有的分享链接。 **CLI**： ```bash python weiyun_skills/main.py shares python weiyun_skills/main.py shares --status active ``` **Python**： ```python shares = client.list_shares(status="active") ``` **输入参数**： | 参数名 | 类型 | 必填 | 默认值 | 说明 | |--------|------|------|--------|------| | `status` | `string` | ❌ | `all` | 状态过滤：`all`/`active`/`expired` | | `page` | `integer` | ❌ | `1` | 分页页码 | | `page_size` | `integer` | ❌ | `20` | 每页数量 | **输出参数**： | 参数名 | 类型 | 说明 | |--------|------|------| | `shares` | `array` | 分享列表 | | `shares[].share_id` | `string` | 分享 ID | | `shares[].share_url` | `string` | 分享链接 | | `shares[].file_name` | `string` | 文件名 | | `shares[].status` | `string` | 状态 | | `shares[].view_count` | `integer` | 查看次数 | | `shares[].download_count` | `integer` | 下载次数 | | `shares[].created_at` | `string` | 创建时间 | | `shares[].expire_at` | `string` | 过期时间 | | `total` | `integer` | 总数量 | --- ## 4. 空间管理 Skills ### 4.1 get_space_info — 空间信息 **描述**：获取微云存储空间使用情况。 **CLI**： ```bash python weiyun_skills/main.py space ``` **Python**： ```python info = client.get_space_info() print(f"Used: {info['used_space_str']} / {info['total_space_str']}") ``` **输入参数**：无 **输出参数**： | 参数名 | 类型 | 说明 | |--------|------|------| | `total_space` | `integer` | 总空间（字节） | | `total_space_str` | `string` | 可读总空间（如 `10 GB`） | | `used_space` | `integer` | 已用空间（字节） | | `used_space_str` | `string` | 可读已用空间 | | `free_space` | `integer` | 剩余空间（字节） | | `free_space_str` | `string` | 可读剩余空间 | | `usage_percent` | `float` | 使用百分比 | | `file_count` | `integer` | 文件总数 | | `folder_count` | `integer` | 文件夹总数 | **示例输出**： ```json { "success": true, "data": { "total_space": 10737418240, "total_space_str": "10.00 GB", "used_space": 5368709120, "used_space_str": "5.00 GB", "free_space": 5368709120, "free_space_str": "5.00 GB", "usage_percent": 50.0, "file_count": 1234, "folder_count": 56 }, "message": "ok" } ``` --- ### 4.2 get_recycle_bin — 回收站 **描述**：获取回收站中的文件列表。 **CLI**： ```bash python weiyun_skills/main.py recycle ``` **Python**： ```python items = client.get_recycle_bin() ``` **输入参数**： | 参数名 | 类型 | 必填 | 默认值 | 说明 | |--------|------|------|--------|------| | `page` | `integer` | ❌ | `1` | 分页页码 | | `page_size` | `integer` | ❌ | `50` | 每页数量 | **输出参数**： | 参数名 | 类型 | 说明 | |--------|------|------| | `files` | `array` | 回收站文件列表 | | `files[].file_id` | `string` | 文件 ID | | `files[].name` | `string` | 文件名 | | `files[].size_str` | `string` | 可读大小 | | `files[].original_path` | `string` | 原始路径 | | `files[].deleted_at` | `string` | 删除时间 | | `total` | `integer` | 总数量 | | `total_size_str` | `string` | 回收站总大小 | --- ### 4.3 restore_file — 恢复文件 **描述**：从回收站恢复文件到原始位置。 **CLI**： ```bash python weiyun_skills/main.py restore f_del001 ``` **Python**： ```python result = client.restore_file("f_del001") ``` **输入参数**： | 参数名 | 类型 | 必填 | 默认值 | 说明 | |--------|------|------|--------|------| | `file_id` | `string` | ✅ | - | 回收站中的文件 ID | **输出参数**： | 参数名 | 类型 | 说明 | |--------|------|------| | `file_id` | `string` | 文件 ID | | `restored_path` | `string` | 恢复后路径 | | `restored_at` | `string` | 恢复时间 | --- ### 4.4 clear_recycle_bin — 清空回收站 **描述**：清空回收站，永久删除所有回收站文件。**⚠️ 此操作不可逆！** **CLI**： ```bash python weiyun_skills/main.py clear-recycle --confirm ``` **Python**： ```python result = client.clear_recycle_bin(confirm=True) ``` **输入参数**： | 参数名 | 类型 | 必填 | 默认值 | 说明 | |--------|------|------|--------|------| | `confirm` | `boolean` | ✅ | - | 必须为 `true` 才执行 | **输出参数**： | 参数名 | 类型 | 说明 | |--------|------|------| | `deleted_count` | `integer` | 删除文件数 | | `freed_space_str` | `string` | 释放的空间大小 | | `cleared_at` | `string` | 清空时间 | --- ## 附录 A：统一错误码 | 错误码 | 说明 | |--------|------| | `AUTH_EXPIRED` | Cookies 已过期，需重新登录 | | `AUTH_FAILED` | 认证失败 | | `FILE_NOT_FOUND` | 文件不存在 | | `FOLDER_NOT_FOUND` | 文件夹不存在 | | `SPACE_FULL` | 空间已满 | | `FILE_TOO_LARGE` | 文件过大 | | `DUPLICATE_NAME` | 名称重复 | | `PERMISSION_DENIED` | 权限不足 | | `RATE_LIMITED` | 请求频率超限 | | `NETWORK_ERROR` | 网络错误 | | `SHARE_EXPIRED` | 分享已过期 | | `INVALID_PARAM` | 参数无效 | | `QR_EXPIRED` | 二维码已过期，需刷新 | | `QR_CANCELLED` | 用户取消了扫码 | **错误响应格式**： ```json { "success": false, "data": null, "message": "Cookies expired, please re-login", "error_code": "AUTH_EXPIRED" } ``` --- ## 附录 B：Cookies 关键字段说明 | Cookie 名称 | 说明 | |-------------|------| | `uin` | 用户 QQ 号标识 | | `skey` | 会话密钥 | | `p_uin` | 加密的用户标识 | | `p_skey` | 加密的会话密钥 | | `pt4_token` | PT4 认证 Token | | `pt2gguin` | 辅助认证字段 | > **提示**：并非所有 Cookie 字段都是必需的，核心字段为 `uin`、`skey`、`p_skey`。

path: README.md
content: # 腾讯微云管理 Skills
> 一套用于管理腾讯微云（Weiyun）云存储服务的 Python 工具集，支持 **扫码登录** 和 **Cookies 登录** 两种认证方式。

---

## 🔐 安全与权限声明（请先阅读）

本 skill 会直接操作用户的腾讯微云账号，属于**高权限工具**，使用前请确认：

1. **凭据持久化** — 登录后会把完整的会话 cookies 写入项目目录下的 `cookies.json`（权限 `0600`，仅当前用户可读写）。
   该文件等价于账号密码：**能读到它的人就能完全控制你的微云账号**（读取、上传、下载、删除、对外分享任意文件），且在腾讯微云侧的有效期通常约 24 小时。
   - 建议使用专用或非主账号登录；
   - 不要把 `cookies.json` 复制到日志、仓库、聊天记录、共享目录中；
   - 用完后及时 `rm cookies.json`。
2. **会变更云端状态的命令**（mutating）：`upload`、`upload-folder`、`delete`、`move`、`copy`、`rename`、`mkdir`、`restore`、`clear-recycle`。
3. **会把文件对外暴露的命令**（exposure）：`share`、`unshare`。
4. **确认机制** — 上述命令在 CLI 中都要求交互式 `y/yes` 确认；在非 TTY / Agent 环境下默认**拒绝执行**，除非显式传入 `--yes`。`--permanent` 删除和 `clear-recycle` 额外打印不可逆警告。
5. **仓库根目录下的 `upload_readme.py`** 只是一次性辅助脚本，默认 **不** 覆盖云端已有 `/README.md`，并需要交互确认；若需非交互运行请显式传 `--yes --overwrite`，或先用 `--dry-run` 预演。
6. **AI Agent 使用契约**（如果你是通过 AI Agent 调用本 skill，以下规则对 Agent 生效）：
   - **逐项授权**：每个 mutating / exposure 命令（`upload`、`delete`、`move`、`copy`、`rename`、`mkdir`、`restore`、`clear-recycle`、`share`、`unshare`、`upload-folder`）都必须由用户在**本次会话中**明确指名目标后再执行。Agent **不得**自行链式执行、批量执行、或以"整理/清理/优化"为由主动修改云端内容。
   - **`--yes` 不得默认启用**：Agent 不得在未获得用户针对该次操作的显式授权时传入 `--yes`；用户的"以后都默认同意"这类概括性授权应被拒绝。
   - **禁止失败升级**：命令失败时不得自动加上更具破坏性的标志重试（例如把 `delete` 换成 `delete --permanent`，或重试时补上 `--overwrite`）—— 必须把失败原样上报给用户。
   - **凭据不可外泄**：Agent 不得读取、打印、截图、转存、上传或以任何方式外泄 `cookies.json` 的内容，也不得把它放进 tar 包、commit、截图、工具输出中。
   - **操作域纪律**：对 Weiyun 根目录 `/` 附近的操作、以及递归的文件夹操作，必须让用户**第二次显式确认**并指名根路径。

---

## 🚀 使用方法

### 1. 安装依赖

```bash
cd weiyun-skills
pip install -r requirements.txt
```

### 2. 登录认证（二选一）

#### 方式一：扫码登录（推荐）

运行扫码登录脚本，终端会显示二维码，使用微信/QQ 扫码即可完成登录：

```bash
python weiyun_skills/login.py --method qrcode
```

扫码成功后，Cookies 会自动保存到 `cookies.json` 文件中，后续操作无需重复登录。

#### 方式二：复制 Cookies 登录

1. 在浏览器中登录 [腾讯微云](https://www.weiyun.com/)
2. 打开开发者工具 (F12) → Network → 任意请求 → 复制 `Cookie` 请求头的值
3. 运行以下命令：

```bash
python weiyun_skills/login.py --method cookies --cookies "your_cookie_string_here"
```

或者直接编辑 `cookies.json` 文件：

```json
{
  "cookies_str": "uin=o012345678; skey=@abcdef1234; ...",
  "update_time": "2026-03-15 21:00:00"
}
```

### 3. 使用 Skills

```bash
# List files in root directory
python weiyun_skills/main.py list /

# Upload a file
python weiyun_skills/main.py upload ./local_file.pdf /云端目录/

# Download a file
python weiyun_skills/main.py download /云端目录/file.pdf ./local_dir/

# Create a share link
python weiyun_skills/main.py share /云端目录/file.pdf --expire 7 --password abc123

# Get space usage info
python weiyun_skills/main.py space

# Search files by keyword
python weiyun_skills/main.py search "报告"

# Delete a file (to recycle bin)
python weiyun_skills/main.py delete /云端目录/old_file.pdf

# Move a file
python weiyun_skills/main.py move /源路径/file.pdf /目标路径/

# Create a folder
python weiyun_skills/main.py mkdir /新文件夹/子文件夹
```

### 4. 在 Python 中调用

```python
from weiyun_skills.client import WeiyunClient

# Initialize client (auto-loads cookies.json)
client = WeiyunClient()

# Or pass cookies string directly
client = WeiyunClient(cookies_str="uin=o012345678; skey=@abcdef1234; ...")

# List files
files = client.list_files("/我的文档")
for f in files:
    print(f["name"], f["size"])

# Upload file
client.upload_file("./report.pdf", "/我的文档/report.pdf")

# Download file
client.download_file("/我的文档/report.pdf", "./downloads/report.pdf")

# Create share link
share = client.create_share("/我的文档/report.pdf", expire_days=7, password="abc1")
print(share["share_url"])

# Get space info
info = client.get_space_info()
print(f"Used: {info['used_space_str']} / {info['total_space_str']}")
```

---

## 📋 项目简介

本项目提供了一组 Python 脚本，用于自动化管理腾讯微云中的文件。核心特性：

- ✅ **扫码登录** — 终端展示二维码，微信/QQ 扫码完成认证
- ✅ **Cookies 登录** — 从浏览器复制 Cookies 快速登录
- ✅ **自动保存会话** — 登录状态持久化，无需重复认证
- ✅ **文件管理** — 上传、下载、删除、移动、复制、重命名、搜索
- ✅ **分享管理** — 创建/取消分享链接，支持密码和有效期
- ✅ **空间管理** — 查看容量、回收站操作
- ✅ **命令行工具** — 所有功能均可通过命令行直接使用

---

## 📁 项目结构

```
weiyun-skills/
├── README.md                        # Project documentation
├── SKILL.md                         # Skills definition
├── LICENSE                          # MIT License
├── requirements.txt                 # Python dependencies
├── cookies.json                     # Saved login cookies (auto-generated)
└── weiyun_skills/
    ├── __init__.py                  # Package init
    ├── login.py                     # QR code login & cookies login
    ├── client.py                    # Weiyun API client
    ├── main.py                      # CLI entry point
    └── utils.py                     # Utility functions
```

---

## ⚙️ 依赖说明

| 依赖包 | 用途 |
|--------|------|
| `requests` | HTTP 请求 |
| `qrcode` | 终端展示扫码登录二维码 |
| `Pillow` | 图片处理（二维码渲染） |
| `tabulate` | 命令行表格输出 |

---

## 🛠️ Skills 一览

详细定义请参阅 [SKILL.md](./SKILL.md)。

| 分类 | Skill | 说明 |
|------|-------|------|
| 🔑 认证 | `qrcode_login` | 扫码登录 |
| 🔑 认证 | `cookies_login` | Cookies 登录 |
| 📂 文件 | `list_files` | 列出文件 |
| 📂 文件 | `upload_file` | 上传文件 |
| 📂 文件 | `download_file` | 下载文件 |
| 📂 文件 | `delete_file` | 删除文件 |
| 📂 文件 | `move_file` | 移动文件 |
| 📂 文件 | `copy_file` | 复制文件 |
| 📂 文件 | `rename_file` | 重命名 |
| 📂 文件 | `create_folder` | 创建文件夹 |
| 📂 文件 | `search_files` | 搜索文件 |
| 🔗 分享 | `create_share` | 创建分享 |
| 🔗 分享 | `cancel_share` | 取消分享 |
| 🔗 分享 | `list_shares` | 列出分享 |
| 💾 空间 | `get_space_info` | 空间信息 |
| 💾 空间 | `get_recycle_bin` | 回收站 |
| 💾 空间 | `restore_file` | 恢复文件 |
| 💾 空间 | `clear_recycle_bin` | 清空回收站 |

---

## 📝 注意事项

1. **Cookies 等价于账号密码** — 请参阅上方「安全与权限声明」。`cookies.json` 默认以 `0600` 权限写入；请勿提交到版本库、上传到云盘或粘贴到聊天中。建议使用专用账号并在使用完毕后删除该文件。
2. **Cookies 有效期**：腾讯微云的 Cookies 通常在 24 小时后过期，届时需重新登录
3. **扫码登录**：需要终端支持 Unicode 字符显示（大部分现代终端均支持）
4. **频率限制**：请避免短时间内大量请求，以免触发风控
5. **大文件上传**：支持分片上传，默认分片大小为 4MB
6. **变更/分享操作需确认**：所有会修改云端文件或对外暴露文件的命令都需要 `y/yes` 确认；在非交互环境请显式传入 `--yes`。

---

## 📄 许可证

[MIT License](./LICENSE) © 2026 enoyao


path: requirements.txt
content: requests==2.31.0
qrcode==7.4.2
tabulate==0.9.0
# Optional: Pillow (for QR code image rendering)
# pip install Pillow==10.3.0


path: upload_readme.py
content: #!/usr/bin/env python3
"""Upload README.md to Weiyun - interactive login + upload script.

Security notes
--------------
This helper will write to the user's Weiyun account. It is documented as a
one-off utility and is *not* part of the default CLI surface. To avoid
accidentally clobbering an existing ``/README.md`` in the user's cloud
storage, the script now:

* defaults to ``overwrite=False`` and will abort if the remote file exists,
* requires an interactive ``y/yes`` confirmation before uploading,
* supports ``--dry-run`` to preview the upload without touching Weiyun, and
* supports ``--yes`` / ``--overwrite`` flags for users who have verified the
  operation and need to run it non-interactively.
"""
import sys
import os
import argparse

# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from weiyun_skills.login import qrcode_login, cookies_login, load_cookies
from weiyun_skills.client import WeiyunClient


def _confirm(prompt: str, assume_yes: bool) -> bool:
    """Interactive confirmation that fails closed outside a TTY."""
    if assume_yes:
        return True
    if not sys.stdin or not sys.stdin.isatty():
        print(
            "[!] Refusing to upload without an interactive confirmation. "
            "Re-run in a terminal, or pass --yes after verifying the action."
        )
        return False
    try:
        return input(f"{prompt} [y/N]: ").strip().lower() in ("y", "yes")
    except EOFError:
        return False


def main():
    parser = argparse.ArgumentParser(
        description="Upload this project's README.md to the user's Weiyun root."
    )
    parser.add_argument(
        "--yes", "-y", action="store_true",
        help="Skip the interactive confirmation prompt (use with care)."
    )
    parser.add_argument(
        "--overwrite", action="store_true",
        help="Allow overwriting an existing /README.md on Weiyun."
    )
    parser.add_argument(
        "--dry-run", action="store_true",
        help="Show what would happen without calling the upload API."
    )
    parser.add_argument(
        "--remote-path", default="/README.md",
        help="Remote target path on Weiyun (default: /README.md)."
    )
    args = parser.parse_args()

    readme_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "README.md")
    cookies_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cookies.json")

    if not os.path.isfile(readme_path):
        print(f"[ERROR] README.md not found at: {readme_path}")
        sys.exit(1)

    print("=" * 60)
    print("  Weiyun Skills - Upload README.md")
    print("=" * 60)
    print(f"  Local file:  {readme_path}")
    print(f"  Size:        {os.path.getsize(readme_path)} bytes")
    print(f"  Remote path: {args.remote_path}")
    print(f"  Overwrite:   {args.overwrite}")
    print(f"  Dry-run:     {args.dry_run}")
    print()

    if args.overwrite:
        print(
            "[!] --overwrite is enabled. If a file already exists at "
            f"{args.remote_path}, its contents will be REPLACED."
        )

    if not _confirm(
        f"Upload '{readme_path}' to Weiyun at '{args.remote_path}'"
        f"{' (overwrite allowed)' if args.overwrite else ''}?",
        assume_yes=args.yes,
    ):
        print("[-] Upload cancelled.")
        return

    if args.dry_run:
        print("[dry-run] No network call made. Exiting.")
        return

    # Step 1: Check if already logged in
    saved = load_cookies(cookies_path)
    if saved and saved.get("cookies_str"):
        print("[*] Found saved cookies, verifying...")
        client = WeiyunClient(cookies_path=cookies_path)
        space = client.get_space_info()
        if space["success"]:
            print(f"[✓] Already logged in as: {saved.get('uin', 'unknown')}")
        else:
            print("[*] Saved cookies expired, need to re-login.")
            saved = {}

    # Step 2: Login if needed
    if not saved or not saved.get("cookies_str"):
        print()
        print("Choose login method:")
        print("  1) QR code scan (recommended)")
        print("  2) Paste cookies from browser")
        print()

        choice = input("Enter choice [1/2]: ").strip()

        if choice == "2":
            print()
            print("Steps to get cookies:")
            print("  1. Open https://www.weiyun.com in browser and login")
            print("  2. Press F12 -> Network tab -> click any request")
            print("  3. Copy the 'Cookie:' header value")
            print()
            cookies_str = input("Paste cookies here: ").strip()
            result = cookies_login(cookies_str, save_path=cookies_path)
        else:
            result = qrcode_login(save_path=cookies_path)

        if not result["success"]:
            print(f"\n[ERROR] Login failed: {result['message']}")
            sys.exit(1)

        print(f"\n[✓] Login successful! Cookies saved to: {cookies_path}")

    # Step 3: Upload README.md
    print()
    print("-" * 60)
    print(f"[*] Uploading README.md to Weiyun at {args.remote_path}...")
    print("-" * 60)

    client = WeiyunClient(cookies_path=cookies_path)
    result = client.upload_file(readme_path, args.remote_path, overwrite=args.overwrite)

    if result["success"]:
        data = result["data"]
        print()
        print("=" * 60)
        print("  ✅ Upload successful!")
        print(f"  📄 File: {data.get('name', 'README.md')}")
        print(f"  📦 Size: {data.get('size', 0)} bytes")
        print(f"  📂 Path: {data.get('remote_path', args.remote_path)}")
        print(f"  🔑 MD5:  {data.get('md5', 'N/A')}")
        print(f"  🕐 Time: {data.get('uploaded_at', 'N/A')}")
        print("=" * 60)
    else:
        print(f"\n[ERROR] Upload failed: {result['message']}")
        print(f"  Error code: {result.get('error_code', 'UNKNOWN')}")
        sys.exit(1)


if __name__ == "__main__":
    main()


path: weiyun_skills/__init__.py
content: """Weiyun Skills - Tencent Weiyun cloud storage management toolkit."""

from weiyun_skills.client import WeiyunClient

__version__ = "1.0.7"
__all__ = ["WeiyunClient"]


path: weiyun_skills/login.py
content: """Weiyun login module - supports QR code login and cookies login."""

import io
import os
import stat
import json
import time
import argparse
import requests

try:
    import qrcode
except ImportError:
    qrcode = None

from weiyun_skills.utils import (
    parse_cookies_str,
    cookies_dict_to_str,
    get_timestamp,
    build_response,
)

# Weiyun QR code login endpoints
XLOGIN_URL = "https://xui.ptlogin2.qq.com/cgi-bin/xlogin"
PTQRSHOW_URL = "https://ssl.ptlogin2.qq.com/ptqrshow"
PTQRLOGIN_URL = "https://ssl.ptlogin2.qq.com/ptqrlogin"
CHECK_URL = "https://www.weiyun.com/disk"

DEFAULT_COOKIES_PATH = "cookies.json"


def _save_cookies(cookies_data: dict, save_path: str) -> None:
    """Save cookies data to a JSON file with strict permissions.

    The cookies file grants broad access to the user's Weiyun account,
    so we write it with mode 0600 (owner read/write only) to prevent
    other local users from reading the credentials.

    Args:
        cookies_data: Cookies data to save.
        save_path: File path to save cookies.
    """
    cookies_data["update_time"] = get_timestamp()

    # Create/truncate the file with 0600 permissions atomically where possible.
    # os.open lets us pass mode bits that honor the umask-stripped 0600 value.
    flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
    try:
        fd = os.open(save_path, flags, 0o600)
    except OSError:
        # Fallback: use regular open, then try to chmod afterwards.
        with open(save_path, "w", encoding="utf-8") as f:
            json.dump(cookies_data, f, ensure_ascii=False, indent=2)
        try:
            os.chmod(save_path, stat.S_IRUSR | stat.S_IWUSR)
        except OSError:
            pass
        return

    with os.fdopen(fd, "w", encoding="utf-8") as f:
        json.dump(cookies_data, f, ensure_ascii=False, indent=2)

    # Ensure permissions are tight even if the file already existed.
    try:
        os.chmod(save_path, stat.S_IRUSR | stat.S_IWUSR)
    except OSError:
        pass

    print(
        f"[!] Cookies saved to {save_path} (mode 0600). This file grants "
        f"full access to the Weiyun account — keep it private and delete it "
        f"when no longer needed."
    )


def load_cookies(save_path: str = DEFAULT_COOKIES_PATH) -> dict:
    """Load cookies from a JSON file.

    Args:
        save_path: Path to cookies file.

    Returns:
        Cookies data dict, or empty dict if file not found.
    """
    try:
        with open(save_path, "r", encoding="utf-8") as f:
            return json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        return {}


def _display_qr_terminal(img_bytes: bytes) -> None:
    """Display QR code image in terminal using half-block characters.

    Also saves the QR code as PNG files (original + enlarged) for easy scanning.

    Args:
        img_bytes: QR code image bytes.
    """
    import os

    # Save QR code as image file for easy access
    qr_path = os.path.join(os.getcwd(), "weiyun_qrcode.png")
    try:
        with open(qr_path, "wb") as f:
            f.write(img_bytes)
        print(f"[*] QR code saved to: {qr_path}")
    except Exception as e:
        print(f"[WARN] Failed to save QR code image: {e}")

    # Try to display in terminal using pixel-accurate half-block rendering
    try:
        from PIL import Image

        img = Image.open(io.BytesIO(img_bytes)).convert("L")
        w, h = img.size

        # Save an enlarged version for easier phone scanning
        if w < 300:
            scale_factor = 300 // w + 1
            enlarged = img.resize(
                (w * scale_factor, h * scale_factor), Image.NEAREST
            )
            enlarged_path = qr_path.replace(".png", "_large.png")
            enlarged.save(enlarged_path)
            print(f"[*] Enlarged QR saved to: {enlarged_path}")

        # Render using half-block characters (▀ ▄ █ and space)
        # Each character represents 2 vertical pixels for accurate display
        print()
        for y in range(0, h - 1, 2):
            line = "  "  # indent
            for x in range(w):
                top = img.getpixel((x, y)) < 128
                bottom = img.getpixel((x, y + 1)) < 128 if y + 1 < h else False
                if top and bottom:
                    line += "█"
                elif top and not bottom:
                    line += "▀"
                elif not top and bottom:
                    line += "▄"
                else:
                    line += " "
            print(line)
        if h % 2 == 1:
            line = "  "
            for x in range(w):
                line += "▀" if img.getpixel((x, h - 1)) < 128 else " "
            print(line)
        print()
    except ImportError:
        print("[WARN] Pillow not installed. Please open the PNG file to scan.")
        print("  Install: pip install Pillow")


def qrcode_login(save_path: str = DEFAULT_COOKIES_PATH) -> dict:
    """Login to Weiyun via QR code scanning.

    Displays a QR code in terminal for the user to scan with WeChat/QQ.
    After successful login, cookies are saved to the specified file.

    Args:
        save_path: Path to save cookies file.

    Returns:
        Response dict with login result.
    """
    session = requests.Session()
    session.headers.update({
        "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"
        ),
        "Referer": "https://www.weiyun.com/",
    })

    # Step 1: Visit xlogin page to get initial cookies
    print("[*] Initializing QR code login...")
    try:
        xlogin_params = {
            "appid": "527020901",
            "daid": "372",
            "style": "33",
            "login_text": "登录",
            "hide_title_bar": "1",
            "hide_border": "1",
            "target": "self",
            "s_url": "https://www.weiyun.com/web/callback/common_qq_login_ok.html",
            "pt_3rd_aid": "0",
            "pt_feedback_link": "https://support.qq.com/",
        }
        session.get(XLOGIN_URL, params=xlogin_params, timeout=10)
    except requests.RequestException as e:
        return build_response(False, message=f"Failed to init login: {e}",
                              error_code="NETWORK_ERROR")

    # Step 2: Get QR code image
    print("[*] Fetching QR code...")
    try:
        qr_params = {
            "appid": "527020901",
            "e": "2",
            "l": "M",
            "s": "3",
            "d": "72",
            "v": "4",
            "t": str(time.time()),
            "daid": "372",
            "pt_3rd_aid": "0",
        }
        qr_resp = session.get(PTQRSHOW_URL, params=qr_params, timeout=10)
        if qr_resp.status_code != 200:
            return build_response(False, message="Failed to get QR code",
                                  error_code="NETWORK_ERROR")

        # Get qrsig cookie for polling
        qrsig = session.cookies.get("qrsig", "")
        if not qrsig:
            return build_response(False, message="Failed to get qrsig cookie",
                                  error_code="AUTH_FAILED")

    except requests.RequestException as e:
        return build_response(False, message=f"Failed to fetch QR code: {e}",
                              error_code="NETWORK_ERROR")

    # Step 3: Display QR code in terminal
    print("\n" + "=" * 50)
    print("  Scan the QR code below with WeChat/QQ")
    print("=" * 50 + "\n")
    _display_qr_terminal(qr_resp.content)
    print("\n" + "=" * 50)
    print("  Waiting for scan...")
    print("=" * 50 + "\n")

    # Step 4: Poll for login result
    def _hash33(t: str) -> int:
        """Hash function for ptqrtoken."""
        e = 0
        for c in t:
            e += (e << 5) + ord(c)
        return e & 0x7FFFFFFF

    ptqrtoken = _hash33(qrsig)
    max_attempts = 60  # Wait up to ~120 seconds
    for attempt in range(max_attempts):
        try:
            poll_params = {
                "u1": "https://www.weiyun.com/web/callback/common_qq_login_ok.html",
                "ptqrtoken": str(ptqrtoken),
                "ptredirect": "0",
                "h": "1",
                "t": "1",
                "g": "1",
                "from_ui": "1",
                "ptlang": "2052",
                "action": f"0-0-{int(time.time() * 1000)}",
                "js_ver": "24012815",
                "js_type": "1",
                "login_sig": session.cookies.get("pt_login_sig", ""),
                "pt_uistyle": "40",
                "aid": "527020901",
                "daid": "372",
                "pt_3rd_aid": "0",
                "o1vId": "",
            }
            poll_resp = session.get(PTQRLOGIN_URL, params=poll_params, timeout=10)
            resp_text = poll_resp.text

            if "登录成功" in resp_text or "登陆成功" in resp_text:
                print("[✓] Login successful!")

                # Extract redirect URL and follow it to get final cookies
                import re
                url_match = re.search(r"'(https?://[^']+)'", resp_text)
                if url_match:
                    redirect_url = url_match.group(1)
                    session.get(redirect_url, timeout=10, allow_redirects=True)

                # Collect all cookies
                all_cookies = {}
                for cookie in session.cookies:
                    all_cookies[cookie.name] = cookie.value

                cookies_str = cookies_dict_to_str(all_cookies)
                cookies_data = {
                    "cookies_str": cookies_str,
                    "cookies_dict": all_cookies,
                    "uin": all_cookies.get("uin", ""),
                }

                _save_cookies(cookies_data, save_path)

                return build_response(True, data={
                    "uin": all_cookies.get("uin", ""),
                    "nickname": "",
                    "save_path": save_path,
                })

            elif "二维码未失效" in resp_text:
                pass  # QR code still valid, keep waiting
            elif "二维码认证中" in resp_text or "已扫描" in resp_text:
                print("[*] QR code scanned, waiting for confirmation...")
            elif "二维码已失效" in resp_text:
                print("[✗] QR code expired!")
                return build_response(False, message="QR code expired",
                                      error_code="QR_EXPIRED")
            elif "本次登录已被拒绝" in resp_text:
                print("[✗] Login cancelled by user!")
                return build_response(False, message="Login cancelled",
                                      error_code="QR_CANCELLED")

        except requests.RequestException:
            pass

        time.sleep(2)

    return build_response(False, message="Login timeout", error_code="QR_EXPIRED")


def cookies_login(cookies_str: str,
                  save_path: str = DEFAULT_COOKIES_PATH) -> dict:
    """Login to Weiyun using cookies copied from browser.

    Args:
        cookies_str: Cookie string from browser DevTools.
        save_path: Path to save cookies file.

    Returns:
        Response dict with login result.
    """
    if not cookies_str or not cookies_str.strip():
        return build_response(False, message="Cookies string is empty",
                              error_code="INVALID_PARAM")

    cookies_dict = parse_cookies_str(cookies_str)

    # Verify cookies by making a test request
    print("[*] Verifying cookies...")
    session = requests.Session()
    session.headers.update({
        "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"
        ),
        "Referer": "https://www.weiyun.com/",
    })
    session.cookies.update(cookies_dict)

    try:
        resp = session.get("https://www.weiyun.com/disk", timeout=10,
                           allow_redirects=False)
        # If we get redirected to login page, cookies are invalid
        if resp.status_code in (301, 302):
            location = resp.headers.get("Location", "")
            if "login" in location.lower() or "xui.ptlogin2" in location:
                return build_response(False,
                                      message="Cookies are invalid or expired",
                                      error_code="AUTH_EXPIRED")
    except requests.RequestException as e:
        return build_response(False, message=f"Verification failed: {e}",
                              error_code="NETWORK_ERROR")

    print("[✓] Cookies verified successfully!")

    # Save cookies
    cookies_data = {
        "cookies_str": cookies_str.strip(),
        "cookies_dict": cookies_dict,
        "uin": cookies_dict.get("uin", ""),
    }
    _save_cookies(cookies_data, save_path)

    return build_response(True, data={
        "uin": cookies_dict.get("uin", ""),
        "nickname": "",
        "save_path": save_path,
    })


def main():
    """CLI entry point for login."""
    parser = argparse.ArgumentParser(description="Weiyun Login Tool")
    parser.add_argument(
        "--method", choices=["qrcode", "cookies"], default="qrcode",
        help="Login method: qrcode (scan QR code) or cookies (paste cookies)"
    )
    parser.add_argument(
        "--cookies", type=str, default="",
        help="Cookie string from browser (required for cookies method)"
    )
    parser.add_argument(
        "--save-path", type=str, default=DEFAULT_COOKIES_PATH,
        help=f"Path to save cookies file (default: {DEFAULT_COOKIES_PATH})"
    )
    args = parser.parse_args()

    # Security notice: make it explicit that this tool persists a
    # high-privilege credential to disk.
    print(
        "[SECURITY] Weiyun login cookies authorize broad access to your "
        "cloud storage. Only proceed if you trust this environment, "
        "prefer a non-primary account, and delete the saved cookies when "
        "you are done."
    )

    if args.method == "qrcode":
        result = qrcode_login(save_path=args.save_path)
    elif args.method == "cookies":
        if not args.cookies:
            print("[ERROR] --cookies argument is required for cookies method")
            print("Usage: python login.py --method cookies --cookies \"your_cookies\"")
            return
        result = cookies_login(args.cookies, save_path=args.save_path)
    else:
        print(f"[ERROR] Unknown method: {args.method}")
        return

    if result["success"]:
        print(f"\n[✓] Login successful! Cookies saved to: {result['data']['save_path']}")
    else:
        print(f"\n[✗] Login failed: {result['message']}")


if __name__ == "__main__":
    main()


path: weiyun_skills/main.py
content: """Weiyun Skills CLI - command line interface for Weiyun management."""

import sys
import json
import argparse

from weiyun_skills.client import WeiyunClient
from weiyun_skills.utils import format_size

try:
    from tabulate import tabulate
except ImportError:
    tabulate = None


def _confirm(prompt: str, assume_yes: bool = False) -> bool:
    """Ask the user to confirm a destructive/irreversible action.

    Returns True only when the user explicitly types 'y' / 'yes' (case
    insensitive). If stdin is not a TTY (e.g. running inside an automated
    agent pipeline) the prompt is refused unless ``assume_yes`` is set via
    an explicit ``--yes`` flag, so an agent cannot silently approve
    destructive operations on the user's behalf.
    """
    if assume_yes:
        return True
    # If we cannot interactively prompt the user, fail closed.
    if not sys.stdin or not sys.stdin.isatty():
        print(
            "[!] Refusing to run a destructive action without an interactive "
            "confirmation. Re-run in a terminal, or pass --yes if you have "
            "manually verified the operation."
        )
        return False
    try:
        answer = input(f"{prompt} [y/N]: ").strip().lower()
    except EOFError:
        return False
    return answer in ("y", "yes")


def _print_json(data: dict) -> None:
    """Pretty print JSON data."""
    print(json.dumps(data, ensure_ascii=False, indent=2))


def _print_table(headers: list, rows: list) -> None:
    """Print data as a formatted table."""
    if tabulate:
        print(tabulate(rows, headers=headers, tablefmt="grid"))
    else:
        # Fallback: simple table output
        print("\t".join(headers))
        print("-" * (len(headers) * 20))
        for row in rows:
            print("\t".join(str(c) for c in row))


def cmd_list(client: WeiyunClient, args) -> None:
    """Handle 'list' command."""
    result = client.list_files(
        remote_path=args.path,
        sort_by=getattr(args, "sort", "name"),
        sort_order=getattr(args, "order", "asc"),
    )
    if not result["success"]:
        print(f"[ERROR] {result['message']}")
        return

    files = result["data"]["files"]
    if not files:
        print(f"(empty directory: {args.path})")
        return

    headers = ["Type", "Name", "Size", "Updated"]
    rows = []
    for f in files:
        icon = "📁" if f["type"] == "folder" else "📄"
        rows.append([icon, f["name"], f["size_str"], f["updated_at"]])
    _print_table(headers, rows)
    print(f"\nTotal: {result['data']['total']} items")


def cmd_upload(client: WeiyunClient, args) -> None:
    """Handle 'upload' command."""
    overwrite = getattr(args, "overwrite", False)
    assume_yes = getattr(args, "yes", False)

    # Uploading mutates the user's Weiyun account. When --overwrite is on,
    # an existing remote file will be REPLACED, which is effectively a
    # silent destructive action, so we surface that explicitly before
    # asking for confirmation.
    if overwrite:
        print(
            f"[!] --overwrite is enabled. If a file already exists at "
            f"{args.remote}, its contents will be REPLACED."
        )
    if not _confirm(
        f"Upload '{args.local}' to Weiyun at '{args.remote}'"
        f"{' (overwrite allowed)' if overwrite else ''}?",
        assume_yes=assume_yes,
    ):
        print("[-] Upload cancelled.")
        return

    print(f"[*] Uploading {args.local} -> {args.remote}")
    result = client.upload_file(
        args.local, args.remote,
        overwrite=overwrite,
    )
    if result["success"]:
        d = result["data"]
        print(f"[✓] Uploaded: {d['name']} ({format_size(d['size'])})")
        print(f"    Path: {d['remote_path']}")
        print(f"    MD5:  {d['md5']}")
    else:
        print(f"[✗] Upload failed: {result['message']}")


def cmd_upload_folder(client: WeiyunClient, args) -> None:
    """Handle 'upload-folder' command."""
    remote = getattr(args, "remote", "/")
    overwrite = getattr(args, "overwrite", False)
    assume_yes = getattr(args, "yes", False)

    # Bulk folder uploads can create and/or overwrite many files at once,
    # so we gate the whole operation behind the same confirmation prompt
    # the rest of the mutating commands use.
    if overwrite:
        print(
            f"[!] --overwrite is enabled. Existing files under "
            f"'{remote}' may be REPLACED in bulk."
        )
    if not _confirm(
        f"Upload folder '{args.local}' to Weiyun at '{remote}'"
        f"{' (overwrite allowed)' if overwrite else ''}?",
        assume_yes=assume_yes,
    ):
        print("[-] Upload cancelled.")
        return

    print(f"[*] Uploading folder '{args.local}' -> Weiyun:{remote}")
    result = client.upload_folder(
        args.local, remote,
        overwrite=overwrite,
    )
    if result["success"]:
        d = result["data"]
        print(f"[✓] Uploaded folder: {d['folder_name']}")
        print(f"    Files:  {d['uploaded_count']} uploaded, "
              f"{d['failed_count']} failed")
        print(f"    Size:   {d['total_size_str']}")
        print(f"    Time:   {d['elapsed']}s")
        if d["uploaded_files"]:
            print("\n    Uploaded files:")
            for f in d["uploaded_files"]:
                instant = " ⚡" if f.get("instant_upload") else ""
                print(f"      📄 {f['name']} ({f['size_str']}){instant}")
        if d["failed_files"]:
            print("\n    Failed files:")
            for f in d["failed_files"]:
                print(f"      ❌ {f['name']}: {f['error']}")
    else:
        print(f"[✗] Upload failed: {result['message']}")


def cmd_download(client: WeiyunClient, args) -> None:
    """Handle 'download' command."""
    print(f"[*] Downloading {args.remote} -> {args.local}")
    result = client.download_file(
        args.remote, args.local,
        overwrite=getattr(args, "overwrite", False)
    )
    if result["success"]:
        d = result["data"]
        print(f"[✓] Downloaded: {d['local_path']} ({format_size(d['size'])})")
        print(f"    MD5:  {d['md5']}")
        print(f"    Time: {d['elapsed']}s")
    else:
        print(f"[✗] Download failed: {result['message']}")


def cmd_download_folder(client: WeiyunClient, args) -> None:
    """Handle 'download-folder' command."""
    as_zip = getattr(args, "zip", False)
    mode = "zip" if as_zip else "recursive"
    print(f"[*] Downloading folder '{args.folder}' -> {args.local} (mode: {mode})")
    result = client.download_folder(
        args.folder, args.local,
        overwrite=getattr(args, "overwrite", False),
        as_zip=as_zip,
    )
    if result["success"]:
        d = result["data"]
        if as_zip:
            print(f"[✓] Downloaded zip: {d['local_path']}")
            print(f"    Size: {d.get('size_str', format_size(d.get('size', 0)))}")
            print(f"    MD5:  {d['md5']}")
            print(f"    Time: {d['elapsed']}s")
        else:
            print(f"[✓] Downloaded folder: {d['local_path']}")
            print(f"    Files:  {d['downloaded_count']} downloaded, "
                  f"{d['failed_count']} failed")
            print(f"    Size:   {d['total_size_str']}")
            print(f"    Time:   {d['elapsed']}s")
            if d["downloaded_files"]:
                print("\n    Downloaded files:")
                for f in d["downloaded_files"]:
                    print(f"      📄 {f['name']} ({f['size_str']})")
            if d["failed_files"]:
                print("\n    Failed files:")
                for f in d["failed_files"]:
                    print(f"      ❌ {f['name']}: {f['error']}")
    else:
        print(f"[✗] Download failed: {result['message']}")


def cmd_delete(client: WeiyunClient, args) -> None:
    """Handle 'delete' command."""
    permanent = getattr(args, "permanent", False)
    assume_yes = getattr(args, "yes", False)
    action = "Permanently deleting" if permanent else "Deleting"

    # Require explicit user confirmation before any remote deletion.
    # Permanent delete skips the recycle bin and is effectively irreversible,
    # so it uses a stricter prompt that spells out the consequences.
    if permanent:
        print(
            f"[!] PERMANENT DELETE requested for: {args.path}\n"
            f"    This will bypass the recycle bin and CANNOT be undone."
        )
        if not _confirm(
            f"Permanently delete '{args.path}'? This cannot be undone.",
            assume_yes=assume_yes,
        ):
            print("[-] Delete cancelled.")
            return
    else:
        if not _confirm(
            f"Move '{args.path}' to the Weiyun recycle bin?",
            assume_yes=assume_yes,
        ):
            print("[-] Delete cancelled.")
            return

    print(f"[*] {action} {args.path}")
    result = client.delete_file(args.path, permanent=permanent)
    if result["success"]:
        d = result["data"]
        msg = "permanently deleted" if d["is_permanent"] else "moved to recycle bin"
        print(f"[✓] {d['deleted_path']} {msg}")
    else:
        print(f"[✗] Delete failed: {result['message']}")


def cmd_move(client: WeiyunClient, args) -> None:
    """Handle 'move' command."""
    if not _confirm(
        f"Move '{args.source}' to '{args.target}' on Weiyun?",
        assume_yes=getattr(args, "yes", False),
    ):
        print("[-] Move cancelled.")
        return
    print(f"[*] Moving {args.source} -> {args.target}")
    result = client.move_file(args.source, args.target)
    if result["success"]:
        d = result["data"]
        print(f"[✓] Moved to: {d['target_path']}")
    else:
        print(f"[✗] Move failed: {result['message']}")


def cmd_copy(client: WeiyunClient, args) -> None:
    """Handle 'copy' command."""
    if not _confirm(
        f"Copy '{args.source}' to '{args.target}' on Weiyun?",
        assume_yes=getattr(args, "yes", False),
    ):
        print("[-] Copy cancelled.")
        return
    print(f"[*] Copying {args.source} -> {args.target}")
    result = client.copy_file(args.source, args.target)
    if result["success"]:
        d = result["data"]
        print(f"[✓] Copied to: {d['target_path']}")
    else:
        print(f"[✗] Copy failed: {result['message']}")


def cmd_rename(client: WeiyunClient, args) -> None:
    """Handle 'rename' command."""
    if not _confirm(
        f"Rename '{args.path}' to '{args.name}' on Weiyun?",
        assume_yes=getattr(args, "yes", False),
    ):
        print("[-] Rename cancelled.")
        return
    print(f"[*] Renaming {args.path} -> {args.name}")
    result = client.rename_file(args.path, args.name)
    if result["success"]:
        d = result["data"]
        print(f"[✓] Renamed: {d['old_path']} -> {d['new_path']}")
    else:
        print(f"[✗] Rename failed: {result['message']}")


def cmd_mkdir(client: WeiyunClient, args) -> None:
    """Handle 'mkdir' command."""
    # mkdir is a mutating operation (it changes the remote directory tree),
    # so it goes through the same confirmation gate as upload/delete/etc.
    if not _confirm(
        f"Create folder '{args.path}' on Weiyun?",
        assume_yes=getattr(args, "yes", False),
    ):
        print("[-] Create cancelled.")
        return
    print(f"[*] Creating folder: {args.path}")
    result = client.create_folder(args.path)
    if result["success"]:
        print(f"[✓] Created: {result['data']['path']}")
    else:
        print(f"[✗] Create failed: {result['message']}")


def cmd_search(client: WeiyunClient, args) -> None:
    """Handle 'search' command."""
    result = client.search_files(
        keyword=args.keyword,
        file_type=getattr(args, "type", "all"),
    )
    if not result["success"]:
        print(f"[ERROR] {result['message']}")
        return

    results = result["data"]["results"]
    if not results:
        print(f"No files found matching '{args.keyword}'")
        return

    headers = ["Name", "Size", "Path"]
    rows = [[r["name"], r["size_str"], r["path"]] for r in results]
    _print_table(headers, rows)
    print(f"\nFound: {result['data']['total']} items")


def cmd_share(client: WeiyunClient, args) -> None:
    """Handle 'share' command."""
    expire = getattr(args, "expire", 0)
    [REDACTED_SECRET], "password", None)
    assume_yes = getattr(args, "yes", False)

    # Creating a share link can expose private files to anyone on the
    # internet, so always surface the full parameters and require an
    # explicit confirmation before calling the Weiyun API.
    expire_desc = f"{expire} day(s)" if expire else "NEVER (permanent link)"
    pw_desc = "set" if password else "NONE (link is the only secret)"
    print("[!] About to create a public share link with these settings:")
    print(f"      File:       {args.path}")
    print(f"      Expires in: {expire_desc}")
    print(f"      [REDACTED_SECRET]")
    if not password or not expire:
        print(
            "      WARNING: Sharing without both a password and an "
            "expiration makes the file reachable indefinitely by anyone "
            "holding the URL."
        )
    if not _confirm(
        f"Create share link for '{args.path}'?",
        assume_yes=assume_yes,
    ):
        print("[-] Share cancelled.")
        return

    print(f"[*] Creating share for: {args.path}")
    result = client.create_share(
        args.path,
        expire_days=expire,
        [REDACTED_SECRET],
    )
    if result["success"]:
        d = result["data"]
        print(f"[✓] Share created!")
        print(f"    URL:      {d['share_url']}")
        if d.get("password"):
            print(f"    Password: {d['password']}")
        if d.get("expire_at"):
            print(f"    Expires:  {d['expire_at']}")
    else:
        print(f"[✗] Share failed: {result['message']}")


def cmd_unshare(client: WeiyunClient, args) -> None:
    """Handle 'unshare' command."""
    if not _confirm(
        f"Cancel share '{args.share_id}'?",
        assume_yes=getattr(args, "yes", False),
    ):
        print("[-] Unshare cancelled.")
        return
    result = client.cancel_share(args.share_id)
    if result["success"]:
        print(f"[✓] Share {args.share_id} cancelled")
    else:
        print(f"[✗] Cancel failed: {result['message']}")


def cmd_shares(client: WeiyunClient, args) -> None:
    """Handle 'shares' command."""
    result = client.list_shares(
        status=getattr(args, "status", "all")
    )
    if not result["success"]:
        print(f"[ERROR] {result['message']}")
        return

    shares = result["data"]["shares"]
    if not shares:
        print("No shares found")
        return

    headers = ["ID", "File", "URL", "Status", "Views", "Downloads", "Expires"]
    rows = []
    for s in shares:
        rows.append([
            s["share_id"], s["file_name"], s["share_url"],
            s["status"], s["view_count"], s["download_count"],
            s["expire_at"],
        ])
    _print_table(headers, rows)
    print(f"\nTotal: {result['data']['total']} shares")


def cmd_space(client: WeiyunClient, args) -> None:
    """Handle 'space' command."""
    result = client.get_space_info()
    if not result["success"]:
        print(f"[ERROR] {result['message']}")
        return

    d = result["data"]
    print("=" * 40)
    print("  Weiyun Space Usage")
    print("=" * 40)
    print(f"  Total:   {d['total_space_str']}")
    print(f"  Used:    {d['used_space_str']} ({d['usage_percent']}%)")
    print(f"  Free:    {d['free_space_str']}")
    print(f"  Files:   {d['file_count']}")
    print(f"  Folders: {d['folder_count']}")
    print("=" * 40)

    # Simple progress bar
    bar_width = 30
    filled = int(bar_width * d["usage_percent"] / 100)
    bar = "█" * filled + "░" * (bar_width - filled)
    print(f"  [{bar}] {d['usage_percent']}%")


def cmd_recycle(client: WeiyunClient, args) -> None:
    """Handle 'recycle' command."""
    result = client.get_recycle_bin()
    if not result["success"]:
        print(f"[ERROR] {result['message']}")
        return

    files = result["data"]["files"]
    if not files:
        print("Recycle bin is empty")
        return

    headers = ["ID", "Name", "Size", "Original Path", "Deleted At"]
    rows = []
    for f in files:
        rows.append([
            f["file_id"], f["name"], f["size_str"],
            f["original_path"], f["deleted_at"],
        ])
    _print_table(headers, rows)
    print(f"\nTotal: {result['data']['total']} items "
          f"({result['data']['total_size_str']})")


def cmd_restore(client: WeiyunClient, args) -> None:
    """Handle 'restore' command."""
    result = client.restore_file(args.file_id)
    if result["success"]:
        d = result["data"]
        print(f"[✓] Restored to: {d['restored_path']}")
    else:
        print(f"[✗] Restore failed: {result['message']}")


def cmd_clear_recycle(client: WeiyunClient, args) -> None:
    """Handle 'clear-recycle' command."""
    assume_yes = getattr(args, "yes", False)
    if not getattr(args, "confirm", False) and not assume_yes:
        print("[!] This will permanently delete all files in recycle bin!")
        print("    Add --confirm flag to proceed.")
        return

    # Even with --confirm, prompt once more interactively (unless --yes).
    if not _confirm(
        "Permanently empty the Weiyun recycle bin? This cannot be undone.",
        assume_yes=assume_yes,
    ):
        print("[-] Clear cancelled.")
        return

    result = client.clear_recycle_bin(confirm=True)
    if result["success"]:
        d = result["data"]
        print(f"[✓] Recycle bin cleared!")
        print(f"    Deleted: {d['deleted_count']} files")
        print(f"    Freed:   {d['freed_space_str']}")
    else:
        print(f"[✗] Clear failed: {result['message']}")


def main():
    """Main CLI entry point."""
    parser = argparse.ArgumentParser(
        description="Weiyun Skills - Tencent Weiyun Management CLI",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python -m weiyun_skills.main list /
  python -m weiyun_skills.main upload ./file.pdf /docs/
  python -m weiyun_skills.main download /docs/file.pdf ./
  python -m weiyun_skills.main share /docs/file.pdf --expire 7
  python -m weiyun_skills.main space
        """
    )

    parser.add_argument(
        "--cookies", type=str, default=None,
        help="Cookies string (overrides cookies.json)"
    )
    parser.add_argument(
        "-y", "--yes", action="store_true",
        help=(
            "Skip interactive confirmation prompts for destructive or "
            "exposure-sensitive actions (delete, permanent-delete, move, "
            "copy, rename, share, unshare, clear-recycle). Use with care."
        )
    )

    subparsers = parser.add_subparsers(dest="command", help="Available commands")

    # list
    p_list = subparsers.add_parser("list", help="List files in a directory")
    p_list.add_argument("path", nargs="?", default="/", help="Directory path")
    p_list.add_argument("--sort", choices=["name", "size", "time"], default="name")
    p_list.add_argument("--order", choices=["asc", "desc"], default="asc")

    # upload
    p_upload = subparsers.add_parser("upload", help="Upload a file")
    p_upload.add_argument("local", help="Local file path")
    p_upload.add_argument("remote", help="Remote target path")
    p_upload.add_argument("--overwrite", action="store_true")

    # upload-folder
    p_ul_folder = subparsers.add_parser("upload-folder",
                                         help="Upload a folder")
    p_ul_folder.add_argument("local", help="Local folder path")
    p_ul_folder.add_argument("remote", nargs="?", default="/",
                              help="Remote target path (default: root)")
    p_ul_folder.add_argument("--overwrite", action="store_true",
                              help="Overwrite existing files on Weiyun")

    # download
    p_download = subparsers.add_parser("download", help="Download a file")
    p_download.add_argument("remote", help="Remote file path")
    p_download.add_argument("local", help="Local save path")
    p_download.add_argument("--overwrite", action="store_true")

    # download-folder
    p_dl_folder = subparsers.add_parser("download-folder",
                                         help="Download a folder")
    p_dl_folder.add_argument("folder", help="Folder name on Weiyun")
    p_dl_folder.add_argument("local", help="Local save directory")
    p_dl_folder.add_argument("--overwrite", action="store_true",
                              help="Overwrite existing local files")
    p_dl_folder.add_argument("--zip", action="store_true",
                              help="Download as zip file instead of "
                                   "individual files")

    # delete
    p_delete = subparsers.add_parser("delete", help="Delete a file")
    p_delete.add_argument("path", help="File path to delete")
    p_delete.add_argument("--permanent", action="store_true",
                          help="Permanently delete (skip recycle bin)")

    # move
    p_move = subparsers.add_parser("move", help="Move a file")
    p_move.add_argument("source", help="Source path")
    p_move.add_argument("target", help="Target directory path")

    # copy
    p_copy = subparsers.add_parser("copy", help="Copy a file")
    p_copy.add_argument("source", help="Source path")
    p_copy.add_argument("target", help="Target directory path")

    # rename
    p_rename = subparsers.add_parser("rename", help="Rename a file")
    p_rename.add_argument("path", help="File path")
    p_rename.add_argument("name", help="New name")

    # mkdir
    p_mkdir = subparsers.add_parser("mkdir", help="Create a folder")
    p_mkdir.add_argument("path", help="Folder path")

    # search
    p_search = subparsers.add_parser("search", help="Search files")
    p_search.add_argument("keyword", help="Search keyword")
    p_search.add_argument("--type",
                          choices=["all", "document", "image", "video", "audio"],
                          default="all")

    # share
    p_share = subparsers.add_parser("share", help="Create a share link")
    p_share.add_argument("path", help="File path to share")
    p_share.add_argument("--expire", type=int, default=0,
                         help="Expire in days (0=permanent)")
    p_share.add_argument("--password", type=str, default=None,
                         help="Share password (4 chars)")

    # unshare
    p_unshare = subparsers.add_parser("unshare", help="Cancel a share")
    p_unshare.add_argument("share_id", help="Share ID to cancel")

    # shares
    p_shares = subparsers.add_parser("shares", help="List all shares")
    p_shares.add_argument("--status", choices=["all", "active", "expired"],
                          default="all")

    # space
    subparsers.add_parser("space", help="Show space usage")

    # recycle
    subparsers.add_parser("recycle", help="Show recycle bin")

    # restore
    p_restore = subparsers.add_parser("restore", help="Restore from recycle bin")
    p_restore.add_argument("file_id", help="File ID to restore")

    # clear-recycle
    p_clear = subparsers.add_parser("clear-recycle",
                                     help="Clear recycle bin")
    p_clear.add_argument("--confirm", action="store_true",
                         help="Confirm clear operation")

    args = parser.parse_args()

    if not args.command:
        parser.print_help()
        sys.exit(1)

    # Initialize client
    client = WeiyunClient(cookies_str=args.cookies)

    # Dispatch command
    commands = {
        "list": cmd_list,
        "upload": cmd_upload,
        "upload-folder": cmd_upload_folder,
        "download": cmd_download,
        "download-folder": cmd_download_folder,
        "delete": cmd_delete,
        "move": cmd_move,
        "copy": cmd_copy,
        "rename": cmd_rename,
        "mkdir": cmd_mkdir,
        "search": cmd_search,
        "share": cmd_share,
        "unshare": cmd_unshare,
        "shares": cmd_shares,
        "space": cmd_space,
        "recycle": cmd_recycle,
        "restore": cmd_restore,
        "clear-recycle": cmd_clear_recycle,
    }

    handler = commands.get(args.command)
    if handler:
        handler(client, args)
    else:
        parser.print_help()


if __name__ == "__main__":
    main()


path: weiyun_skills/utils.py
content: """Utility functions for Weiyun Skills."""

import os
import hashlib
from datetime import datetime


def format_size(size_bytes: int) -> str:
    """Convert bytes to human-readable size string.

    Args:
        size_bytes: File size in bytes.

    Returns:
        Human-readable size string like '2.50 MB'.
    """
    if size_bytes == 0:
        return "0 B"
    units = ["B", "KB", "MB", "GB", "TB"]
    unit_index = 0
    size = float(size_bytes)
    while size >= 1024 and unit_index < len(units) - 1:
        size /= 1024
        unit_index += 1
    return f"{size:.2f} {units[unit_index]}"


def get_file_md5(file_path: str) -> str:
    """Calculate MD5 hash of a file.

    Args:
        file_path: Path to the file.

    Returns:
        MD5 hex digest string.
    """
    md5 = hashlib.md5()
    with open(file_path, "rb") as f:
        for chunk in iter(lambda: f.read(8192), b""):
            md5.update(chunk)
    return md5.hexdigest()


def parse_cookies_str(cookies_str: str) -> dict:
    """Parse cookies string into a dictionary.

    Args:
        cookies_str: Cookie string from browser, e.g. 'key1=val1; key2=val2'.

    Returns:
        Dictionary of cookie key-value pairs.
    """
    cookies = {}
    if not cookies_str:
        return cookies
    for item in cookies_str.split(";"):
        item = item.strip()
        if "=" in item:
            key, value = item.split("=", 1)
            cookies[key.strip()] = value.strip()
    return cookies


def cookies_dict_to_str(cookies_dict: dict) -> str:
    """Convert cookies dictionary back to string.

    Args:
        cookies_dict: Dictionary of cookies.

    Returns:
        Cookie string like 'key1=val1; key2=val2'.
    """
    return "; ".join(f"{k}={v}" for k, v in cookies_dict.items())


def get_timestamp() -> str:
    """Get current timestamp string.

    Returns:
        Timestamp string in 'YYYY-MM-DD HH:MM:SS' format.
    """
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")


def ensure_dir(path: str) -> None:
    """Ensure the directory for a file path exists.

    Args:
        path: File path whose parent directory should exist.
    """
    dir_path = os.path.dirname(path)
    if dir_path:
        os.makedirs(dir_path, exist_ok=True)


def build_response(success: bool, data=None, message: str = "ok",
                   error_code: str = None) -> dict:
    """Build a standardized response dict.

    Args:
        success: Whether the operation succeeded.
        data: Response data payload.
        message: Response message.
        error_code: Error code string if failed.

    Returns:
        Standardized response dictionary.
    """
    resp = {"success": success, "data": data, "message": message}
    if error_code:
        resp["error_code"] = error_code
    return resp


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