Skill: kevindai/tapd (version 0.1.3)

--- name: tapd description: 当用户需要查询、创建或更新 TAPD 需求、任务、缺陷、评论、工作流、迭代、测试用例、Wiki、工时、发布计划，或发送企业微信通知时使用本 Skill。使用 Python 标准库调用 TAPD 开放 API，不依赖 MCP 或第三方 HTTP 库。 homepage: https://www.tapd.cn metadata: { "openclaw": { "emoji": "📋", "requires": { "bins": [], "env": ["TAPD_ACCESS_TOKEN"] }, "primaryEnv": "TAPD_ACCESS_TOKEN", "install": [], }, } --- # TAPD Skill 通过 TAPD 开放 API 完成需求/任务/缺陷/评论/迭代/用例/Wiki/工时等操作。本 Skill 不依赖 MCP 服务，由 AI 根据以下说明直接构造 HTTP 请求（或使用本 Skill 附带的仅标准库 Python 脚本）。 ## 何时使用 - 获取用户参与的项目列表（未指定 workspace_id 时） - 查询、创建、更新需求（stories）或任务（tasks） - 查询、创建、更新缺陷（bugs） - 获取或添加、更新评论（comments） - 获取自定义字段配置（需求/任务/迭代/测试用例） - 获取需求/任务/缺陷中的图片下载链接或附件信息 - 获取工作流流转、状态映射、结束状态及工作项类型 - 获取需求字段中英文与候选值（get_fields_lable、get_fields_info） - 获取项目信息、迭代列表，创建或更新迭代 - 获取需求关联的缺陷、创建需求与缺陷的关联关系（relations） - 查询、创建、批量创建测试用例（tcases） - 查询、创建、更新 Wiki（tapd_wikis） - 获取用户待办（todo） - 查询、新建、更新工时（timesheets） - 获取发布计划（releases）、获取提交关键字（get_scm_copy_keywords） - 发送企业微信群消息（需配置 BOT_URL） ## 环境与认证 | 变量名 | 必填 | 说明 | |--------|------|------| | TAPD_ACCESS_TOKEN | 二选一 | 个人访问令牌，推荐 | | TAPD_API_USER | 二选一 | API 账号（与 TAPD_API_PASSWORD 搭配） | | TAPD_API_PASSWORD | 二选一 | API 密码 | | TAPD_API_BASE_URL | 可选 | API 根地址，默认 `https://api.tapd.cn` | | TAPD_BASE_URL | 可选 | 前端地址，用于生成需求/任务/缺陷等链接，默认 `https://www.tapd.cn` | | BOT_URL | 可选 | 企业微信机器人 webhook，仅发送群消息时需要 | | CURRENT_USER_NICK | 可选 | 当前用户昵称，未传 nick 时用于参与项目、待办、工时等查询 | ### 请求规范 - **URL**：所有请求在 base 后追加 `?s=mcp`（若 URL 已有 query 则用 `&s=mcp`）。例如：`GET {TAPD_API_BASE_URL}/stories?s=mcp`。 - **Headers**： - 认证二选一：`Authorization: Bearer <TAPD_ACCESS_TOKEN>` 或 `Authorization: Basic <base64(TAPD_API_USER:TAPD_API_PASSWORD)>` - `Content-Type: application/json` - `Via: mcp` - **Body**：POST 请求使用 JSON；GET 参数放在 query string。 ## ID 规则（短号转长号） TAPD 部分接口接受短 ID（≤9 位数字）。调用前需转为长 ID： - 云环境（`TAPD_API_BASE_URL` 包含 `api.tapd.cn`）：前缀 `11`；否则前缀 `10`。 - 格式：`{prefix}{workspace_id}{id.zfill(9)}`。例如 workspace_id=123，短 id=456 → `1012300000456` 或 `1112300000456`。 - 多 ID 逗号分隔时，逐个转换后再用逗号拼接。 涉及 id 转换的常见场景：stories/tasks 的 id、bugs 的 id、comments 的 entry_id、get_scm_copy_keywords 的 object_id 等。 ## 操作清单（API 端点与参数） 以下为语义说明与对应 HTTP 方法、端点及主要参数。详细参数见 [reference/api_reference.md](./reference/api_reference.md)。 | 能力 | 方法 | 端点 | 主要参数/说明 | |------|------|------|----------------| | 获取用户参与项目 | GET | workspaces/user_participant_projects | params: nick。过滤 category=organization。 | | 获取需求或任务 | GET | stories 或 tasks | params: workspace_id, entity_type(stories/tasks), page, limit, id, name, status, fields 等。使用 custom_field_* 前先调自定义字段配置。 | | 获取需求/任务数量 | GET | stories/count 或 tasks/count | params: workspace_id 及与列表相同的筛选条件。 | | 创建/更新需求或任务 | POST | stories 或 tasks | body: workspace_id, name(创建必填), id(更新必填), entity_type, description 等。 | | 获取实体自定义字段配置 | GET | {entity_type}/custom_fields_settings | entity_type: stories / tasks / iterations / tcases。params: workspace_id。 | | 获取图片下载链接 | GET | files/get_image | params: workspace_id, image_path(必填)。 | | 获取附件信息/下载 | GET | attachments；下载 attachments/down | params: workspace_id, entry_id, type(story/bug) 等。 | | 获取缺陷 | GET | bugs | params: workspace_id, page, limit, id, title, status, fields 等。 | | 获取缺陷数量 | GET | bugs/count | params: workspace_id 及筛选条件。 | | 创建/更新缺陷 | POST | bugs | body: workspace_id, title(创建必填), id(更新必填) 等。 | | 获取评论 | GET | comments | params: workspace_id, entry_id, entry_type, page, limit 等。 | | 添加评论 | POST | comments | body: workspace_id, entry_id, entry_type(bug/stories/tasks 等), author, description。 | | 更新评论 | POST | comments | body: workspace_id, id, description, change_creator。 | | 工作流流转细则 | GET | workflows/all_transitions | params: workspace_id, system(story/bug), workitem_type_id。 | | 工作流状态中英文映射 | GET | workflows/status_map | params: 同上。 | | 工作流结束状态 | GET | workflows/last_steps | params: workspace_id, system, workitem_type_id, type(可选)。 | | 工作项类型列表 | GET | workitem_types | params: workspace_id。 | | 需求字段中英文 | GET | stories/get_fields_lable | params: workspace_id。 | | 需求字段及候选值 | GET | stories/get_fields_info | params: workspace_id。 | | 项目信息 | GET | workspaces/get_workspace_info | params: workspace_id。 | | 获取迭代 | GET | iterations | params: workspace_id, id, name 等。 | | 创建/更新迭代 | POST | iterations | body: workspace_id, name/startdate/enddate/creator(创建必填), id/current_user(更新必填) 等。 | | 需求关联缺陷 | GET | stories/get_related_bugs | params: workspace_id, story_id。 | | 创建需求与缺陷关联 | POST | relations | body: workspace_id, source_type, target_type, source_id, target_id。 | | 获取测试用例 | GET | tcases | params: workspace_id, page, limit 等。 | | 创建/更新单条用例 | POST | tcases | body: workspace_id, name 等。 | | 批量创建用例 | POST | tcases/batch_save | body: 数组，每项含 workspace_id, name 等，最多 200 条。 | | 获取 Wiki | GET | tapd_wikis | params: workspace_id, page, limit 等。 | | 创建/更新 Wiki | POST | tapd_wikis | body: workspace_id, name, markdown_description, creator 等；更新带 id。 | | 获取待办 | GET | users/todo/{user_nick}/{entity_type} | entity_type: story/bug/task。 | | 获取工时 | GET | timesheets | params: workspace_id, entity_type, entity_id, owner, spentdate 等。 | | 新建/更新工时 | POST | timesheets | body: workspace_id, entity_type, entity_id, timespent, owner, spentdate(新建)；更新带 id。 | | 发布计划 | GET | releases | params: workspace_id, id, name, startdate, enddate 等。 | | 提交关键字 | GET | svn_commits/get_scm_copy_keywords | params: workspace_id, object_id, type(story/task/bug)。 | | 需求分类 ID | GET | story_categories | params: workspace_id, name 等。 | | 用户信息 | GET | users/info | 用于解析当前用户 nick。 | | 企业微信消息 | POST | BOT_URL（非 TAPD） | body: msgtype 为 markdown 或 markdown_v2，content 为消息内容；含 @ 时用 markdown，否则可用 markdown_v2。 | ## 链接格式（供返回给用户） - 需求：`{TAPD_BASE_URL}/{workspace_id}/prong/stories/view/{id}` - 任务：`{TAPD_BASE_URL}/{workspace_id}/prong/tasks/view/{id}` - 缺陷：`{TAPD_BASE_URL}/{workspace_id}/bugtrace/bugs/view/{id}` - 迭代：`{TAPD_BASE_URL}/{workspace_id}/prong/iterations/card_view/{id}` - 测试用例：`{TAPD_BASE_URL}/{workspace_id}/sparrow/tcase/view/{id}` - Wiki：`{TAPD_BASE_URL}/{workspace_id}/markdown_wikis/show/#{id}` ## 示例流程 ### 示例 1：先取自定义字段配置再按自定义字段查需求 1. 调用 `GET {entity_type}/custom_fields_settings?workspace_id={workspace_id}`，其中 entity_type 为 `stories`（需求）或 `tasks`（任务）。 2. 从返回中确认 custom_field_* 的字段名。 3. 调用 `GET stories`（或 `tasks`），params 包含 `workspace_id`、`entity_type`、以及 `custom_field_1` 等查询条件；需要详情时加 `fields=description,...`。 ### 示例 2：创建需求并填写工时 1. 调用 `POST stories`，body 含 `workspace_id`、`name`、可选 `description`、`owner`、`iteration_id` 等；记下返回的需求 id。 2. 调用 `POST timesheets`，body 含 `workspace_id`、`entity_type=story`、`entity_id`、`timespent`、`owner`、`spentdate`（YYYY-MM-DD）。 ## 命令行调用方式（推荐 AI 使用） 在配置好环境变量（TAPD_ACCESS_TOKEN 或 TAPD_API_USER/TAPD_API_PASSWORD；TAPD_API_BASE_URL 可选，默认云环境）后，使用 **python3** 运行脚本（脚本仅用标准库）。输出为 JSON 到 stdout，便于解析。 ```bash # 获取用户参与的项目（nick 默认取环境变量 CURRENT_USER_NICK） python3 {baseDir}/scripts/tapd_client_stdlib.py projects [--nick 用户昵称] # 获取项目信息 python3 {baseDir}/scripts/tapd_client_stdlib.py workspace --workspace-id <项目ID> # 获取需求或任务列表（--entity-type 默认 stories） python3 {baseDir}/scripts/tapd_client_stdlib.py stories --workspace-id <项目ID> [--entity-type stories|tasks] [--limit 10] [--page 1] [--id ID] [--name 标题] [--status 状态] [--fields id,name,description] # 获取缺陷列表 python3 {baseDir}/scripts/tapd_client_stdlib.py bugs --workspace-id <项目ID> [--limit 10] [--page 1] [--id ID] [--title 标题] # 获取迭代列表 python3 {baseDir}/scripts/tapd_client_stdlib.py iterations --workspace-id <项目ID> [--limit 30] [--page 1] [--name 迭代名] # 获取发布计划列表 python3 {baseDir}/scripts/tapd_client_stdlib.py releases --workspace-id <项目ID> [--limit 30] [--page 1] # 通用 GET（任意端点，-p 可多次） python3 {baseDir}/scripts/tapd_client_stdlib.py get --endpoint "stories/count" -p workspace_id=<项目ID> -p entity_type=stories python3 {baseDir}/scripts/tapd_client_stdlib.py get --endpoint [REDACTED_SECRET] -p nick=用户昵称 # 通用 POST（请求体为 JSON 或 -p key=val 多组） python3 {baseDir}/scripts/tapd_client_stdlib.py post --endpoint "stories" -b '{"workspace_id":123,"name":"需求标题","entity_type":"stories"}' python3 {baseDir}/scripts/tapd_client_stdlib.py post --endpoint "comments" -p workspace_id=123 -p entry_id=xxx -p entry_type=stories -p author=昵称 -p description=内容 ``` **说明**：未列出的能力（如评论、工作流、Wiki、工时、企业微信等）可通过 `get` / `post` 子命令配合 [reference/api_reference.md](./reference/api_reference.md) 中的端点与参数自行拼装调用。 ### 安全与权限建议 - **TAPD_ACCESS_TOKEN**：建议在 TAPD 开放平台中为该技能创建**最小权限、短期有效**的令牌（仅授予所需项目/接口权限，并设置较短过期时间），避免使用高权限长期令牌。 - **BOT_URL / TAPD_API_BASE_URL**：使用前请确认 `BOT_URL`（企业微信 webhook）和自定义的 `TAPD_API_BASE_URL` 均指向**可信端点**（如 TAPD 官方 `https://api.tapd.cn` 或贵司自建 TAPD、企业微信官方 webhook 地址），避免指向不可信第三方。 - **可选加固**：若需更强安全，可在沙盒环境中运行本脚本，并限制网络仅允许访问 TAPD API 与 BOT_URL（例如通过防火墙或容器网络策略），以确认脚本仅与预期端点通信。 ### API key / 环境变量 - `TAPD_ACCESS_TOKEN` 环境变量（推荐）；或使用 `TAPD_API_USER` + `TAPD_API_PASSWORD`。 - OpenClaw 中可设置 `skills.tapd.env.TAPD_ACCESS_TOKEN` 于 `~/.openclaw/openclaw.json`。 ## 使用脚本（可选） 本 Skill 提供仅使用 Python 标准库的客户端脚本 [scripts/tapd_client_stdlib.py](./scripts/tapd_client_stdlib.py)，使用 **python3 {baseDir}/scripts/tapd_client_stdlib.py** 运行。可从环境变量读取配置并封装通用 request 及部分高频接口；支持上述**命令行调用**与 Python 内 import 两种方式。 ## 注意事项 - 使用 custom_field_* 前必须先调用对应实体类型的 custom_fields_settings 接口获取配置。 - 任务状态仅三种：open（未开始）、progressing（进行中）、done（已完成）；需求状态需通过 get_workflows_status_map / get_stories_fields_info 获取项目配置。 - 测试用例的 precondition、steps、expectation 可传纯文本；若需富文本，由调用方自行转换为 HTML，本 Skill 不依赖 markdown 库。

path: reference/api_reference.md
content: # TAPD API 参考

本文档为 TAPD 开放 API 的端点与参数速查，配合 [SKILL.md](../SKILL.md) 使用。所有请求需在 base URL 后加 `?s=mcp`（或 `&s=mcp`），并携带认证与 `Via: mcp` 等 Header。

## 通用说明

- **Base URL**：环境变量 `TAPD_API_BASE_URL`，默认 `https://api.tapd.cn`（未设置时使用该默认值）。
- **认证**：`Authorization: Bearer <token>` 或 `Authorization: Basic <base64(user:password)>`。
- **分页**：多数列表接口支持 `page`、`limit`（如 limit 默认 10 或 30，最大 200）。
- **返回结构**：常见为 `{ "status": 1, "data": {...} 或 [...], "info": "..." }`；列表常在 `data` 下，单条如 `data.Story`、`data.Bug`、`data.Task`、`data.Iteration` 等。

---

## 项目与用户

| 方法 | 端点 | 主要参数 | 说明 |
|------|------|----------|------|
| GET | workspaces/user_participant_projects | nick | 用户参与的项目；过滤 category=organization。 |
| GET | workspaces/get_workspace_info | workspace_id | 项目信息。 |
| GET | users/info | — | 当前用户信息（含 nick）。 |

---

## 需求与任务（stories / tasks）

| 方法 | 端点 | 主要参数/Body | 说明 |
|------|------|----------------|------|
| GET | stories 或 tasks | workspace_id, entity_type(stories/tasks), page, limit, id, name, status, v_status, category_id, iteration_id, owner, fields 等 | 列表；id 支持多值逗号；短 id 需转长 id。 |
| GET | stories/count 或 tasks/count | 同列表筛选条件 | 数量。 |
| POST | stories 或 tasks | workspace_id, name(创建必填), id(更新必填), entity_type, description, owner, iteration_id, priority_label 等 | 创建/更新；description 可传 HTML。 |
| GET | stories/custom_fields_settings?workspace_id= | workspace_id | 需求自定义字段配置。 |
| GET | tasks/custom_fields_settings?workspace_id= | workspace_id | 任务自定义字段配置。 |
| GET | stories/get_fields_lable?workspace_id= | workspace_id | 需求字段中英文。 |
| GET | stories/get_fields_info?workspace_id= | workspace_id | 需求字段及候选值。 |

---

## 缺陷（bugs）

| 方法 | 端点 | 主要参数/Body | 说明 |
|------|------|----------------|------|
| GET | bugs | workspace_id, page, limit, id, title, status, priority_label, severity, fields 等 | 列表；id 需转长 id。 |
| GET | bugs/count | workspace_id 及筛选条件 | 数量。 |
| POST | bugs | workspace_id, title(创建必填), id(更新必填), description, status, current_owner, reporter 等 | 创建/更新。 |
| GET | bugs/custom_fields_settings?workspace_id= | workspace_id | 缺陷自定义字段配置。 |

---

## 评论（comments）

| 方法 | 端点 | 主要参数/Body | 说明 |
|------|------|----------------|------|
| GET | comments | workspace_id, entry_id, entry_type(bug/stories/tasks 等), author, page, limit, order 等 | 列表。 |
| POST | comments | workspace_id, entry_id, entry_type, author, description；更新时 id, change_creator | 添加/更新评论；entry_id 短号需转长 id。 |

---

## 附件与图片

| 方法 | 端点 | 主要参数 | 说明 |
|------|------|----------|------|
| GET | files/get_image | workspace_id, image_path(必填) | 单张图片下载链接。 |
| GET | attachments | workspace_id, entry_id, type(story/bug) | 附件列表含 download_url。 |
| GET | attachments/down | workspace_id, id(附件 id) | 附件下载。 |

---

## 工作流与工作项类型

| 方法 | 端点 | 主要参数 | 说明 |
|------|------|----------|------|
| GET | workflows/all_transitions | workspace_id, system(story/bug), workitem_type_id | 流转细则。 |
| GET | workflows/status_map | workspace_id, system, workitem_type_id | 状态中英文映射。 |
| GET | workflows/last_steps | workspace_id, system, workitem_type_id, type(可选) | 结束状态。 |
| GET | workitem_types | workspace_id | 需求类别列表。 |

---

## 迭代（iterations）

| 方法 | 端点 | 主要参数/Body | 说明 |
|------|------|----------------|------|
| GET | iterations | workspace_id, id, name, status 等 | 列表。 |
| POST | iterations | workspace_id, name, startdate, enddate, creator(创建)；id, current_user(更新) | 创建/更新；status: open/done。 |
| GET | iterations/custom_fields_settings?workspace_id= | workspace_id | 迭代自定义字段配置。 |

---

## 需求分类与关联

| 方法 | 端点 | 主要参数 | 说明 |
|------|------|----------|------|
| GET | story_categories | workspace_id, name | 需求分类，用于得到 category_id。 |
| GET | stories/get_related_bugs | workspace_id, story_id | 需求关联的缺陷 id。 |
| POST | relations | workspace_id, source_type, target_type, source_id, target_id | 创建需求与缺陷关联。 |

---

## 测试用例（tcases）

| 方法 | 端点 | 主要参数/Body | 说明 |
|------|------|----------------|------|
| GET | tcases | workspace_id, page, limit, id, name, category_id, status 等 | 列表。 |
| GET | tcases/count | 同列表条件 | 数量。 |
| POST | tcases | workspace_id, name, category_id, status, precondition, steps, expectation 等 | 单条创建/更新。 |
| POST | tcases/batch_save | body 为数组，每项含 workspace_id, name 等 | 批量创建，最多 200 条。 |
| GET | tcases/custom_fields_settings?workspace_id= | workspace_id | 用例自定义字段配置。 |

---

## Wiki（tapd_wikis）

| 方法 | 端点 | 主要参数/Body | 说明 |
|------|------|----------------|------|
| GET | tapd_wikis | workspace_id, page, limit, id, name 等 | 列表。 |
| GET | tapd_wikis/count | 同列表条件 | 数量。 |
| POST | tapd_wikis | workspace_id, name, markdown_description, creator；更新带 id | 创建/更新。 |

---

## 待办与工时

| 方法 | 端点 | 主要参数/Body | 说明 |
|------|------|----------------|------|
| GET | users/todo/{user_nick}/{entity_type} | entity_type: story/bug/task | 用户待办。 |
| GET | timesheets | workspace_id, entity_type, entity_id, owner, spentdate, page, limit 等 | 工时列表。 |
| POST | timesheets | workspace_id, entity_type, entity_id, timespent, owner, spentdate(新建)；id(更新) | 新建/更新工时。 |

---

## 发布与提交

| 方法 | 端点 | 主要参数 | 说明 |
|------|------|----------|------|
| GET | releases | workspace_id, id, name, startdate, enddate, status 等 | 发布计划。 |
| GET | svn_commits/get_scm_copy_keywords | workspace_id, object_id, type(story/task/bug) | 提交关键字；object_id 短号需转长 id。 |

---

## 企业微信（非 TAPD API）

- **URL**：环境变量 `BOT_URL`（机器人 webhook）。
- **方法**：POST，Body JSON。
- **格式**：消息含 `@` 时用 `msgtype: markdown`、`markdown.content`；否则可用 `msgtype: markdown_v2`、`markdown_v2.content`。


path: scripts/tapd_client_stdlib.py
content: """
TAPD API 客户端（仅使用 Python 标准库）。

依赖：urllib.request, json, os, base64, argparse。
环境变量：TAPD_ACCESS_TOKEN 或 TAPD_API_USER + TAPD_API_PASSWORD；
         TAPD_API_BASE_URL（可选，默认 https://api.tapd.cn）；可选 TAPD_BASE_URL（默认 https://www.tapd.cn）, BOT_URL, CURRENT_USER_NICK。

命令行用法（AI 可直接调用）：
    python tapd_client_stdlib.py projects [--nick NICK]
    python tapd_client_stdlib.py workspace --workspace-id ID
    python tapd_client_stdlib.py stories --workspace-id ID [--entity-type stories|tasks] [--limit N] [--page N] [--id ID] [--name NAME] [--status STATUS]
    python tapd_client_stdlib.py bugs --workspace-id ID [--limit N] [--page N] [--id ID]
    python tapd_client_stdlib.py iterations --workspace-id ID [--limit N] [--page N]
    python tapd_client_stdlib.py releases --workspace-id ID [--limit N] [--page N]
    python tapd_client_stdlib.py get --endpoint "stories/count" -p workspace_id=123 -p entity_type=stories
    python tapd_client_stdlib.py post --endpoint "stories" -b '{"workspace_id":123,"name":"需求标题"}'

Python 调用示例：
    from tapd_client_stdlib import request, get_stories, get_workspace_info
    resp = request("GET", "stories", params={"workspace_id": 123, "limit": 10})
    stories = get_stories(123, {"entity_type": "stories", "limit": 5})
"""

import argparse
import json
import os
import sys
import urllib.parse
import urllib.request
from base64 import b64encode
from typing import Any, Optional


# 默认 TAPD 云环境地址，未配置环境变量时使用
DEFAULT_TAPD_API_BASE_URL = "https://api.tapd.cn"
DEFAULT_TAPD_BASE_URL = "https://www.tapd.cn"


def _get_base_url() -> str:
    base = os.environ.get("TAPD_API_BASE_URL", DEFAULT_TAPD_API_BASE_URL)
    return base.rstrip("/")


def _get_headers() -> dict:
    [REDACTED_SECRET]"TAPD_ACCESS_TOKEN")
    if [REDACTED_SECRET] {
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json",
            "Via": "mcp",
        }
    user = os.environ.get("TAPD_API_USER")
    [REDACTED_SECRET]"TAPD_API_PASSWORD")
    if not user or not password:
        raise ValueError(
            "请设置 TAPD_ACCESS_TOKEN 或 TAPD_API_USER + TAPD_API_PASSWORD"
        )
    auth = b64encode(f"{user}:{password}".encode()).decode()
    return {
        "Authorization": f"Basic {auth}",
        "Content-Type": "application/json",
        "Via": "mcp",
    }


def _is_cloud() -> bool:
    base = os.environ.get("TAPD_API_BASE_URL", DEFAULT_TAPD_API_BASE_URL)
    return "api.tapd.cn" in base


def to_long_id(short_id: str, workspace_id: int) -> str:
    """将短 ID（≤9 位数字）转为 TAPD 长 ID。"""
    s = str(short_id).strip()
    if not s.isdigit() or len(s) > 9:
        return s
    prefix = "11" if _is_cloud() else "10"
    return f"{prefix}{workspace_id}{s.zfill(9)}"


def request(
    method: str,
    endpoint: str,
    params: Optional[dict] = None,
    data: Optional[dict] = None,
    timeout: int = 30,
) -> dict:
    """
    发送 TAPD API 请求。URL 自动追加 ?s=mcp 或 &s=mcp。

    :param method: GET 或 POST
    :param endpoint: 路径，如 "stories", "workspaces/get_workspace_info"
    :param params: GET 查询参数或 POST 时也可拼在 URL（部分接口习惯用 query）
    :param data: POST 请求体（JSON）
    :param timeout: 超时秒数
    :return: 响应 JSON（dict）
    """
    base = _get_base_url()
    url = f"{base}/{endpoint.lstrip('/')}"
    sep = "&" if "?" in url else "?"
    url = f"{url}{sep}s=mcp"

    if params:
        encoded = urllib.parse.urlencode(params, doseq=True)
        url = f"{url}&{encoded}" if "?" in url else f"{url}?{encoded}"

    headers = _get_headers()
    req_body = None
    if method.upper() == "POST" and data is not None:
        req_body = json.dumps(data).encode("utf-8")

    req = urllib.request.Request(url, data=req_body, headers=headers, method=method.upper())
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        return json.loads(resp.read().decode("utf-8"))


def get_stories(
    workspace_id: int,
    options: Optional[dict] = None,
) -> dict:
    """
    获取需求或任务列表。entity_type 在 options 中指定为 stories 或 tasks。
    """
    opts = options or {}
    entity_type = opts.get("entity_type", "stories")
    endpoint = "stories" if entity_type == "stories" else "tasks"
    params = {"workspace_id": workspace_id, "page": 1, "limit": 10}
    params.update(opts)
    return request("GET", endpoint, params=params)


def get_workspace_info(workspace_id: int) -> dict:
    """根据项目 ID 获取项目信息。"""
    return request(
        "GET",
        f"workspaces/get_workspace_info?workspace_id={workspace_id}",
    )


def get_user_participant_projects(nick: str) -> dict:
    """获取用户参与的项目列表。"""
    return request("GET", "workspaces/user_participant_projects", params={"nick": nick})


def get_bugs(workspace_id: int, options: Optional[dict] = None) -> dict:
    """获取缺陷列表。"""
    params = {"workspace_id": workspace_id, "page": 1, "limit": 10}
    if options:
        params.update(options)
    return request("GET", "bugs", params=params)


def get_iterations(workspace_id: int, options: Optional[dict] = None) -> dict:
    """获取迭代列表。"""
    params = {"workspace_id": workspace_id}
    if options:
        params.update(options)
    return request("GET", "iterations", params=params)


def get_releases(workspace_id: int, options: Optional[dict] = None) -> dict:
    """获取发布计划列表。"""
    params = {"workspace_id": workspace_id}
    if options:
        params.update(options)
    return request("GET", "releases", params=params)


def _cli():
    parser = argparse.ArgumentParser(
        description="TAPD API 命令行客户端（仅标准库）。需设置 TAPD_ACCESS_TOKEN 或 TAPD_API_USER/TAPD_API_PASSWORD；TAPD_API_BASE_URL 可选（默认 https://api.tapd.cn）。"
    )
    sub = parser.add_subparsers(dest="command", required=True, help="子命令")

    # projects
    p_projects = sub.add_parser("projects", help="获取用户参与的项目列表")
    p_projects.add_argument("--nick", default=os.environ.get("CURRENT_USER_NICK", ""), help="用户昵称，默认 CURRENT_USER_NICK")

    # workspace
    p_ws = sub.add_parser("workspace", help="获取项目信息")
    p_ws.add_argument("--workspace-id", type=int, required=True, dest="workspace_id", help="项目 ID")

    # stories（含 tasks）
    p_stories = sub.add_parser("stories", help="获取需求或任务列表")
    p_stories.add_argument("--workspace-id", type=int, required=True, dest="workspace_id")
    p_stories.add_argument("--entity-type", choices=("stories", "tasks"), default="stories", dest="entity_type")
    p_stories.add_argument("--limit", type=int, default=10)
    p_stories.add_argument("--page", type=int, default=1)
    p_stories.add_argument("--id", dest="id_", metavar="ID", help="需求/任务 ID，支持逗号分隔多 ID")
    p_stories.add_argument("--name", help="标题模糊匹配")
    p_stories.add_argument("--status", help="状态")
    p_stories.add_argument("--fields", help="返回字段，逗号分隔")

    # bugs
    p_bugs = sub.add_parser("bugs", help="获取缺陷列表")
    p_bugs.add_argument("--workspace-id", type=int, required=True, dest="workspace_id")
    p_bugs.add_argument("--limit", type=int, default=10)
    p_bugs.add_argument("--page", type=int, default=1)
    p_bugs.add_argument("--id", dest="id_", metavar="ID", help="缺陷 ID")
    p_bugs.add_argument("--title", help="标题")

    # iterations
    p_iter = sub.add_parser("iterations", help="获取迭代列表")
    p_iter.add_argument("--workspace-id", type=int, required=True, dest="workspace_id")
    p_iter.add_argument("--limit", type=int, default=30)
    p_iter.add_argument("--page", type=int, default=1)
    p_iter.add_argument("--name", help="迭代名称")

    # releases
    p_rel = sub.add_parser("releases", help="获取发布计划列表")
    p_rel.add_argument("--workspace-id", type=int, required=True, dest="workspace_id")
    p_rel.add_argument("--limit", type=int, default=30)
    p_rel.add_argument("--page", type=int, default=1)

    # 通用 get / post
    p_get = sub.add_parser("get", help="通用 GET：--endpoint 路径，-p key=val 多个参数")
    p_get.add_argument("--endpoint", required=True, help="API 路径，如 stories、bugs/count")
    p_get.add_argument("-p", "--param", dest="params", action="append", default=[], metavar="KEY=VAL", help="查询参数，可多次")

    p_post = sub.add_parser("post", help="通用 POST：--endpoint 路径，-b JSON 或 -p key=val")
    p_post.add_argument("--endpoint", required=True, help="API 路径，如 stories、comments")
    p_post.add_argument("-b", "--body", help="JSON 请求体")
    p_post.add_argument("-p", "--param", dest="params", action="append", default=[], metavar="KEY=VAL", help="作为 body 的键值，可多次（与 -b 二选一）")

    args = parser.parse_args()
    out = None

    if args.command == "projects":
        nick = (args.nick or "").strip()
        if not nick:
            print(json.dumps({"error": "请提供 --nick 或设置 CURRENT_USER_NICK"}, ensure_ascii=False), file=sys.stderr)
            raise SystemExit(1)
        out = get_user_participant_projects(nick)
    elif args.command == "workspace":
        out = get_workspace_info(args.workspace_id)
    elif args.command == "stories":
        opts = {"entity_type": args.entity_type, "limit": args.limit, "page": args.page}
        if getattr(args, "id_", None):
            opts["id"] = args.id_
        if getattr(args, "name", None):
            opts["name"] = args.name
        if getattr(args, "status", None):
            opts["status"] = args.status
        if getattr(args, "fields", None):
            opts["fields"] = args.fields
        out = get_stories(args.workspace_id, opts)
    elif args.command == "bugs":
        opts = {"limit": args.limit, "page": args.page}
        if getattr(args, "id_", None):
            opts["id"] = args.id_
        if getattr(args, "title", None):
            opts["title"] = args.title
        out = get_bugs(args.workspace_id, opts)
    elif args.command == "iterations":
        opts = {"limit": args.limit, "page": args.page}
        if getattr(args, "name", None):
            opts["name"] = args.name
        out = get_iterations(args.workspace_id, opts)
    elif args.command == "releases":
        out = get_releases(args.workspace_id, {"limit": args.limit, "page": args.page})
    elif args.command == "get":
        params = {}
        for s in args.params:
            if "=" in s:
                k, v = s.split("=", 1)
                params[k.strip()] = v.strip()
        out = request("GET", args.endpoint, params=params if params else None)
    elif args.command == "post":
        if args.body:
            try:
                data = json.loads(args.body)
            except json.JSONDecodeError as e:
                print(json.dumps({"error": f"无效 JSON: {e}"}, ensure_ascii=False), file=sys.stderr)
                raise SystemExit(1)
        else:
            data = {}
            for s in args.params:
                if "=" in s:
                    k, v = s.split("=", 1)
                    data[k.strip()] = v.strip()
        out = request("POST", args.endpoint, data=data if data else None)

    if out is not None:
        print(json.dumps(out, indent=2, ensure_ascii=False))


if __name__ == "__main__":
    _cli()


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