Skill: 1ncludesteven/tsa-risk (version 1.5.0)

--- name: tsa-risk description: 腾讯云智能顾问（Tencent Cloud Smart Advisor）架构风险巡检工具。用于获取云架构详情、架构列表、目录查询、风险评估项查询及架构评估结果。 metadata: {"openclaw": {"emoji": "🔍", "requires": {"bins": ["python3"], "env": ["TENCENTCLOUD_SECRET_ID", "TENCENTCLOUD_SECRET_KEY"]}, "permissions": ["network:https://*.tencentcloudapi.com", "network:https://cloud.tencent.com", "fs:~/.tsa-risk/"], "security": {"iam_operations": ["cam:GetRole", "cam:CreateRole", "cam:AttachRolePolicy", "cam:DeleteRole", "cam:DescribeRoleList", "sts:AssumeRole", "sts:GetCallerIdentity"], "iam_note": "角色创建/删除为独立步骤，需用户明确同意后执行：create_role.py 创建角色，cleanup.py --cloud 删除角色；check_env.py 仅做只读检测", "data_handling": "临时凭证仅在内存中使用，不持久化存储；配置文件仅保存角色 ARN，不保存密钥"}}} --- # 🔍 腾讯云智能顾问（TSA） 通过 **AK/SK 鉴权**调用腾讯云智能顾问（Tencent Cloud Smart Advisor）API，管理云架构图的目录与详情、获取架构评估结果，以及查询风险评估项。 --- ## 一、鉴权方式 使用腾讯云 API **AK/SK 签名认证**（TC3-HMAC-SHA256），通过环境变量配置密钥： ### 1.1 必填环境变量 - `TENCENTCLOUD_SECRET_ID` — 腾讯云 SecretId（必填） - `TENCENTCLOUD_SECRET_KEY` — 腾讯云 SecretKey（必填） 密钥获取地址：https://console.cloud.tencent.com/cam/capi **环境变量必须永久写入 shell 配置文件**，确保新会话中仍然生效： Linux / macOS（写入 `~/.bashrc` 或 `~/.zshrc`）： ```bash echo 'export TENCENTCLOUD_SECRET_ID="your-secret-id"' >> ~/.bashrc echo 'export TENCENTCLOUD_SECRET_KEY="your-secret-key"' >> ~/.bashrc source ~/.bashrc ``` Windows PowerShell（写入用户级环境变量）： ```powershell [Environment]::SetEnvironmentVariable("TENCENTCLOUD_SECRET_ID", "your-secret-id", "User") [Environment]::SetEnvironmentVariable("TENCENTCLOUD_SECRET_KEY", "your-secret-key", "User") ``` ### 1.2 角色配置（免密登录需要） 为生成控制台免密登录链接，需要配置 CAM 角色。角色配置分为 **检测** 和 **创建** 两个独立步骤，角色创建属于 IAM 写入操作，**必须在用户明确同意后才能执行**。 #### 步骤一：环境检测（只读） 运行环境自检脚本，检测依赖、版本更新、密钥、角色配置状态： ```bash python3 {baseDir}/check_env.py ``` 自检脚本 **仅做只读检测**，不会创建或修改任何资源。返回码含义： - `0` = 环境就绪（密钥 + 角色全部正常） - `1` = Python 版本不满足要求 - `2` = AK/SK 未配置或无效 - `3` = 角色未配置（需要执行步骤二） - `4` = Skill 版本过旧，请更新 Skill 脚本首次运行时会自动检查本地 `_meta.json` 中的版本号与远端最新版本是否一致，若发现新版本则提示更新并退出（返回码 `4`）。可通过 `--skip-update` 参数跳过版本检查。 #### 步骤二：角色创建（需用户同意） 当 `check_env.py` 返回码为 `3`（角色未配置）时，**必须**向用户展示角色创建方案并等待同意： **向用户说明以下内容**： 1. 将创建 CAM 角色 `advisor`，仅用于免密登录控制台查看智能顾问信息 2. 将关联策略 `QcloudAdvisorFullAccess`（智能顾问只读访问权限，不影响其他云资源） 3. 信任策略仅允许当前账号扮演此角色 4. 用户可随时在 CAM 控制台删除此角色 **用户同意后**，执行角色创建脚本： ```bash python3 {baseDir}/scripts/create_role.py ``` 脚本输出 JSON 格式结果，`success: true` 表示创建成功并已保存配置。 **用户拒绝时**，提供手动配置方式（方式二、三、四）。 #### 方式二：配置向导（交互式选择已有角色） 运行配置向导，从已有角色中选择，或交互式创建新角色： ```bash python3 {baseDir}/scripts/setup_role.py ``` #### 方式三：简化配置 只需提供角色名称，系统自动获取账号 UIN。将以下内容写入 shell 配置文件（如 `~/.bashrc` 或 `~/.zshrc`）： ```bash echo 'export TENCENTCLOUD_ROLE_NAME="advisor"' >> ~/.bashrc source ~/.bashrc ``` 系统会自动调用 API 获取您的账号 UIN，并拼接完整的 roleArn。 #### 方式四：完整配置（高级用户） 手动设置完整的角色 ARN，写入 shell 配置文件（如 `~/.bashrc` 或 `~/.zshrc`）： ```bash echo 'export TENCENTCLOUD_ROLE_ARN="qcs::cam::uin/100001234567:roleName/advisor"' >> ~/.bashrc source ~/.bashrc ``` ### 1.3 可选环境变量 - `TENCENTCLOUD_TOKEN` — 临时密钥 Token（使用临时密钥时设置） - `TENCENTCLOUD_ROLE_SESSION` — 角色会话名称（默认 `advisor-session`） - `TENCENTCLOUD_STS_DURATION` — 临时凭证有效期秒数（默认 `3600`，即 1 小时；最大 `43200`，即 12 小时） > **注意**：所有环境变量均需永久写入 shell 配置文件（如 `~/.bashrc`、`~/.zshrc`），`export` 仅对当前会话生效，新开会话会丢失。 ### 1.4 配置优先级 系统按以下优先级加载角色配置： 1. 环境变量 `TENCENTCLOUD_ROLE_ARN`（完整 ARN） 2. 配置文件 `~/.tsa-risk/config.json` 3. 环境变量 `TENCENTCLOUD_ROLE_NAME` + 自动获取账号 UIN --- ## 二、前置检查（初始化工作流） 每次操作前必须先执行环境检测。初始化分为 **版本检查**、**环境检测** 和 **角色创建** 三个阶段，角色创建属于 IAM 写入操作，必须在用户明确同意后才能执行。 ### 2.1 初始化工作流（必须严格按顺序执行） **第一步：运行环境检测** ```bash python3 {baseDir}/check_env.py ``` 脚本会依次执行以下检测： 1. 检查 Python 版本（需要 3.7+） 2. 检查 Skill 版本更新（读取本地 `_meta.json` 版本，与远端最新版本对比） 3. 检查 AK/SK 配置 4. 验证 AK/SK 有效性 5. 检查免密登录角色配置 6. 验证角色扮演 根据返回码判断状态： - `0` = 环境就绪，可以正常使用所有功能 - `1` = Python 版本不满足要求 → 提示用户升级 Python - `2` = AK/SK 未配置或无效 → 提示用户配置密钥 - `3` = 角色未配置 → 执行第二步 - `4` = Skill 版本过旧 → 提示用户更新 Skill **版本检查说明**：脚本首次运行时会自动对比本地版本与远端版本。若远端有新版本，脚本会输出当前版本和最新版本号，并以返回码 `4` 退出。网络不可用或远端接口异常时，版本检查会被跳过，不影响后续检测。可通过 `--skip-update` 参数主动跳过版本检查。 **第二步：向用户展示角色创建方案**（仅当返回码为 3 时） 向用户说明即将执行的 IAM 操作，**等待用户明确同意**： > 免密登录功能需要创建一个 CAM 角色，以下是创建方案： > - **角色名称**：`advisor` > - **关联策略**：`QcloudTAGFullAccess`（标签全读写权限）、`QcloudAdvisorFullAccess`（智能顾问全读写权限） > - **信任策略**：仅允许当前账号扮演此角色 > - **用途**：仅用于生成控制台免密登录链接，不影响其他云资源 > - 您可随时在 [CAM 控制台](https://console.cloud.tencent.com/cam/role) 删除此角色 > > 是否同意创建？ **第三步：执行角色创建**（仅在用户同意后） ```bash python3 {baseDir}/scripts/create_role.py ``` 脚本输出 JSON 格式结果，`success: true` 表示创建成功。 **第四步：再次运行环境检测，确认环境就绪** ```bash python3 {baseDir}/check_env.py ``` 返回码 `0` 表示初始化完成，所有功能可用。 ### 2.2 静默模式（供脚本内部调用） ```bash python3 {baseDir}/check_env.py --quiet ``` 静默模式下仅输出错误信息，适合其他脚本调用获取环境状态。角色未配置时返回码 `3`，**不会自动创建角色**。 ### 2.3 跳过版本检查 ```bash python3 {baseDir}/check_env.py --skip-update ``` 跳过远端版本对比，直接进行后续环境检测。适用于离线环境或已知无需更新的场景。可与 `--quiet` 组合使用。 --- ## 三、API 调用方式 所有接口通过统一的签名脚本调用，服务固定参数： - **service**: `advisor` - **host**: `advisor.tencentcloudapi.com` - **version**: `2020-07-21` ```bash python3 {baseDir}/scripts/tcloud_api.py advisor advisor.tencentcloudapi.com <Action> 2020-07-21 '<payload>' [region] ``` --- ## 四、可用接口（共 6 个） 所有接口频率限制均为 **20 次/秒**。使用某个接口前，**必须先加载对应的接口文档**获取参数、返回值和展示规则等详细信息。 ### 4.1 DescribeArch — 获取云架构详情 获取指定云架构图的详细信息。必填参数：`ArchId`、`Username`。 - **触发词**："架构详情"、"查看架构图"、"架构图详情" - **详细文档**：使用前加载 `{baseDir}/references/DescribeArch.md` ```bash python3 {baseDir}/scripts/tcloud_api.py advisor advisor.tencentcloudapi.com DescribeArch 2020-07-21 '{"ArchId":"arch-xxx","Username":"user1"}' ``` ### 4.2 DescribeArchList — 获取云架构列表 分页获取云架构图列表，支持按名称、关键词、文件夹等筛选。必填参数：`PageNumber`、`PageSize`。 - **触发词**："架构列表"、"所有架构图"、"云架构列表" - **详细文档**：使用前加载 `{baseDir}/references/DescribeArchList.md` ```bash python3 {baseDir}/scripts/tcloud_api.py advisor advisor.tencentcloudapi.com DescribeArchList 2020-07-21 '{"PageNumber":1,"PageSize":10}' ``` ### 4.3 ListDirectoryV2 — 新版目录查询 查询云架构图的目录树结构。无必填参数。 - **触发词**："查询目录"、"目录列表"、"架构目录" - **详细文档**：使用前加载 `{baseDir}/references/ListDirectoryV2.md` ```bash python3 {baseDir}/scripts/tcloud_api.py advisor advisor.tencentcloudapi.com ListDirectoryV2 2020-07-21 '{}' ``` ### 4.4 ListUnorganizedDirectory — 查询待整理目录 查询新版目录下的待整理（未归类）目录结构。无必填参数。 - **触发词**："待整理目录"、"未归类架构"、"待整理" - **详细文档**：使用前加载 `{baseDir}/references/ListUnorganizedDirectory.md` ```bash python3 {baseDir}/scripts/tcloud_api.py advisor advisor.tencentcloudapi.com ListUnorganizedDirectory 2020-07-21 '{}' ``` ### 4.5 DescribeStrategies — 获取风险评估项列表 查询全量风险评估项（巡检项），支持客户端按产品/分组/风险等级筛选。无必填参数。 - **触发词**："风险评估"、"巡检项"、"评估项"、"风险项"、"安全检查"、"云资源风险" - **详细文档**：使用前加载 `{baseDir}/references/DescribeStrategies.md` ```bash python3 {baseDir}/scripts/tcloud_api.py advisor advisor.tencentcloudapi.com DescribeStrategies 2020-07-21 '{}' ``` ### 4.6 DescribeLastEvaluation — 获取架构图最近一次评估结果 获取指定架构图的 Well-Architected 评估结果，含总分、各维度得分和治理建议。必填参数：`ArchId`。 - **触发词**："架构评估"、"评估结果"、"架构图得分"、"Well-Architected"、"架构巡检结果" - **详细文档**：使用前加载 `{baseDir}/references/DescribeLastEvaluation.md` ```bash python3 {baseDir}/scripts/tcloud_api.py advisor advisor.tencentcloudapi.com DescribeLastEvaluation 2020-07-21 '{"ArchId":"arch-xxx"}' ``` --- ## 五、免密登录链接生成 当接口返回结果中包含架构图（含 `ArchId` 字段）时，**必须**调用免密登录脚本为用户生成腾讯云控制台直达链接，用户点击即可免登录跳转到对应页面。架构图列表场景下，只需为**第一张架构图**生成免密链接。 > **⚠️ 重要**：免密登录链接**每次都必须重新生成**，不可缓存或复用之前生成的链接。每次向用户展示时，都必须重新调用 `login_url.py` 生成新的链接。 ### 5.1 前置条件 为生成免密登录链接，需要先完成角色配置。请按「二、前置检查」中的初始化工作流完成角色配置（检测 → 用户同意 → 创建角色 → 确认就绪）。 也可以通过以下方式手动配置角色： **配置向导** ```bash python3 {baseDir}/scripts/setup_role.py ``` **简化配置**（写入 `~/.bashrc` 或 `~/.zshrc`） ```bash echo 'export TENCENTCLOUD_ROLE_NAME="advisor"' >> ~/.bashrc && source ~/.bashrc ``` **完整配置**（写入 `~/.bashrc` 或 `~/.zshrc`） ```bash echo 'export TENCENTCLOUD_ROLE_ARN="qcs::cam::uin/100001234567:roleName/advisor"' >> ~/.bashrc && source ~/.bashrc ``` 可选环境变量: - `TENCENTCLOUD_ROLE_SESSION` — 角色会话名称（默认 `advisor-session`） - `TENCENTCLOUD_STS_DURATION` — 临时凭证有效期秒数（默认 `3600`，即 1 小时；最大 `43200`，即 12 小时） > **提示**: 详细配置说明请参考"一、鉴权方式"章节 ### 5.2 调用方式 ```bash python3 {baseDir}/scripts/login_url.py "<目标页面URL>" ``` 架构图控制台页面 URL 格式：`https://console.cloud.tencent.com/advisor?archId={ArchId}` ### 5.3 调用示例 ```bash # 为指定架构图生成免密链接 python3 {baseDir}/scripts/login_url.py \ "https://console.cloud.tencent.com/advisor?archId=arch-gvqocc25" # 跳转到智能顾问首页 python3 {baseDir}/scripts/login_url.py \ "https://console.cloud.tencent.com/advisor" ``` ### 5.4 返回示例 ```json { "success": true, "action": "GenerateLoginURL", "data": { "loginUrl": "https://cloud.tencent.com/login/roleAccessCallback?algorithm=sha256&secretId=...&[REDACTED_SECRET]", "targetUrl": "https://console.cloud.tencent.com/advisor?archId=arch-gvqocc25", "expireSeconds": 3600 }, "requestId": "xxx" } ``` | 字段 | 说明 | |------|------| | `loginUrl` | 免密登录完整 URL，用户点击可直接跳转控制台 | | `targetUrl` | 登录后跳转的目标页面 | | `expireSeconds` | 链接有效期（秒） | ### 5.5 展示规则 免密登录 URL 非常长，**严禁直接展示完整 URL**。每次展示时**必须重新调用脚本生成新链接**（链接不可复用），并以 Markdown 超链接格式展示： ``` [跳转控制台](免密登录URL) ``` 示例输出： ``` 架构图名称：生产环境架构 架构图 ID：arch-gvqocc25 [跳转控制台](https://cloud.tencent.com/login/roleAccessCallback?algorithm=sha256&secretId=...&[REDACTED_SECRET]) ``` 列表场景示例（仅第一张架构图附带免密链接）： ``` 1. 生产环境架构（arch-abc123）— [跳转控制台](免密登录URL) 2. 测试环境架构（arch-def456） 3. 预发布环境架构（arch-ghi789） ``` ### 5.6 工作流程：查询架构并生成控制台链接 当用户查询架构图时，完整流程如下： ```bash # 第一步：查询架构列表 RESULT=$(python3 {baseDir}/scripts/tcloud_api.py \ advisor advisor.tencentcloudapi.com \ DescribeArchList 2020-07-21 \ '{"PageNumber":1,"PageSize":10}') # 第二步：从结果中提取 ArchId（假设取第一个） ARCH_ID=$(echo "$RESULT" | python3 -c " import sys, json data = json.load(sys.stdin) arch_list = data.get('data', {}).get('ArchList', []) if arch_list: print(arch_list[0]['ArchId']) ") # 第三步：为该架构图生成免密登录链接 LOGIN_RESULT=$(python3 {baseDir}/scripts/login_url.py \ "https://console.cloud.tencent.com/advisor?archId=${ARCH_ID}") # 第四步：提取 loginUrl 并以超链接形式展示 LOGIN_URL=$(echo "$LOGIN_RESULT" | python3 -c " import sys, json data = json.load(sys.stdin) print(data.get('data', {}).get('loginUrl', '')) ") # 输出给用户（Markdown 超链接格式，注意：每次都必须重新生成链接） echo "[跳转控制台](${LOGIN_URL})" ``` ### 5.7 实现原理 1. **STS AssumeRole** — 使用 AK/SK 调用 STS 服务扮演指定角色，获取临时凭证（TmpSecretId、TmpSecretKey、Token） 2. **签名生成** — 使用临时密钥对登录回调 URL 进行 HMAC-SHA256 签名 3. **拼接 URL** — 将临时凭证、签名、目标页面 URL 拼接为免密登录链接 4. 用户点击链接 → 腾讯云验证签名 → 自动登录 → 跳转到目标页面 --- ## 六、统一输出格式 所有接口调用的输出均为统一的 JSON 格式，通过 `success` 字段区分成功与失败。 ### 成功响应 ```json { "success": true, "action": "DescribeArchList", "data": { ... }, "requestId": "9cbe807c-..." } ``` | 字段 | 类型 | 说明 | |------|------|------| | `success` | Boolean | 固定为 `true` | | `action` | String | 调用的接口名称 | | `data` | Object | 接口返回的业务数据（已去除 RequestId） | | `requestId` | String | 腾讯云请求 ID，用于问题排查 | ### 失败响应 ```json { "success": false, "action": "DescribeArchList", "error": { "code": "AuthFailure.SecretIdNotFound", "message": "The SecretId is not found, please ensure that your SecretId is correct." }, "requestId": "ed93f3cb-..." } ``` | 字段 | 类型 | 说明 | |------|------|------| | `success` | Boolean | 固定为 `false` | | `action` | String | 调用的接口名称 | | `error.code` | String | 错误码 | | `error.message` | String | 错误描述 | | `requestId` | String | 腾讯云请求 ID（网络错误时为空） | ### 特殊错误码（脚本层面） | 错误码 | 含义 | |--------|------| | `MissingParameter` | 脚本调用缺少必要参数 | | `MissingCredentials` | 未配置 AK/SK 环境变量 | | `NetworkError` | 网络请求失败，无法连接 API | | `ParseError` | 响应不是有效的 JSON | ### 常见 API 错误码 | 错误码 | 含义 | 解决方案 | |--------|------|----------| | `AuthFailure.SecretIdNotFound` | SecretId 不存在 | 检查 TENCENTCLOUD_SECRET_ID | | `AuthFailure.SignatureFailure` | 签名错误 | 检查 TENCENTCLOUD_SECRET_KEY | | `AuthFailure.SignatureExpire` | 签名过期 | 检查本地时间是否准确 | | `AuthFailure.TokenFailure` | Token 错误 | 检查 TENCENTCLOUD_TOKEN | | `AuthFailure.UnauthorizedOperation` | 未授权 | 检查 CAM 策略 | | `ResourceNotFound` | 资源不存在 | 检查 ArchId 等参数是否正确 | | `InvalidParameter` | 参数错误 | 检查请求参数格式和类型 | | `InvalidParameterValue` | 参数取值错误 | 检查参数值范围 | | `RequestLimitExceeded` | 频率限制 | 降低调用频率（限 20 次/秒） | --- ## 七、注意事项 1. **密钥安全**：严禁将 AK/SK 硬编码在代码中，必须通过环境变量传入 2. **权限控制**：建议使用子账号密钥，角色关联 `QcloudTAGFullAccess` 和 `QcloudAdvisorFullAccess` 策略 3. **临时密钥**：生产环境推荐使用 STS 临时密钥，设置 `TENCENTCLOUD_TOKEN` 4. **频率限制**：所有接口限制 20 次/秒（维度：API + 接入地域 + 子账号） 5. **地域选择**：默认 `ap-guangzhou`，Region 为可选参数 6. **跨平台支持**：所有脚本均使用纯 Python 实现，支持 Windows / Linux / macOS，无需 curl、openssl、jq 等外部依赖 7. **免密链接有效期**：默认 1 小时（3600 秒），可通过 `TENCENTCLOUD_STS_DURATION` 调整（最大 43200 秒，即 12 小时） 8. **架构图免密链接**：当返回结果包含架构图时，只需为**第一张架构图**生成免密登录控制台链接，并以 `[跳转控制台](免密登录URL)` 超链接形式展示，严禁直接展示完整 URL。**每次展示都必须重新调用 `login_url.py` 生成新链接，不可缓存或复用之前生成的链接** 9. **评估项控制台链接**：评估项不需要免密登录链接，直接展示控制台 URL：`https://console.cloud.tencent.com/advisor/assess?strategyName={URL编码后的Name}` 10. **按需加载接口文档**：使用某个接口前，必须先通过 `read_file` 加载 `{baseDir}/references/<Action>.md` 获取完整的参数说明、返回字段、展示格式等详细信息 --- ## 八、安全与权限声明 ### 8.1 所需凭证 本 Skill 需要以下环境变量才能正常运行： | 环境变量 | 必填 | 说明 | |---------|------|------| | `TENCENTCLOUD_SECRET_ID` | **是** | 腾讯云 API SecretId | | `TENCENTCLOUD_SECRET_KEY` | **是** | 腾讯云 API SecretKey | 密钥仅通过环境变量读取，**不会**被写入文件、日志或网络传输中。 ### 8.2 IAM 操作声明 本 Skill 包含以下 CAM（访问管理）操作。**写入类操作仅由独立脚本 `scripts/create_role.py` 执行，且必须在用户明确同意后才会运行**。`check_env.py` 仅执行只读检测操作。 | API 操作 | 类型 | 所在脚本 | 说明 | |---------|------|---------|------| | `sts:GetCallerIdentity` | 只读 | `check_env.py` / `create_role.py` | 获取当前账号 UIN | | `cam:GetRole` | 只读 | `check_env.py` / `create_role.py` | 检查角色是否存在 | | `cam:DescribeRoleList` | 只读 | `setup_role.py` | 列出可用角色供用户选择 | | `cam:CreateRole` | **写入** | `scripts/create_role.py` | 创建 `advisor` 角色（需用户明确同意后执行） | | `cam:AttachRolePolicy` | **写入** | `scripts/create_role.py` | 关联 `QcloudAdvisorFullAccess` 策略（随角色创建执行） | | `sts:AssumeRole` | 敏感 | `login_url.py` | 扮演角色获取临时凭证（用于生成免密登录链接） | | `cam:DeleteRole` | **写入** | `scripts/cleanup.py` | 删除 `advisor` 角色（仅 `--cloud` 模式，需用户明确确认） | ### 8.3 数据安全 - **临时凭证**：STS AssumeRole 获取的临时凭证仅在内存中使用，不持久化存储 - **配置文件**：`~/.tsa-risk/config.json` 仅保存角色 ARN 和账号 UIN，**不保存任何密钥** - **文件权限**：配置目录设为 `700`，配置文件设为 `600`，仅当前用户可读写 - **SSL 验证**：所有 HTTPS 请求均启用完整的 SSL 证书验证，不支持跳过验证 - **网络访问**：仅连接腾讯云官方 API 域名（`*.tencentcloudapi.com`）和登录域名（`cloud.tencent.com`） ### 8.4 配置清理 用户可随时运行清理脚本删除本机上的所有配置和缓存： ```bash # 交互式清理（逐项确认） python3 {baseDir}/scripts/cleanup.py # 一键清理所有本地配置 python3 {baseDir}/scripts/cleanup.py --all # 一键清理所有本地配置 + 云端 advisor 角色 python3 {baseDir}/scripts/cleanup.py --all --cloud ``` 清理范围： - 配置目录 `~/.tsa-risk/`（含 `config.json`） - 临时缓存 `{系统临时目录}/.tcloud_advisor_uin_cache` - 环境变量 `TENCENTCLOUD_*` 系列（`SECRET_ID`、`SECRET_KEY`、`TOKEN`、`ROLE_ARN`、`ROLE_NAME`、`ROLE_SESSION`、`STS_DURATION`），脚本会自动检测已设置的变量并生成对应平台的清理命令（`source` 脚本 / PowerShell 脚本） - 云端 CAM 角色 `advisor`（仅 `--cloud` 模式，需配置 AK/SK）

path: _meta.json
content: ownerId: kn7em5btzdk0xmgqmng5g51wmh83746e
slug: tsa
version: 1.5.0
publishedAt: 1774422698251

path: check_env.py
content: #!/usr/bin/env python3
"""
腾讯云智能顾问环境检测脚本（只读，不修改任何配置）

功能：检测 Python 版本、Skill 版本更新、密钥、角色配置状态，输出检测结果
注意：本脚本仅做检测，不会创建角色或修改任何配置（发现已有角色时保存配置除外）

用法:
    python3 check_env.py           # 标准模式：输出详细检测结果
    python3 check_env.py --quiet   # 静默模式：仅输出错误信息（供其他脚本调用）
    python3 check_env.py --skip-update  # 跳过版本更新检查

返回码:
    0 - 环境就绪（密钥 + 角色全部正常）
    1 - Python 版本不满足
    2 - AK/SK 未配置或无效
    3 - 角色未配置（需要执行角色创建步骤）
    4 - Skill 版本过旧，需要更新

跨平台支持: Windows / Linux / macOS
"""

import json
import os
import platform
import stat
import sys
import urllib.request
import urllib.error
from datetime import datetime, timezone
from pathlib import Path

# 将 scripts 目录加入搜索路径
SCRIPT_DIR = Path(__file__).resolve().parent
SCRIPTS_DIR = SCRIPT_DIR / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))

from tcloud_api import call_api  # noqa: E402


# ============== 配置 ==============
CONFIG_DIR = Path.home() / ".tsa-risk"
CONFIG_FILE = CONFIG_DIR / "config.json"
ADVISOR_ROLE_NAME = "advisor"

# 版本检查配置
META_FILE = SCRIPT_DIR / "_meta.json"
VERSION_CHECK_TIMEOUT = 15  # 秒


# ============== 输出函数 ==============
QUIET_MODE = "--quiet" in sys.argv
SKIP_UPDATE = "--skip-update" in sys.argv


def log_info(msg: str):
    if not QUIET_MODE:
        print(msg)


def log_ok(msg: str):
    if not QUIET_MODE:
        print(f"  [OK] {msg}")


def log_warn(msg: str):
    if not QUIET_MODE:
        print(f"  [WARN] {msg}")


def log_fail(msg: str):
    print(f"  [FAIL] {msg}")


def log_section(title: str):
    if not QUIET_MODE:
        print(f"\n=== {title} ===")


def save_config(account_uin: str, role_name: str, role_arn: str,
                auto_created: bool = False, role_id: str = ""):
    """保存配置文件（跨平台兼容）"""
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)

    # 设置目录权限（非 Windows）
    if platform.system() != "Windows":
        try:
            os.chmod(str(CONFIG_DIR), stat.S_IRWXU)  # 700
        except OSError:
            pass

    config = {
        "accountUin": account_uin,
        "roleName": role_name,
        "roleArn": role_arn,
        "configuredAt": datetime.now(timezone.utc).isoformat(),
        "autoCreated": auto_created,
        "version": "1.0",
    }
    if role_id:
        config["roleId"] = role_id

    CONFIG_FILE.write_text(json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8")

    # 设置文件权限（非 Windows）
    if platform.system() != "Windows":
        try:
            os.chmod(str(CONFIG_FILE), stat.S_IRUSR | stat.S_IWUSR)  # 600
        except OSError:
            pass


def parse_version(version_str: str) -> tuple:
    """解析语义化版本号字符串为可比较的元组，如 '1.3.0' -> (1, 3, 0)"""
    try:
        parts = version_str.strip().lstrip("v").split(".")
        return tuple(int(p) for p in parts)
    except (ValueError, AttributeError):
        return (0, 0, 0)


def get_local_version() -> tuple:
    """读取本地 _meta.json 中的版本号，返回 (slug, version_str) 或 (None, None)"""
    if not META_FILE.exists():
        return None, None
    try:
        meta = json.loads(META_FILE.read_text(encoding="utf-8"))
        return meta.get("slug"), meta.get("version")
    except (json.JSONDecodeError, IOError):
        return None, None


def get_remote_version(slug: str) -> str | None:
    """
    从 ClawHub registry 查询指定 slug 的最新版本号，返回版本字符串或 None。

    使用 `clawhub inspect <slug> --json` 命令获取远端版本信息。
    """
    import subprocess
    try:
        result = subprocess.run(
            ["clawhub", "inspect", slug, "--json"],
            capture_output=True, text=True, timeout=VERSION_CHECK_TIMEOUT,
        )
        if result.returncode != 0:
            return None
        data = json.loads(result.stdout)
        return data.get("latestVersion", {}).get("version")
    except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError, KeyError):
        return None


def check_version_update() -> dict:
    """
    检查本地版本与远端版本是否一致。

    返回 dict:
      - status: "up_to_date" | "update_available" | "check_failed" | "no_meta"
      - local_version: 本地版本号（str）
      - remote_version: 远端版本号（str 或 None）
      - slug: skill 标识符
      - message: 可读的状态说明
    """
    slug, local_ver = get_local_version()
    if not slug or not local_ver:
        return {
            "status": "no_meta",
            "local_version": None,
            "remote_version": None,
            "slug": slug,
            "message": "未找到 _meta.json 或版本信息缺失",
        }

    remote_ver = get_remote_version(slug)
    if remote_ver is None:
        return {
            "status": "check_failed",
            "local_version": local_ver,
            "remote_version": None,
            "slug": slug,
            "message": "无法获取远端版本信息（网络问题或接口不可用）",
        }

    local_parsed = parse_version(local_ver)
    remote_parsed = parse_version(remote_ver)

    if remote_parsed > local_parsed:
        return {
            "status": "update_available",
            "local_version": local_ver,
            "remote_version": remote_ver,
            "slug": slug,
            "message": f"发现新版本: {local_ver} → {remote_ver}",
        }
    else:
        return {
            "status": "up_to_date",
            "local_version": local_ver,
            "remote_version": remote_ver,
            "slug": slug,
            "message": f"当前已是最新版本: {local_ver}",
        }


def main():
    # ============== 1. 检查 Python 版本 ==============
    log_section("1. 检查运行环境")

    py_ver = sys.version_info
    if py_ver < (3, 7):
        log_fail(f"Python 版本过低: {sys.version}，需要 Python 3.7+")
        sys.exit(1)

    log_ok(f"Python {py_ver.major}.{py_ver.minor}.{py_ver.micro} ({platform.system()} {platform.machine()})")

    # ============== 2. 检查 Skill 版本更新 ==============
    log_section("2. 检查 Skill 版本")

    if SKIP_UPDATE:
        log_ok("已跳过版本更新检查（--skip-update）")
    else:
        ver_result = check_version_update()
        status = ver_result["status"]

        if status == "up_to_date":
            log_ok(ver_result["message"])
        elif status == "update_available":
            log_warn(ver_result["message"])
            log_info("")
            log_info(f"  当前版本: {ver_result['local_version']}")
            log_info(f"  最新版本: {ver_result['remote_version']}")
            log_info("")
            log_info("  请前往 SkillHub 或 ClawHub 更新此 Skill 以获取最新功能和修复")
            log_info("")
            log_fail(f"Skill 版本过旧（{ver_result['local_version']} < {ver_result['remote_version']}），请先更新后再使用")
            sys.exit(4)
        elif status == "check_failed":
            log_warn(ver_result["message"])
            log_info("  版本检查跳过，继续后续检测...")
        elif status == "no_meta":
            log_warn(ver_result["message"])
            log_info("  版本检查跳过，继续后续检测...")

    # ============== 3. 检查 AK/SK 配置 ==============
    log_section("3. 检查 AK/SK 配置")

    secret_id = os.environ.get("TENCENTCLOUD_SECRET_ID", "")
    secret_key = os.environ.get("TENCENTCLOUD_SECRET_KEY", "")

    if not secret_id or not secret_key:
        missing = []
        if not secret_id:
            missing.append("TENCENTCLOUD_SECRET_ID")
        if not secret_key:
            missing.append("TENCENTCLOUD_SECRET_KEY")
        log_fail(f"未配置以下环境变量: {', '.join(missing)}")
        log_info("")
        log_info("  请将腾讯云 API 密钥永久写入 shell 配置文件：")
        log_info("")
        log_info("  Linux / macOS（写入 ~/.bashrc 或 ~/.zshrc）:")
        log_info('    echo \'export TENCENTCLOUD_SECRET_ID="your-secret-id"\' >> ~/.bashrc')
        log_info('    echo \'export TENCENTCLOUD_SECRET_KEY="your-secret-key"\' >> ~/.bashrc')
        log_info("    source ~/.bashrc")
        log_info("")
        log_info("  Windows PowerShell（写入用户级环境变量）:")
        log_info('    [Environment]::SetEnvironmentVariable("TENCENTCLOUD_SECRET_ID", "your-secret-id", "User")')
        log_info('    [Environment]::SetEnvironmentVariable("TENCENTCLOUD_SECRET_KEY", "your-secret-key", "User")')
        log_info("")
        log_info("  密钥获取地址: https://console.cloud.tencent.com/cam/capi")
        sys.exit(2)

    masked_id = f"{secret_id[:4]}****{secret_id[-4:]}" if len(secret_id) > 8 else "****"
    log_ok(f"SecretId 已配置: {masked_id}")
    log_ok("SecretKey 已配置: ****")

    [REDACTED_SECRET]"TENCENTCLOUD_TOKEN", "")
    if [REDACTED_SECRET]"临时密钥 Token 已配置")

    # ============== 4. 验证 AK/SK 有效性 ==============
    log_section("4. 验证 AK/SK 有效性")

    verify_result = call_api(
        "advisor", "advisor.tencentcloudapi.com",
        "DescribeArchList", "2020-07-21",
        {"PageNumber": 1, "PageSize": 1},
        "ap-guangzhou",
    )

    if verify_result.get("success"):
        log_ok("AK/SK 验证通过，接口调用成功")
    else:
        error_code = verify_result.get("error", {}).get("code", "Unknown")
        auth_failures = [
            "AuthFailure.SecretIdNotFound",
            "AuthFailure.SignatureFailure",
            "AuthFailure.InvalidSecretId",
        ]
        if error_code in auth_failures:
            log_fail(f"AK/SK 无效: {error_code}")
            log_info("  请检查密钥是否正确: https://console.cloud.tencent.com/cam/capi")
            sys.exit(2)
        elif error_code in ("NetworkError", "HTTPError"):
            log_fail("接口调用失败，请检查网络连接")
            sys.exit(1)
        else:
            log_ok("AK/SK 验证通过（鉴权成功）")
            if not QUIET_MODE:
                log_warn(f"接口返回业务错误: {error_code}（不影响鉴权）")

    # ============== 5. 检查角色配置状态（仅检测，不创建） ==============
    log_section("5. 检查免密登录角色配置")

    role_arn = os.environ.get("TENCENTCLOUD_ROLE_ARN", "")
    role_configured = False

    # 5.1 优先检查环境变量
    if role_arn:
        log_ok("ROLE_ARN 已通过环境变量配置")
        role_configured = True

    # 5.2 检查配置文件
    if not role_configured and CONFIG_FILE.exists():
        try:
            config = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
            saved_arn = config.get("roleArn", "")
            saved_role = config.get("roleName", "")
            if saved_arn:
                log_ok(f"角色已配置（来自配置文件）: {saved_role}")
                role_configured = True
        except (json.JSONDecodeError, IOError):
            pass

    # 5.3 检查 ROLE_NAME 环境变量
    if not role_configured:
        role_name_env = os.environ.get("TENCENTCLOUD_ROLE_NAME", "")
        if role_name_env:
            log_ok(f"ROLE_NAME 已配置: {role_name_env}")
            role_configured = True

    # 5.4 角色未配置 → 检测账号下是否存在可用角色
    if not role_configured:
        log_warn("免密登录角色未配置")
        log_info("")

        # 获取账号 UIN
        uin_result = call_api(
            "sts", "sts.tencentcloudapi.com",
            "GetCallerIdentity", "2018-08-13", {},
        )
        account_uin = str(uin_result.get("data", {}).get("AccountId", ""))

        if not account_uin or account_uin == "None":
            log_fail("无法获取账号 UIN")
            log_info("  需要创建角色用于免密登录，请先检查网络和 AK/SK 权限")
            sys.exit(3)

        log_info(f"  账号 UIN: {account_uin}")

        # 检查 advisor 角色是否已存在
        log_info(f"  检查 {ADVISOR_ROLE_NAME} 角色是否存在...")
        role_check = call_api(
            "cam", "cam.tencentcloudapi.com",
            "GetRole", "2019-01-16",
            {"RoleName": ADVISOR_ROLE_NAME},
        )

        if role_check.get("success"):
            console_login = role_check.get("data", {}).get("ConsoleLogin", 0)
            if console_login == 1:
                # 角色存在且支持控制台登录 → 保存配置
                log_ok(f"检测到已有角色 {ADVISOR_ROLE_NAME}（支持控制台登录），自动配置")
                computed_arn = f"qcs::cam::uin/{account_uin}:roleName/{ADVISOR_ROLE_NAME}"
                role_id = str(role_check.get("data", {}).get("RoleId", ""))
                save_config(account_uin, ADVISOR_ROLE_NAME, computed_arn,
                            auto_created=False, role_id=role_id)
                log_ok(f"配置已保存到 {CONFIG_FILE}")
                role_configured = True
            else:
                log_warn(f"角色 {ADVISOR_ROLE_NAME} 存在但不支持控制台登录")
                log_info("  需要创建一个支持控制台登录的角色用于免密登录链接获取")
                log_info(f"  请运行角色创建脚本: python3 {SCRIPT_DIR}/scripts/create_role.py")
                sys.exit(3)
        else:
            # 角色不存在 → 提示需要创建
            log_warn(f"未检测到 {ADVISOR_ROLE_NAME} 角色")
            log_info("")
            log_info("  免密登录功能需要一个 CAM 角色，请执行角色创建步骤：")
            log_info(f"  python3 {SCRIPT_DIR}/scripts/create_role.py")
            sys.exit(3)

    # ============== 6. 验证角色扮演（仅角色已配置时） ==============
    if role_configured:
        log_section("6. 验证角色扮演")

        # 调用 login_url.py 中的逻辑验证
        try:
            scripts_dir = SCRIPT_DIR / "scripts"
            login_url_path = scripts_dir / "login_url.py"
            if not login_url_path.exists():
                login_url_path = SCRIPT_DIR / "login_url.py"

            import subprocess
            test_result = subprocess.run(
                [sys.executable, str(login_url_path),
                 "https://console.cloud.tencent.com/advisor"],
                capture_output=True, text=True, timeout=30,
            )
            try:
                result_data = json.loads(test_result.stdout)
                if result_data.get("success"):
                    log_ok("角色扮演验证通过，免密登录功能正常")
                else:
                    err_msg = result_data.get("error", {}).get("message", "未知错误")
                    log_warn(f"角色扮演验证失败: {err_msg}")
                    log_info("  免密登录功能可能需要等待角色生效（通常几秒内）")
            except json.JSONDecodeError:
                log_warn("角色扮演验证返回格式异常")
        except Exception as e:
            log_warn(f"角色扮演验证异常: {e}")

    # ============== 检测完成 ==============
    log_info("")
    log_info("=== 检测完成 ===")
    if role_configured:
        log_ok("环境就绪，所有功能可用（API 查询 + 免密登录）")
        log_info("")
        log_info(f"  [OK] Python {py_ver.major}.{py_ver.minor} ({platform.system()})")
        log_info("  [OK] AK/SK 密钥验证通过")
        log_info("  [OK] 免密登录角色已配置")
        sys.exit(0)
    else:
        log_fail("环境检测未通过：免密登录角色未配置")
        log_info("")
        log_info("  请执行角色创建步骤完成初始化")
        sys.exit(3)


if __name__ == "__main__":
    main()


path: references/DescribeArch.md
content: # DescribeArch — 获取云架构详情

获取指定云架构图的详细信息，包括名称、SVG 数据、节点列表、绑定资源等。

## 参数

| 参数 | 必选 | 类型 | 描述 |
|------|------|------|------|
| ArchId | 是 | String | 云架构 ID，如 `arch-jcxtbe9t` |
| Username | 是 | String | 用户名，用于记录最近访问 |
| VersionId | 否 | Integer | 版本 ID，不传默认获取最新版 |
| SvgType | 否 | String | SVG 类型，默认 `3D`，可指定为 `2D` |
| WithoutDetail | 否 | Boolean | 是否不返回 detail 数据，默认 `false` |

## 调用示例

```bash
python3 {baseDir}/scripts/tcloud_api.py \
  advisor \
  advisor.tencentcloudapi.com \
  DescribeArch \
  2020-07-21 \
  '{"ArchId":"arch-jcxtbe9t","Username":"user1"}'
```

## 返回示例

```json
{
  "success": true,
  "action": "DescribeArch",
  "data": {
    "Arch": {
      "ArchId": "arch-jcxtbe9t",
      "ArchName": "架构图1",
      "FolderId": 123,
      "CreateTime": "2023-11-14 10:29:04",
      "VersionName": "版本1",
      "VersionId": 76,
      "NodeList": [
        {
          "DiagramId": "ddd",
          "NodeName": "CVM节点",
          "ProductType": "CVM",
          "ResourceList": [
            {"InstanceId": "ins-j7uxx5im", "Attributes": "aaa"}
          ]
        }
      ]
    }
  },
  "requestId": "b90a5cc7-..."
}
```

## 免密登录链接规则

返回结果包含架构图时，**必须**调用免密登录脚本为该架构图生成控制台直达链接（见 SKILL.md 第五章），架构图页面 URL 格式为：
`https://console.cloud.tencent.com/advisor?archId={ArchId}`

生成链接后，以 Markdown 超链接形式展示给用户：`[跳转控制台](免密登录URL)`，不要直接展示完整 URL。

> **⚠️ 注意**：免密登录链接**每次都必须重新生成**，不可缓存或复用之前生成的链接。每次向用户展示架构图信息时，都必须重新调用 `login_url.py` 生成新的链接。


path: references/DescribeArchList.md
content: # DescribeArchList — 获取云架构列表

分页获取云架构图列表，支持按名称、ID、关键词、文件夹、共享状态等筛选。

## 参数

| 参数 | 必选 | 类型 | 描述 |
|------|------|------|------|
| PageNumber | 是 | Integer | 页码，>0 |
| PageSize | 是 | Integer | 每页数目，>0 且 ≤100 |
| ArchId | 否 | String | 按架构图 ID 搜索 |
| ArchName | 否 | String | 按架构图名称搜索 |
| SearchKey | 否 | String | 按关键词搜索 |
| FolderId | 否 | Integer | 文件夹 ID，不传则全局搜索 |
| ShareStatus | 否 | String | 按共享状态过滤，`"true"` 或 `"false"` |
| UpdateUser | 否 | String | 按更新人过滤 |
| WithoutSvg | 否 | Boolean | 不返回 SVG 数据，默认 `false` |
| WithSvgURL | 否 | Boolean | 返回 SVG 的临时 COS URL，默认 `false` |
| OrderBy | 否 | String | 排序：`CREATE_TIME`（默认）/ `UPDATE_TIME` / `VISIT_TIME` |

## 调用示例

```bash
# 获取第一页，每页10条
python3 {baseDir}/scripts/tcloud_api.py \
  advisor \
  advisor.tencentcloudapi.com \
  DescribeArchList \
  2020-07-21 \
  '{"PageNumber":1,"PageSize":10,"WithSvgURL":true}'

# 按名称搜索
python3 {baseDir}/scripts/tcloud_api.py \
  advisor \
  advisor.tencentcloudapi.com \
  DescribeArchList \
  2020-07-21 \
  '{"PageNumber":1,"PageSize":20,"SearchKey":"生产环境"}'
```

## 返回示例

```json
{
  "success": true,
  "action": "DescribeArchList",
  "data": {
    "TotalCount": 916,
    "ArchList": [
      {
        "ArchId": "arch-xxxxxxx",
        "ArchName": "示例架构图",
        "CreateTime": "2025-10-28 17:18:38",
        "UpdateTime": "2025-10-28 18:58:04",
        "FolderId": 1598964,
        "VersionName": "1.0.0",
        "ShareStatus": false,
        "SvgURL": "https://xxxxxxxxxxx",
        "Tags": []
      }
    ]
  },
  "requestId": "9cbe807c-..."
}
```

## 免密登录链接规则

返回结果包含架构图列表时，只需为列表中的**第一张架构图**调用免密登录脚本生成控制台直达链接（见 SKILL.md 第五章），架构图页面 URL 格式为：
`https://console.cloud.tencent.com/advisor?archId={ArchId}`

生成链接后，以 Markdown 超链接形式展示给用户：`[跳转控制台](免密登录URL)`，不要直接展示完整 URL。仅第一张架构图附带超链接，其余架构图正常展示信息即可。

> **⚠️ 注意**：免密登录链接**每次都必须重新生成**，不可缓存或复用之前生成的链接。每次向用户展示架构图列表时，都必须重新调用 `login_url.py` 生成新的链接。


path: references/DescribeLastEvaluation.md
content: # DescribeLastEvaluation — 获取架构图最近一次评估结果

获取指定架构图最近一次的 Well-Architected 评估结果，包括总分、各支柱维度得分以及治理建议。

## 参数

| 参数 | 必选 | 类型 | 描述 |
|------|------|------|------|
| ArchId | 是 | String | 云架构 ID，如 `arch-gvqocc25` |

## 调用示例

```bash
python3 {baseDir}/scripts/tcloud_api.py \
  advisor \
  advisor.tencentcloudapi.com \
  DescribeLastEvaluation \
  2020-07-21 \
  '{"ArchId":"arch-gvqocc25"}'
```

## 返回示例

```json
{
  "success": true,
  "action": "DescribeLastEvaluation",
  "data": {
    "UpdateTime": "2024-07-12 09:12:34",
    "NeedReEvaluation": false,
    "WellArchScore": {
      "TotalScore": 68,
      "PillarScore": [
        {"Category": "安全", "Value": 34},
        {"Category": "可靠", "Value": 34},
        {"Category": "性能", "Value": 34},
        {"Category": "成本", "Value": 34},
        {"Category": "服务限制", "Value": 34},
        {"Category": "卓越运营", "Value": 34}
      ]
    },
    "GovernanceAdvices": [
      {
        "AdviceCategory": "推荐治理",
        "Layer": [
          {
            "LayerCategory": "资源层",
            "Advices": [
              {
                "StrategyName": "strategy-xxx",
                "Title": "云数据库（MySQL）root 账号安全风险",
                "Pillar": "安全",
                "Explain": "检查root 账号安全风险",
                "Problems": [
                  {
                    "Title": "MySQL实例只设置了root账号",
                    "ImpactInstances": 4,
                    "Callback": {"Type": "Plugin", "Value": "PluginName", "Param": "测试参数"}
                  }
                ]
              }
            ]
          },
          {
            "LayerCategory": "架构层",
            "Advices": []
          }
        ]
      }
    ]
  },
  "requestId": "xxx-xxx-xx"
}
```

## 返回字段说明

| 字段 | 类型 | 说明 |
|------|------|------|
| `UpdateTime` | String | 最近一次评估时间 |
| `NeedReEvaluation` | Boolean | 是否需要重新评估（架构图有变更时为 `true`） |
| `WellArchScore.TotalScore` | Integer | 总评分（满分 100） |
| `WellArchScore.PillarScore[]` | Array | 各支柱维度得分 |
| `PillarScore[].Category` | String | 维度名称：安全 / 可靠 / 性能 / 成本 / 服务限制 / 卓越运营 |
| `PillarScore[].Value` | Integer | 该维度得分 |
| `GovernanceAdvices[]` | Array | 治理建议分组 |
| `GovernanceAdvices[].AdviceCategory` | String | 建议分类：推荐治理 / 安全 / 可靠 / 性能 / 成本 / 服务限制 / 卓越运营 |
| `GovernanceAdvices[].Layer[]` | Array | 按层级分组的建议 |
| `Layer[].LayerCategory` | String | 层级分类：资源层 / 架构层 |
| `Layer[].Advices[]` | Array | 具体建议列表 |
| `Advices[].StrategyName` | String | 策略标识 |
| `Advices[].Title` | String | 建议标题 |
| `Advices[].Pillar` | String | 所属支柱维度 |
| `Advices[].Explain` | String | 检查说明 |
| `Advices[].Problems[]` | Array | 发现的问题列表 |
| `Problems[].Title` | String | 问题描述 |
| `Problems[].ImpactInstances` | Integer | 影响的实例数量 |
| `Problems[].Callback` | Object | 回调信息（可选） |
| `Callback.Type` | String | 回调类型：`URL`（跳转链接）/ `Plugin`（插件） |
| `Callback.Value` | String | 回调值（URL 地址或插件名称） |
| `Callback.Param` | String | 回调参数 |

## 展示格式

向用户展示评估结果时，先展示总分和各维度得分，再按建议分类展示治理建议：

```
📊 架构评估结果（arch-gvqocc25）
评估时间：2024-07-12 09:12:34
总分：68 / 100

各维度得分：
  安全：34  |  可靠：34  |  性能：34
  成本：34  |  服务限制：34  |  卓越运营：34

【推荐治理】
  ▸ 资源层
    ⚠️ 云数据库（MySQL）root 账号安全风险（安全）
       MySQL实例只设置了root账号，影响 4 个实例

  ▸ 架构层
    （无建议）

【安全】
  ▸ 资源层
    ⚠️ 云数据库（MySQL）root 账号安全风险（安全）
       MySQL实例只设置了root账号，影响 4 个实例
```

当 `NeedReEvaluation` 为 `true` 时，应在输出中提示用户：`⚠️ 架构图已变更，建议重新评估`。

当 `Callback.Type` 为 `URL` 时，可将 `Callback.Value` 作为问题详情链接展示。

## 典型用法

- "查看架构图 arch-xxx 的评估结果" → 调用接口，展示总分和各维度得分
- "这个架构图有哪些安全问题" → 调用接口，筛选 `AdviceCategory=安全` 或 `Pillar=安全` 的建议
- "架构图需要治理什么" → 调用接口，筛选 `AdviceCategory=推荐治理` 的建议


path: references/DescribeStrategies.md
content: # DescribeStrategies — 获取风险评估项列表

查询智能顾问的所有风险评估项（巡检项），涵盖安全、可靠、费用、性能、服务限制等维度，覆盖 COS、CVM、MySQL、Redis、CBS、CAM、CLB、VPC 等云产品。

## 参数

无必填参数，直接调用即可。

## 调用示例

```bash
python3 {baseDir}/scripts/tcloud_api.py \
  advisor \
  advisor.tencentcloudapi.com \
  DescribeStrategies \
  2020-07-21 \
  '{}'
```

## 返回示例

```json
{
  "success": true,
  "action": "DescribeStrategies",
  "data": {
    "Strategies": [
      {
        "StrategyId": 131,
        "Name": "云数据库（Redis）跨可用区部署",
        "Desc": "检查 Redis 实例是否跨可用区部署...",
        "Product": "redis",
        "ProductDesc": "云数据库（Redis）",
        "Repair": "建议采用跨可用区部署方案...",
        "GroupId": 2,
        "GroupName": "可靠",
        "IsSupportCustom": false,
        "Conditions": [
          {
            "ConditionId": 178,
            "Level": 2,
            "LevelDesc": "中风险",
            "Desc": "Redis 实例未跨可用区部署"
          }
        ]
      }
    ]
  },
  "requestId": "xxxxxxxx-..."
}
```

## 返回字段说明

| 字段 | 类型 | 说明 |
|------|------|------|
| `StrategyId` | Integer | 评估项唯一 ID |
| `Name` | String | 评估项名称（用于拼接控制台链接） |
| `Desc` | String | 评估项描述 |
| `Product` | String | 产品标识（cos / cvm / mysql / redis / cbs / cam 等） |
| `ProductDesc` | String | 产品中文名 |
| `Repair` | String | 修复建议 |
| `GroupName` | String | 分组：安全 / 可靠 / 费用 / 性能 / 服务限制 |
| `Conditions[].Level` | Integer | 风险等级：3=高危 / 2=中危 / 1=低危 |
| `Conditions[].LevelDesc` | String | 等级描述 |
| `Conditions[].Desc` | String | 条件描述 |

## 筛选方式

该接口无服务端筛选参数，返回全量评估项。需在返回结果中按以下维度进行客户端筛选：

1. **按产品筛选**：用 `Product` 字段匹配（如 `cos`、`cvm`、`mysql`、`redis`）
2. **按分组筛选**：用 `GroupName` 字段匹配（`安全`、`可靠`、`费用`、`性能`、`服务限制`）
3. **按风险等级筛选**：遍历 `Conditions[]`，`Level` 为 3=高危、2=中危、1=低危

## 常见 Product 值

| Product | ProductDesc |
|---------|-------------|
| cos | 对象存储（COS） |
| cvm | 云服务器（CVM） |
| mysql | 云数据库（MySQL） |
| redis | 云数据库（Redis） |
| cbs | 云硬盘（CBS） |
| cam | 访问管理（CAM） |
| clb | 负载均衡（CLB） |
| vpc | 私有网络（VPC） |
| lighthouse | 轻量应用服务器（LH） |
| mongodb | 云数据库（MongoDB） |

## 控制台直达链接

每个评估项可通过 `Name` 字段拼接控制台跳转 URL（无需免密登录，直接展示即可）：

```
https://console.cloud.tencent.com/advisor/assess?strategyName={URL编码后的Name}
```

## 展示格式

向用户展示评估项时，按产品分组，每条包含风险等级、名称、分组和控制台链接：

```
【云数据库（Redis）】
  🔴 高危  Redis 实例未开启密码认证
  分组：安全  |  StrategyId：132
  控制台：https://console.cloud.tencent.com/advisor/assess?strategyName=Redis%20...

  🟡 中危  云数据库（Redis）跨可用区部署
  分组：可靠  |  StrategyId：131
  控制台：https://console.cloud.tencent.com/advisor/assess?strategyName=...
```

等级图标：🔴 高危（Level=3）、🟡 中危（Level=2）、🟢 低危（Level=1）

## 典型用法

- "查看 COS 的所有风险项" → 筛选 `Product=cos`，输出带控制台链接的列表
- "有哪些高危的安全风险" → 筛选 `GroupName=安全` + `Level=3`
- "智能顾问有哪些巡检项" → 直接输出全量列表（按 ProductDesc 分组）
- "给我 Redis 的巡检链接" → 筛选 `Product=redis`，每条附上控制台 URL


path: references/ListDirectoryV2.md
content: # ListDirectoryV2 — 新版目录查询

查询云架构图的目录树结构。

## 参数

| 参数 | 必选 | 类型 | 描述 |
|------|------|------|------|
| FolderId | 否 | Integer | 目录 ID |
| ShareStatus | 否 | String | 共享状态 |
| SearchKey | 否 | String | 模糊搜索关键词 |

## 调用示例

```bash
# 查询全部目录
python3 {baseDir}/scripts/tcloud_api.py \
  advisor \
  advisor.tencentcloudapi.com \
  ListDirectoryV2 \
  2020-07-21 \
  '{}'

# 按关键词搜索目录
python3 {baseDir}/scripts/tcloud_api.py \
  advisor \
  advisor.tencentcloudapi.com \
  ListDirectoryV2 \
  2020-07-21 \
  '{"SearchKey":"arch"}'
```

## 返回示例

```json
{
  "success": true,
  "action": "ListDirectoryV2",
  "data": {
    "FirstFolderId": 1,
    "Folders": [
      {
        "Id": 1,
        "Name": "folder",
        "Type": "Directory",
        "FolderTypeId": 2,
        "Children": []
      }
    ]
  },
  "requestId": "909d6e2b-..."
}
```


path: references/ListUnorganizedDirectory.md
content: # ListUnorganizedDirectory — 查询待整理目录

查询新版目录下的待整理（未归类）目录结构。

## 参数

| 参数 | 必选 | 类型 | 描述 |
|------|------|------|------|
| FolderId | 否 | Integer | 目录 ID |
| ShareStatus | 否 | String | 架构图共享状态 |
| SearchKey | 否 | String | 查询关键词 |

## 调用示例

```bash
# 查询全部待整理目录
python3 {baseDir}/scripts/tcloud_api.py \
  advisor \
  advisor.tencentcloudapi.com \
  ListUnorganizedDirectory \
  2020-07-21 \
  '{}'
```

## 返回示例

```json
{
  "success": true,
  "action": "ListUnorganizedDirectory",
  "data": {
    "Folders": [
      {
        "Id": 12,
        "Name": "folder1",
        "Type": "Directory",
        "Children": [
          {
            "Id": 13,
            "Name": "folder2",
            "Type": "Directory",
            "Children": [
              {"Id": 14, "Name": "folder4", "Type": "Directory"}
            ]
          }
        ]
      }
    ]
  },
  "requestId": "xxxx-xxxx-xxxx-xxxx"
}
```


path: scripts/cleanup.py
content: #!/usr/bin/env python3
"""
腾讯云智能顾问 - 配置清理脚本

清理本机上 tsa-risk 产生的所有配置和缓存文件，
可选删除云端通过本工具创建的 CAM 角色。

用法:
    python3 cleanup.py               # 交互式清理（逐项确认）
    python3 cleanup.py --all         # 一键清理所有本地配置（不含云端角色）
    python3 cleanup.py --all --cloud # 一键清理所有本地配置 + 云端角色

清理范围:
    1. 配置目录  ~/.tsa-risk/（含 config.json）
    2. 临时缓存  {系统临时目录}/.tcloud_advisor_uin_cache
    3. 环境变量  TENCENTCLOUD_* 系列环境变量
    4. 云端角色  CAM 角色 advisor（可选，需 AK/SK）

返回码:
    0 - 清理完成
    1 - 用户取消

跨平台支持: Windows / Linux / macOS
"""

import json
import os
import platform
import shutil
import sys
import tempfile
from pathlib import Path

# 导入 tcloud_api 模块（用于可选的云端角色删除）
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))

# ============== 配置 ==============
CONFIG_DIR = Path.home() / ".tsa-risk"
CONFIG_FILE = CONFIG_DIR / "config.json"
CACHE_FILE = Path(tempfile.gettempdir()) / ".tcloud_advisor_uin_cache"
ROLE_NAME = "advisor"

# 智能顾问使用的所有环境变量
ENV_VARS = [
    "TENCENTCLOUD_SECRET_ID",
    "TENCENTCLOUD_SECRET_KEY",
    "TENCENTCLOUD_TOKEN",
    "TENCENTCLOUD_ROLE_ARN",
    "TENCENTCLOUD_ROLE_NAME",
    "TENCENTCLOUD_ROLE_SESSION",
    "TENCENTCLOUD_STS_DURATION",
]


# ============== 终端颜色 ==============
def _supports_color() -> bool:
    if os.environ.get("NO_COLOR"):
        return False
    if platform.system() == "Windows":
        return os.environ.get("TERM") == "xterm" or hasattr(sys.stderr, "reconfigure")
    return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()


_COLOR = _supports_color()


def _c(code: str, text: str) -> str:
    return f"\033[{code}m{text}\033[0m" if _COLOR else text


def green(t: str) -> str:
    return _c("32", t)


def red(t: str) -> str:
    return _c("31", t)


def yellow(t: str) -> str:
    return _c("33", t)


def bold(t: str) -> str:
    return _c("1", t)


def dim(t: str) -> str:
    return _c("2", t)


# ============== 工具函数 ==============
def confirm(prompt: str) -> bool:
    """交互式确认，默认为 No"""
    try:
        answer = input(f"{prompt} [y/N]: ").strip().lower()
        return answer in ("y", "yes")
    except (EOFError, KeyboardInterrupt):
        print()
        return False


def read_config() -> dict:
    """读取现有配置文件"""
    if CONFIG_FILE.exists():
        try:
            return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, OSError):
            return {}
    return {}


def remove_file(path: Path, label: str) -> bool:
    """删除单个文件"""
    if not path.exists():
        print(f"  {dim('[-]')} {label}: {dim('不存在，跳过')}")
        return False
    try:
        path.unlink()
        print(f"  {green('[OK]')} {label}: 已删除")
        return True
    except OSError as e:
        print(f"  {red('[FAIL]')} {label}: 删除失败 - {e}")
        return False


def remove_dir(path: Path, label: str) -> bool:
    """删除目录及其所有内容"""
    if not path.exists():
        print(f"  {dim('[-]')} {label}: {dim('不存在，跳过')}")
        return False
    try:
        shutil.rmtree(str(path))
        print(f"  {green('[OK]')} {label}: 已删除")
        return True
    except OSError as e:
        print(f"  {red('[FAIL]')} {label}: 删除失败 - {e}")
        return False


# ============== 清理动作 ==============
def clean_config_dir(interactive: bool) -> bool:
    """清理配置目录 ~/.tsa-risk/"""
    print(f"\n{bold('1. 配置目录')}")

    if not CONFIG_DIR.exists():
        print(f"  {dim('[-]')} {CONFIG_DIR}: {dim('不存在，跳过')}")
        return False

    # 列出目录内容
    files = list(CONFIG_DIR.iterdir())
    print(f"  路径: {CONFIG_DIR}")
    if files:
        for f in files:
            size = f.stat().st_size if f.is_file() else 0
            print(f"    - {f.name} ({size} bytes)")
    else:
        print(f"    {dim('(空目录)')}")

    if interactive and not confirm(f"\n  确认删除 {CONFIG_DIR} ?"):
        print(f"  {yellow('[SKIP]')} 用户跳过")
        return False

    return remove_dir(CONFIG_DIR, str(CONFIG_DIR))


def clean_cache_file(interactive: bool) -> bool:
    """清理临时缓存文件"""
    print(f"\n{bold('2. 临时缓存')}")

    if not CACHE_FILE.exists():
        print(f"  {dim('[-]')} {CACHE_FILE}: {dim('不存在，跳过')}")
        return False

    print(f"  路径: {CACHE_FILE}")
    try:
        size = CACHE_FILE.stat().st_size
        print(f"    - .tcloud_advisor_uin_cache ({size} bytes)")
    except OSError:
        pass

    if interactive and not confirm(f"\n  确认删除 {CACHE_FILE} ?"):
        print(f"  {yellow('[SKIP]')} 用户跳过")
        return False

    return remove_file(CACHE_FILE, str(CACHE_FILE))


def clean_env_vars(interactive: bool) -> bool:
    """清理智能顾问相关环境变量"""
    print(f"\n{bold('4. 环境变量')}")

    # 检测哪些环境变量当前已设置
    found = {}
    for var in ENV_VARS:
        val = os.environ.get(var, "")
        if val:
            found[var] = val

    if not found:
        print(f"  {dim('[-]')} 未检测到已设置的 TENCENTCLOUD_* 环境变量，跳过")
        return False

    # 显示已设置的变量（敏感值掩码）
    print(f"  检测到 {len(found)} 个已设置的环境变量:")
    for var, val in found.items():
        if "SECRET" in var or "TOKEN" in var or "KEY" in var:
            display = val[:4] + "****" + val[-4:] if len(val) > 8 else "****"
        else:
            display = val
        print(f"    - {var} = {display}")

    if interactive and not confirm(f"\n  确认清理以上 {len(found)} 个环境变量?"):
        print(f"  {yellow('[SKIP]')} 用户跳过")
        return False

    # 生成清理命令
    is_windows = platform.system() == "Windows"
    print()

    if is_windows:
        # Windows: 生成 PowerShell 脚本
        script_file = Path(tempfile.gettempdir()) / "cleanup_tencent_env.ps1"
        lines = ["# 腾讯云智能顾问 - 环境变量清理脚本 (PowerShell)",
                 "# 自动生成，执行后可删除此文件", ""]
        for var in found:
            lines.append(f'Remove-Item Env:\\{var} -ErrorAction SilentlyContinue')
            lines.append(f'Write-Host "  [OK] 已清除 {var}"')
        lines.append("")
        lines.append('Write-Host ""')
        lines.append(f'Write-Host "  已清理 {len(found)} 个环境变量（仅影响当前会话）"')

        script_file.write_text("\n".join(lines), encoding="utf-8")
        print(f"  已生成 PowerShell 清理脚本: {script_file}")
        print(f"  请在 PowerShell 中执行:")
        print(f"    . {script_file}")

    else:
        # Linux / macOS: 生成 source 脚本
        script_file = Path(tempfile.gettempdir()) / "cleanup_tencent_env.sh"
        lines = ["#!/bin/sh",
                 "# 腾讯云智能顾问 - 环境变量清理脚本",
                 "# 自动生成，执行后可删除此文件", ""]
        for var in found:
            lines.append(f'unset {var}')
            lines.append(f'echo "  [OK] 已清除 {var}"')
        lines.append("")
        lines.append('echo ""')
        lines.append(f'echo "  已清理 {len(found)} 个环境变量（仅影响当前会话）"')

        script_file.write_text("\n".join(lines), encoding="utf-8")
        try:
            os.chmod(str(script_file), 0o755)
        except OSError:
            pass
        print(f"  已生成清理脚本: {script_file}")
        print(f"  请在当前终端执行:")
        print(f"    source {script_file}")

    print()
    print(f"  {yellow('注意')}: 环境变量存在于 shell 进程中，Python 脚本无法直接修改父 shell。")
    print(f"         请执行上方命令完成清理（仅影响当前终端会话）。")
    print(f"         如果变量写在 ~/.bashrc、~/.zshrc 或系统环境中，请手动移除对应行。")

    return True


def clean_cloud_role(interactive: bool) -> bool:
    """删除云端 CAM 角色（可选）"""
    print(f"\n{bold('3. 云端 CAM 角色')}")

    secret_id = os.environ.get("TENCENTCLOUD_SECRET_ID", "")
    secret_key = os.environ.get("TENCENTCLOUD_SECRET_KEY", "")

    if not secret_id or not secret_key:
        print(f"  {dim('[-]')} 未配置 AK/SK 环境变量，跳过云端角色清理")
        print(f"      如需删除云端角色，请设置 TENCENTCLOUD_SECRET_ID 和 TENCENTCLOUD_SECRET_KEY 后重试")
        return False

    # 加载 tcloud_api
    try:
        from tcloud_api import call_api
    except ImportError:
        print(f"  {red('[FAIL]')} 无法加载 tcloud_api 模块")
        return False

    # 检查角色是否存在
    print(f"  正在检查角色 {ROLE_NAME} ...")
    check_result = call_api(
        "cam", "cam.tencentcloudapi.com",
        "GetRole", "2019-01-16",
        {"RoleName": ROLE_NAME},
    )

    if not check_result.get("success"):
        err_code = check_result.get("error", {}).get("code", "")
        if "NotFound" in err_code or "not exist" in check_result.get("error", {}).get("message", "").lower():
            print(f"  {dim('[-]')} 角色 {ROLE_NAME} 不存在，无需删除")
            return False
        else:
            print(f"  {red('[FAIL]')} 查询角色失败: {check_result.get('error', {}).get('message', '未知错误')}")
            return False

    role_data = check_result.get("data", {})
    role_id = role_data.get("RoleId", "unknown")
    description = role_data.get("Description", "无描述")
    print(f"  检测到角色:")
    print(f"    名称: {ROLE_NAME}")
    print(f"    ID:   {role_id}")
    print(f"    描述: {description}")

    if interactive and not confirm(f"\n  确认删除云端角色 {ROLE_NAME} ? (此操作不可恢复)"):
        print(f"  {yellow('[SKIP]')} 用户跳过")
        return False

    # 执行删除
    print(f"  正在删除角色 {ROLE_NAME} ...")
    delete_result = call_api(
        "cam", "cam.tencentcloudapi.com",
        "DeleteRole", "2019-01-16",
        {"RoleName": ROLE_NAME},
    )

    if delete_result.get("success"):
        print(f"  {green('[OK]')} 云端角色 {ROLE_NAME} 已删除")
        return True
    else:
        err_msg = delete_result.get("error", {}).get("message", "未知错误")
        print(f"  {red('[FAIL]')} 删除角色失败: {err_msg}")
        return False


# ============== 主流程 ==============
def main():
    args = set(sys.argv[1:])

    # 帮助信息
    if args & {"-h", "--help"}:
        print(__doc__)
        sys.exit(0)

    auto_mode = "--all" in args
    include_cloud = "--cloud" in args

    # 标题
    print(f"\n{'=' * 58}")
    print(f"  腾讯云智能顾问 - 配置清理")
    print(f"{'=' * 58}")

    # 显示当前配置摘要
    config = read_config()
    if config:
        print(f"\n当前配置:")
        print(f"  账号 UIN:  {config.get('accountUin', '未知')}")
        print(f"  角色名称:  {config.get('roleName', '未知')}")
        print(f"  角色 ARN:  {config.get('roleArn', '未知')}")
        print(f"  配置时间:  {config.get('configuredAt', '未知')}")

    interactive = not auto_mode

    if interactive:
        print(f"\n{dim('提示: 使用 --all 跳过确认，--cloud 同时删除云端角色')}")

    # 执行清理（注意：云端角色删除必须在环境变量清理之前，因为需要 AK/SK）
    results = []
    results.append(("配置目录", clean_config_dir(interactive)))
    results.append(("临时缓存", clean_cache_file(interactive)))

    if include_cloud:
        results.append(("云端角色", clean_cloud_role(interactive)))
    else:
        print(f"\n{bold('3. 云端 CAM 角色')}")
        print(f"  {dim('[-]')} 未指定 --cloud 参数，跳过云端角色清理")
        print(f"      如需同时删除云端角色，请添加 --cloud 参数")

    # 环境变量清理放在最后（云端角色删除可能需要用到 AK/SK）
    results.append(("环境变量", clean_env_vars(interactive)))

    # 汇总
    cleaned = sum(1 for _, ok in results if ok)
    total = len(results)

    print(f"\n{'=' * 58}")
    print(f"  清理完成: {cleaned}/{total} 项已清理")
    print(f"{'=' * 58}\n")

    sys.exit(0)


if __name__ == "__main__":
    main()


path: scripts/create_role.py
content: #!/usr/bin/env python3
"""
腾讯云智能顾问 — CAM 角色创建脚本 (Python 版)

功能：创建 advisor 角色并关联 QcloudTAGFullAccess、QcloudAdvisorFullAccess 策略
注意：本脚本包含 IAM 写入操作（CreateRole、AttachRolePolicy），
      仅应在用户明确同意后执行，不可自动运行

用法:
    python3 create_role.py                  # 自动获取账号 UIN
    python3 create_role.py --uin 100001234  # 手动指定账号 UIN

返回码:
    0 - 角色创建成功，配置已保存
    1 - 参数错误
    2 - AK/SK 未配置或无效
    3 - 角色创建失败

输出: JSON 格式结果（供 AI 解析）

跨平台支持: Windows / Linux / macOS
"""

import json
import os
import platform
import stat
import sys
from datetime import datetime, timezone
from pathlib import Path

# 导入 tcloud_api 模块
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))

from tcloud_api import call_api  # noqa: E402

# ============== 配置 ==============
CONFIG_DIR = Path.home() / ".tsa-risk"
CONFIG_FILE = CONFIG_DIR / "config.json"
ROLE_NAME = "advisor"
POLICY_NAMES = ["QcloudTAGFullAccess", "QcloudAdvisorFullAccess"]


def output_json(obj: dict) -> str:
    return json.dumps(obj, indent=2, ensure_ascii=False)


def output_success(role_arn: str, account_uin: str, role_id: str = "unknown") -> dict:
    return {
        "success": True,
        "action": "CreateAdvisorRole",
        "data": {
            "roleName": ROLE_NAME,
            "roleArn": role_arn,
            "roleId": role_id,
            "accountUin": account_uin,
            "policiesAttached": POLICY_NAMES,
            "consoleLogin": True,
            "configFile": str(CONFIG_FILE),
        },
    }


def output_error(code: str, message: str) -> dict:
    return {
        "success": False,
        "action": "CreateAdvisorRole",
        "error": {"code": code, "message": message},
    }


def save_config(account_uin: str, role_arn: str, role_id: str = "",
                auto_created: bool = True):
    """保存配置到文件"""
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)

    if platform.system() != "Windows":
        try:
            os.chmod(str(CONFIG_DIR), stat.S_IRWXU)  # 700
        except OSError:
            pass

    config = {
        "accountUin": account_uin,
        "roleName": ROLE_NAME,
        "roleArn": role_arn,
        "roleId": role_id,
        "configuredAt": datetime.now(timezone.utc).isoformat(),
        "autoCreated": auto_created,
        "version": "1.0",
    }

    CONFIG_FILE.write_text(
        json.dumps(config, indent=2, ensure_ascii=False),
        encoding="utf-8",
    )

    if platform.system() != "Windows":
        try:
            os.chmod(str(CONFIG_FILE), stat.S_IRUSR | stat.S_IWUSR)  # 600
        except OSError:
            pass


def main():
    # ============== 参数解析 ==============
    account_uin = ""
    args = sys.argv[1:]
    i = 0
    while i < len(args):
        if args[i] == "--uin" and i + 1 < len(args):
            account_uin = args[i + 1]
            i += 2
        else:
            print(output_json(output_error("InvalidParameter", f"未知参数: {args[i]}")))
            sys.exit(1)

    # ============== 1. 检查 AK/SK ==============
    secret_id = os.environ.get("TENCENTCLOUD_SECRET_ID", "")
    secret_key = os.environ.get("TENCENTCLOUD_SECRET_KEY", "")

    if not secret_id or not secret_key:
        print(output_json(output_error(
            "MissingCredentials",
            "未配置 TENCENTCLOUD_SECRET_ID 或 TENCENTCLOUD_SECRET_KEY"
        )))
        sys.exit(2)

    # ============== 2. 获取账号 UIN ==============
    if not account_uin:
        uin_result = call_api(
            "sts", "sts.tencentcloudapi.com",
            "GetCallerIdentity", "2018-08-13", {},
        )
        account_uin = str(uin_result.get("data", {}).get("AccountId", ""))

        if not account_uin or account_uin in ("", "None", "null"):
            error_msg = uin_result.get("error", {}).get("message", "无法获取账号 UIN")
            print(output_json(output_error("GetCallerIdentityFailed", error_msg)))
            sys.exit(2)

    # ============== 3. 检查角色是否已存在 ==============
    role_check = call_api(
        "cam", "cam.tencentcloudapi.com",
        "GetRole", "2019-01-16",
        {"RoleName": ROLE_NAME},
    )

    if role_check.get("success"):
        console_login = role_check.get("data", {}).get("ConsoleLogin", 0)
        role_id = str(role_check.get("data", {}).get("RoleId", "unknown"))
        role_arn = f"qcs::cam::uin/{account_uin}:roleName/{ROLE_NAME}"

        if console_login == 1:
            # 角色已存在且支持控制台登录 → 直接保存配置
            save_config(account_uin, role_arn, role_id, auto_created=False)
            print(output_json(output_success(role_arn, account_uin, role_id)))
            sys.exit(0)
        else:
            print(output_json(output_error(
                "RoleExistsNoConsoleLogin",
                f"角色 {ROLE_NAME} 已存在但未启用控制台登录（ConsoleLogin=0），"
                "请到 CAM 控制台手动启用或删除后重新创建: "
                "https://console.cloud.tencent.com/cam/role"
            )))
            sys.exit(3)

    # ============== 4. 创建角色 ==============
    trust_policy = json.dumps({
        "version": "2.0",
        "statement": [{
            "action": "name/sts:AssumeRole",
            "effect": "allow",
            "principal": {
                "qcs": [f"qcs::cam::uin/{account_uin}:root"]
            }
        }]
    }, separators=(",", ":"))

    create_result = call_api(
        "cam", "cam.tencentcloudapi.com",
        "CreateRole", "2019-01-16",
        {
            "RoleName": ROLE_NAME,
            "PolicyDocument": trust_policy,
            "ConsoleLogin": 1,
            "Description": "腾讯云智能顾问助手角色（由 tsa-risk skill 创建）",
        },
    )

    if not create_result.get("success"):
        err = create_result.get("error", {})
        print(output_json(output_error(
            err.get("code", "CreateRoleFailed"),
            f"角色创建失败: {err.get('message', '未知错误')}"
        )))
        sys.exit(3)

    role_id = str(create_result.get("data", {}).get("RoleId", "unknown"))

    # ============== 5. 关联策略 ==============
    attach_warnings = []
    for policy_name in POLICY_NAMES:
        attach_result = call_api(
            "cam", "cam.tencentcloudapi.com",
            "AttachRolePolicy", "2019-01-16",
            {"AttachRoleName": ROLE_NAME, "PolicyName": policy_name},
        )
        if not attach_result.get("success"):
            err_msg = attach_result.get("error", {}).get("message", "未知错误")
            attach_warnings.append(f"策略 {policy_name} 关联失败: {err_msg}")
            print(f"WARNING: {attach_warnings[-1]}", file=sys.stderr)

    # ============== 6. 保存配置 ==============
    role_arn = f"qcs::cam::uin/{account_uin}:roleName/{ROLE_NAME}"
    save_config(account_uin, role_arn, role_id, auto_created=True)

    # ============== 7. 输出结果 ==============
    result = output_success(role_arn, account_uin, role_id)
    if attach_warnings:
        result["data"]["warnings"] = attach_warnings
    print(output_json(result))
    sys.exit(0)


if __name__ == "__main__":
    main()


path: scripts/login_url.py
content: #!/usr/bin/env python3
"""
腾讯云控制台免密登录链接生成脚本 (Python 版)
通过 STS AssumeRole 获取临时凭证，生成免密登录 URL

用法:
    python3 login_url.py <target_url>

示例:
    python3 login_url.py "https://console.cloud.tencent.com/advisor?archId=arch-gvqocc25"
    python3 login_url.py "https://console.cloud.tencent.com/advisor"

环境变量（必须提前设置）:
    TENCENTCLOUD_SECRET_ID   - 腾讯云 SecretId（必填）
    TENCENTCLOUD_SECRET_KEY  - 腾讯云 SecretKey（必填）
    TENCENTCLOUD_ROLE_ARN    - CAM 角色 ARN（可选）
    TENCENTCLOUD_ROLE_NAME   - CAM 角色名称（可选），系统会自动拼接完整 ARN

配置优先级:
    1. 环境变量 TENCENTCLOUD_ROLE_ARN（完整 ARN）
    2. 配置文件 ~/.tsa-risk/config.json
    3. 环境变量 TENCENTCLOUD_ROLE_NAME + 自动获取账号 UIN

可选环境变量:
    TENCENTCLOUD_ROLE_SESSION - 角色会话名称（默认 advisor-session）
    TENCENTCLOUD_STS_DURATION - 临时凭证有效期秒数（默认 3600，最大 43200）

输出格式（统一 JSON）:
    成功: {"success": true, "action": "GenerateLoginURL", "data": {"loginUrl": "...", "targetUrl": "...", "expireSeconds": 3600}, "requestId": "xxx"}
    失败: {"success": false, "action": "GenerateLoginURL", "error": {"code": "xxx", "message": "xxx"}, "requestId": ""}
"""

import hashlib
import hmac
import base64
import json
import os
import random
import ssl
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import quote
from urllib.request import Request, urlopen

# 导入 tcloud_api 模块
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
from tcloud_api import call_api  # noqa: E402

ACTION_NAME = "GenerateLoginURL"


def output_error(code: str, message: str, request_id: str = "") -> str:
    """输出统一的错误 JSON"""
    return json.dumps({
        "success": False,
        "action": ACTION_NAME,
        "error": {"code": code, "message": message},
        "requestId": request_id,
    }, ensure_ascii=False)


def output_success(login_url: str, target_url: str, expire_seconds: int, request_id: str) -> str:
    """输出统一的成功 JSON"""
    return json.dumps({
        "success": True,
        "action": ACTION_NAME,
        "data": {
            "loginUrl": login_url,
            "targetUrl": target_url,
            "expireSeconds": expire_seconds,
        },
        "requestId": request_id,
    }, ensure_ascii=False)


def _get_ssl_context():
    """获取 SSL 上下文，兼容 macOS 缺少系统 CA 证书的情况"""
    try:
        import certifi
        return ssl.create_default_context(cafile=certifi.where())
    except ImportError:
        ctx = ssl.create_default_context()
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
        return ctx


def _sign_tc3(key: bytes, msg: str) -> bytes:
    """TC3 HMAC-SHA256 签名辅助函数"""
    return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()


def _get_role_arn(secret_id: str, secret_key: str) -> str:
    """
    按优先级获取 RoleArn:
    1. 环境变量 TENCENTCLOUD_ROLE_ARN
    2. 配置文件 ~/.tsa-risk/config.json
    3. 环境变量 TENCENTCLOUD_ROLE_NAME + 自动获取账号 UIN
    """
    # 优先级 1: 环境变量 ROLE_ARN
    role_arn = os.environ.get("TENCENTCLOUD_ROLE_ARN", "")
    if role_arn:
        return role_arn

    # 优先级 2: 配置文件
    config_file = Path.home() / ".tsa-risk" / "config.json"
    if config_file.exists():
        try:
            config = json.loads(config_file.read_text())
            role_arn = config.get("roleArn", "")
            if role_arn:
                return role_arn
        except (json.JSONDecodeError, IOError):
            pass

    # 优先级 3: ROLE_NAME + 自动获取 UIN
    role_name = os.environ.get("TENCENTCLOUD_ROLE_NAME", "")
    if role_name:
        # 尝试 UIN 缓存（跨平台：Windows 用 TEMP，其他用 /tmp）
        import tempfile
        cache_dir = Path(tempfile.gettempdir())
        uin_cache_file = cache_dir / ".tcloud_advisor_uin_cache"
        account_uin = ""

        if uin_cache_file.exists():
            cache_age = time.time() - uin_cache_file.stat().st_mtime
            if cache_age < 86400:  # 24 小时
                account_uin = uin_cache_file.read_text().strip()

        if not account_uin:
            # 调用 tcloud_api 模块获取 UIN
            try:
                result = call_api(
                    "sts", "sts.tencentcloudapi.com",
                    "GetCallerIdentity", "2018-08-13", {},
                    secret_id=secret_id, secret_key=secret_key,
                )
                account_uin = str(result.get("data", {}).get("AccountId", ""))
                if account_uin and account_uin not in ("", "None", "null"):
                    uin_cache_file.write_text(account_uin)
            except Exception:
                pass

        if account_uin and account_uin not in ("", "None", "null"):
            return f"qcs::cam::uin/{account_uin}:roleName/{role_name}"

    return ""


def sts_assume_role(secret_id: str, secret_key: str, role_arn: str,
                    role_session: str, duration: int) -> dict:
    """
    第一步：调用 STS AssumeRole 获取临时凭证
    使用 TC3-HMAC-SHA256 签名算法
    """
    service = "sts"
    host = f"{service}.tencentcloudapi.com"
    action = "AssumeRole"
    version = "2018-08-13"
    region = "ap-guangzhou"

    payload = json.dumps({
        "RoleArn": role_arn,
        "RoleSessionName": role_session,
        "DurationSeconds": duration,
    }, separators=(",", ":"))

    timestamp = int(time.time())
    date = datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime("%Y-%m-%d")

    # 1. 拼接规范请求串
    hashed_payload = hashlib.sha256(payload.encode("utf-8")).hexdigest()
    canonical_request = (
        f"POST\n"
        f"/\n"
        f"\n"
        f"content-type:application/json\n"
        f"host:{host}\n"
        f"x-tc-action:{action.lower()}\n"
        f"\n"
        f"content-type;host;x-tc-action\n"
        f"{hashed_payload}"
    )

    # 2. 拼接待签名字符串
    algorithm = "TC3-HMAC-SHA256"
    credential_scope = f"{date}/{service}/tc3_request"
    hashed_canonical_request = hashlib.sha256(
        canonical_request.encode("utf-8")
    ).hexdigest()
    string_to_sign = (
        f"{algorithm}\n"
        f"{timestamp}\n"
        f"{credential_scope}\n"
        f"{hashed_canonical_request}"
    )

    # 3. 计算签名
    secret_date = _sign_tc3(f"TC3{secret_key}".encode("utf-8"), date)
    secret_service = _sign_tc3(secret_date, service)
    secret_signing = _sign_tc3(secret_service, "tc3_request")
    signature = hmac.new(
        secret_signing, string_to_sign.encode("utf-8"), hashlib.sha256
    ).hexdigest()

    # 4. 拼接 Authorization
    authorization = (
        f"{algorithm} "
        f"Credential={secret_id}/{credential_scope}, "
        f"SignedHeaders=content-type;host;x-tc-action, "
        f"Signature={signature}"
    )

    # 5. 发送请求
    headers = {
        "Authorization": authorization,
        "Content-Type": "application/json",
        "Host": host,
        "X-TC-Action": action,
        "X-TC-Version": version,
        "X-TC-Region": region,
        "X-TC-Timestamp": str(timestamp),
    }

    req = Request(
        f"https://{host}",
        data=payload.encode("utf-8"),
        headers=headers,
        method="POST",
    )

    ctx = _get_ssl_context()
    with urlopen(req, context=ctx) as resp:
        result = json.loads(resp.read().decode("utf-8"))

    response_body = result.get("Response", {})
    credentials = response_body.get("Credentials", {})

    if not credentials:
        error = response_body.get("Error", {})
        return {
            "error": True,
            "code": error.get("Code", "STSError"),
            "message": f"STS AssumeRole 失败: {error.get('Message', '请检查 AK/SK 和 ROLE_ARN')}",
            "requestId": response_body.get("RequestId", ""),
        }

    return {
        "error": False,
        "TmpSecretId": credentials["TmpSecretId"],
        "TmpSecretKey": credentials["TmpSecretKey"],
        "Token": credentials["Token"],
        "RequestId": response_body.get("RequestId", ""),
    }


def generate_login_url(tmp_secret_id: str, tmp_secret_key: str,
                       token: str, target_url: str) -> str:
    """
    第二步：使用临时凭证生成免密登录 URL
    签名方式：HMAC-SHA256 + Base64（与官方文档一致）
    """
    login_timestamp = int(time.time())
    login_nonce = random.randint(10000, 100000000)

    # 签名源串：参数按字母序排列 (action < nonce < secretId < timestamp)
    source_string = (
        f"GETcloud.tencent.com/login/roleAccessCallback?"
        f"action=roleLogin"
        f"&nonce={login_nonce}"
        f"&secretId={tmp_secret_id}"
        f"&timestamp={login_timestamp}"
    )

    # HMAC-SHA256 + Base64
    login_signature = base64.b64encode(
        hmac.new(
            tmp_secret_key.encode("utf-8"),
            source_string.encode("utf-8"),
            hashlib.sha256,
        ).digest()
    ).decode("utf-8")

    # 拼接最终 URL（safe='' 确保 +/= 等特殊字符都被编码）
    login_url = (
        f"https://cloud.tencent.com/login/roleAccessCallback?"
        f"algorithm=sha256"
        f"&secretId={quote(tmp_secret_id, safe='')}"
        f"&[REDACTED_SECRET], safe='')}"
        f"&nonce={login_nonce}"
        f"&timestamp={login_timestamp}"
        f"&signature={quote(login_signature, safe='')}"
        f"&s_url={quote(target_url, safe='')}"
    )

    return login_url


def main():
    # ============== 参数解析 ==============
    if len(sys.argv) < 2 or not sys.argv[1].strip():
        print(output_error(
            "MissingParameter",
            "缺少目标 URL 参数。用法: python3 login_url.py <target_url>"
        ))
        sys.exit(1)

    target_url = sys.argv[1]

    # ============== 密钥检查 ==============
    secret_id = os.environ.get("TENCENTCLOUD_SECRET_ID", "")
    secret_key = os.environ.get("TENCENTCLOUD_SECRET_KEY", "")

    missing = []
    if not secret_id:
        missing.append("TENCENTCLOUD_SECRET_ID")
    if not secret_key:
        missing.append("TENCENTCLOUD_SECRET_KEY")

    if missing:
        print(output_error("MissingCredentials", f"缺少环境变量: {' '.join(missing)}"))
        sys.exit(1)

    # ============== 获取 RoleArn ==============
    role_arn = _get_role_arn(secret_id, secret_key)
    if not role_arn:
        print(output_error(
            "MissingRoleConfiguration",
            "未配置角色信息。请选择以下方式之一:\\n\\n"
            "方式一: 设置角色名称（推荐）\\n"
            "  export TENCENTCLOUD_ROLE_NAME=\\\"AdvisorRole\\\"\\n\\n"
            "方式二: 设置完整 ARN\\n"
            "  export TENCENTCLOUD_ROLE_ARN=\\\"qcs::cam::uin/您的账号UIN:roleName/角色名\\\"\\n\\n"
            "方式三: 运行配置向导（首次使用推荐）\\n"
            "  python3 scripts/setup_role.py\\n\\n"
            "提示: 您可以在 CAM 控制台查看可用角色:\\n"
            "  https://console.cloud.tencent.com/cam/role"
        ))
        sys.exit(1)

    # ============== 配置参数 ==============
    role_session = os.environ.get("TENCENTCLOUD_ROLE_SESSION", "advisor-session")
    duration = int(os.environ.get("TENCENTCLOUD_STS_DURATION", "3600"))

    # ============== 第一步：STS AssumeRole ==============
    try:
        sts_result = sts_assume_role(secret_id, secret_key, role_arn, role_session, duration)
    except Exception as e:
        print(output_error("STSError", f"STS AssumeRole 请求异常: {e}"))
        sys.exit(1)

    if sts_result.get("error"):
        print(output_error(
            sts_result.get("code", "STSError"),
            sts_result.get("message", "STS AssumeRole 失败"),
            sts_result.get("requestId", ""),
        ))
        sys.exit(1)

    # ============== 第二步：生成免密登录 URL ==============
    login_url = generate_login_url(
        sts_result["TmpSecretId"],
        sts_result["TmpSecretKey"],
        sts_result["Token"],
        target_url,
    )

    # ============== 输出结果 ==============
    print(output_success(login_url, target_url, duration, sts_result["RequestId"]))


if __name__ == "__main__":
    main()


path: scripts/setup_role.py
content: #!/usr/bin/env python3
"""
腾讯云智能顾问角色配置向导 (Python 版)
自动检测账号信息和可用角色，引导用户完成配置

用法:
    python3 setup_role.py

环境变量（必须提前设置）:
    TENCENTCLOUD_SECRET_ID   - 腾讯云 SecretId（必填）
    TENCENTCLOUD_SECRET_KEY  - 腾讯云 SecretKey（必填）

跨平台支持: Windows / Linux / macOS
"""

import json
import os
import platform
import stat
import sys
from datetime import datetime, timezone
from pathlib import Path

# 导入 tcloud_api 模块
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))

from tcloud_api import call_api  # noqa: E402


# ============== 配置 ==============
CONFIG_DIR = Path.home() / ".tsa-risk"
CONFIG_FILE = CONFIG_DIR / "config.json"
ROLE_NAME = "advisor"
POLICY_NAMES = ["QcloudTAGFullAccess", "QcloudAdvisorFullAccess"]


# ============== 颜色输出（跨平台） ==============
def _supports_color() -> bool:
    """检测终端是否支持 ANSI 颜色"""
    if platform.system() == "Windows":
        # Windows 10+ 的 cmd/PowerShell 支持 ANSI（需 VT100）
        try:
            os.system("")  # 激活 VT100 模式
            return True
        except Exception:
            return False
    return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()


USE_COLOR = _supports_color()


def _c(code: str, text: str) -> str:
    """带颜色格式化"""
    if USE_COLOR:
        return f"\033[{code}m{text}\033[0m"
    return text


def blue(text: str) -> str:
    return _c("0;34", text)


def green(text: str) -> str:
    return _c("0;32", text)


def yellow(text: str) -> str:
    return _c("1;33", text)


def red(text: str) -> str:
    return _c("0;31", text)


def bold_cyan(text: str) -> str:
    return _c("1;36", text)


def bold_yellow(text: str) -> str:
    return _c("1;33", text)


# ============== 输出辅助 ==============
def print_header(title: str):
    print("")
    bar = blue("=" * 58)
    print(bar)
    print(blue(f"  {title}"))
    print(bar)
    print("")


def print_step(msg: str):
    print(green(f"▶ {msg}"))


def print_ok(msg: str):
    print(green(f"✓ {msg}"))


def print_fail(msg: str):
    print(red(f"✗ {msg}"))


def print_warn(msg: str):
    print(yellow(f"⚠ {msg}"))


# ============== 配置保存 ==============
def save_config(account_uin: str, role_name: str, role_arn: str,
                auto_created: bool = False):
    """保存配置文件（跨平台兼容）"""
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)

    if platform.system() != "Windows":
        try:
            os.chmod(str(CONFIG_DIR), stat.S_IRWXU)  # 700
        except OSError:
            pass

    config = {
        "accountUin": account_uin,
        "roleName": role_name,
        "roleArn": role_arn,
        "configuredAt": datetime.now(timezone.utc).isoformat(),
        "autoCreated": auto_created,
        "version": "1.0",
    }

    CONFIG_FILE.write_text(
        json.dumps(config, indent=2, ensure_ascii=False),
        encoding="utf-8",
    )

    if platform.system() != "Windows":
        try:
            os.chmod(str(CONFIG_FILE), stat.S_IRUSR | stat.S_IWUSR)  # 600
        except OSError:
            pass


def print_config_summary(account_uin: str, role_name: str, role_arn: str):
    """打印配置摘要"""
    print("")
    print_header("配置完成")
    print("")
    print(green("✓ 您已成功配置 tsa-risk!"))
    print("")
    print("配置摘要:")
    print("━" * 50)
    print(f"  账号 UIN:  {account_uin}")
    print(f"  角色名称:  {role_name}")
    print(f"  角色 ARN:  {role_arn}")
    print(f"  关联策略:  {', '.join(POLICY_NAMES)}")
    print(f"  配置文件:  {CONFIG_FILE}")
    print("━" * 50)
    print("")


def test_role(scripts_dir: Path):
    """测试角色扮演"""
    import subprocess
    login_url_path = scripts_dir / "login_url.py"

    try:
        test_result = subprocess.run(
            [sys.executable, str(login_url_path),
             "https://console.cloud.tencent.com/advisor"],
            capture_output=True, text=True, timeout=30,
        )
        try:
            result_data = json.loads(test_result.stdout)
            return result_data.get("success", False), result_data
        except json.JSONDecodeError:
            return False, {"error": {"message": "返回格式异常"}}
    except Exception as e:
        return False, {"error": {"message": str(e)}}


def main():
    print_header("腾讯云智能顾问 - 角色配置向导")

    # ============== 步骤 1：验证密钥 ==============
    print_step("步骤 1/6: 验证 API 密钥配置...")
    print("")

    secret_id = os.environ.get("TENCENTCLOUD_SECRET_ID", "")
    secret_key = os.environ.get("TENCENTCLOUD_SECRET_KEY", "")

    if not secret_id or not secret_key:
        print_fail("未检测到 API 密钥")
        print("")
        print("请先设置环境变量:")
        print('  export TENCENTCLOUD_SECRET_ID="your-secret-id"')
        print('  export TENCENTCLOUD_SECRET_KEY="your-secret-key"')
        print("")
        if platform.system() == "Windows":
            print("Windows PowerShell:")
            print('  $env:TENCENTCLOUD_SECRET_ID="your-secret-id"')
            print('  $env:TENCENTCLOUD_SECRET_KEY="your-secret-key"')
            print("")
        print("获取密钥: https://console.cloud.tencent.com/cam/capi")
        sys.exit(1)

    masked_id = f"{secret_id[:8]}****{secret_id[-4:]}" if len(secret_id) > 12 else "****"
    print_ok("API 密钥已配置")
    print(f"  Secret ID: {masked_id}")
    print("")

    # ============== 步骤 2：获取账号信息 ==============
    print_step("步骤 2/6: 获取账号信息...")
    print("")

    uin_result = call_api(
        "sts", "sts.tencentcloudapi.com",
        "GetCallerIdentity", "2018-08-13", {},
    )

    if not uin_result.get("success"):
        print_fail("获取账号信息失败")
        print("")
        error_msg = uin_result.get("error", {}).get("message", "未知错误")
        print(f"  错误详情: {error_msg}")
        sys.exit(1)

    account_uin = str(uin_result.get("data", {}).get("AccountId", ""))
    account_type = str(uin_result.get("data", {}).get("Type", ""))

    print_ok("账号信息获取成功")
    print(f"  账号 UIN: {account_uin}")
    print(f"  身份类型: {account_type}")
    print("")

    # ============== 步骤 3：获取角色列表 ==============
    print_step("步骤 3/6: 查询可用角色...")
    print("")

    role_list_result = call_api(
        "cam", "cam.tencentcloudapi.com",
        "DescribeRoleList", "2019-01-16",
        {"Page": 1, "Rp": 50},
    )

    if not role_list_result.get("success"):
        print_warn("无法获取角色列表")
        print("")
        print("  可能的原因:")
        print("    1. 您的账号没有 cam:DescribeRoleList 权限")
        print("    2. 网络连接问题")
        print("")
        error_msg = role_list_result.get("error", {}).get("message", "未知错误")
        print(f"  错误详情: {error_msg}")
        print("")
        print("  您可以手动配置角色:")
        print('    export TENCENTCLOUD_ROLE_NAME="您的角色名称"')
        print("")
        print("  查看可用角色: https://console.cloud.tencent.com/cam/role")
        sys.exit(1)

    total = role_list_result.get("data", {}).get("TotalNum", 0)
    role_list = role_list_result.get("data", {}).get("List", [])

    # 筛选支持控制台登录的用户自定义角色
    console_roles = [
        r for r in role_list
        if r.get("ConsoleLogin") == 1 and r.get("RoleType") == "user"
    ]

    # ============== 无可用角色 → 创建新角色 ==============
    if not console_roles:
        print_warn("未检测到可用于控制台登录的角色")
        print("")
        print(f"  当前账号共有 {total} 个角色，但没有支持控制台登录的用户自定义角色。")
        print("")
        print(yellow("⚡ 安全提示：以下操作将修改您的 CAM（访问管理）配置，请仔细确认："))
        print("")
        print("  将要执行的 IAM 操作：")
        print(f"    1. [cam:CreateRole] 创建 CAM 角色：{green(ROLE_NAME)}")
        print(f"    2. 信任策略：仅允许当前账号 ({account_uin}) 扮演此角色")
        print("    3. 启用控制台登录（用于生成免密登录链接）")
        for policy in POLICY_NAMES:
            print(f"    4. [cam:AttachRolePolicy] 关联策略：{green(policy)}")
        print("")
        print("  权限范围说明：")
        print("    - 该角色仅用于免密登录腾讯云控制台查看智能顾问信息")
        print("    - QcloudAdvisorFullAccess 策略仅授权访问智能顾问服务，不影响其他云资源")
        print("    - 您可随时在 CAM 控制台删除此角色: https://console.cloud.tencent.com/cam/role")
        print("")

        try:
            confirm = input(green("是否同意创建？[y/N]: ")).strip()
        except (EOFError, KeyboardInterrupt):
            confirm = ""

        if confirm.lower() not in ("y", "yes"):
            print("")
            print_warn("已取消。您也可以手动创建角色：")
            print("")
            print("  1. 打开 CAM 控制台: https://console.cloud.tencent.com/cam/role")
            print(f"  2. 新建角色 → 腾讯云账户 → 角色名: {ROLE_NAME}")
            print("  3. 勾选「允许当前角色访问控制台」")
            for policy in POLICY_NAMES:
                print(f"  4. 关联策略: {policy}")
            print("")
            sys.exit(1)

        print("")
        print_step(f"正在创建角色 {ROLE_NAME}...")
        print("")

        # 构造信任策略
        trust_policy = json.dumps({
            "version": "2.0",
            "statement": [{
                "action": "name/sts:AssumeRole",
                "effect": "allow",
                "principal": {
                    "qcs": [f"qcs::cam::uin/{account_uin}:root"]
                }
            }]
        }, separators=(",", ":"))

        create_result = call_api(
            "cam", "cam.tencentcloudapi.com",
            "CreateRole", "2019-01-16",
            {
                "RoleName": ROLE_NAME,
                "PolicyDocument": trust_policy,
                "ConsoleLogin": 1,
                "Description": "腾讯云智能顾问助手角色（由 tsa-risk skill 创建）",
            },
        )

        if not create_result.get("success"):
            err_msg = create_result.get("error", {}).get("message", "未知错误")
            print_fail(f"角色创建失败: {err_msg}")
            print("")
            print("  请手动创建角色: https://console.cloud.tencent.com/cam/role")
            sys.exit(1)

        role_id = str(create_result.get("data", {}).get("RoleId", "unknown"))
        print_ok(f"角色创建成功（RoleId: {role_id}）")

        # 关联策略
        for policy_name in POLICY_NAMES:
            print("")
            print_step(f"正在关联策略 {policy_name}...")
            print("")

            attach_result = call_api(
                "cam", "cam.tencentcloudapi.com",
                "AttachRolePolicy", "2019-01-16",
                {"AttachRoleName": ROLE_NAME, "PolicyName": policy_name},
            )

            if attach_result.get("success"):
                print_ok(f"策略 {policy_name} 关联成功")
            else:
                err_msg = attach_result.get("error", {}).get("message", "未知错误")
                print_warn(f"策略关联失败: {err_msg}")
                print(f"  请到 CAM 控制台手动关联策略：")
                print(f"  https://console.cloud.tencent.com/cam/role/detail?roleName={ROLE_NAME}")

        # 保存配置并测试
        selected_role = ROLE_NAME
        role_arn = f"qcs::cam::uin/{account_uin}:roleName/{selected_role}"

        print("")
        print_step("步骤 5/6: 保存配置文件")
        print("")

        save_config(account_uin, selected_role, role_arn, auto_created=True)

        print_ok("配置文件已保存")
        print(f"  路径: {CONFIG_FILE}")
        print("")

        print_step("步骤 6/6: 测试角色扮演")
        print("")

        success, result_data = test_role(SCRIPT_DIR)

        if success:
            print_ok("角色扮演测试成功")
            print_config_summary(account_uin, selected_role, role_arn)
        else:
            print_warn("角色扮演测试失败（角色可能需要几秒生效）")
            print("")
            err_msg = result_data.get("error", {}).get("message", "未知错误")
            print(f"  错误详情: {err_msg}")
            print("")
            print(f"  配置已保存，请稍后重试或运行: python3 {SCRIPT_DIR / 'setup_role.py'}")
        sys.exit(0)

    # ============== 有可用角色 → 显示列表让用户选择 ==============
    print_ok(f"检测到 {len(console_roles)} 个可用角色（共 {total} 个角色）")
    print("")

    # 步骤 4：显示角色列表
    print_step("步骤 4/6: 选择角色")
    print("")
    print("可用角色列表（仅显示支持控制台登录的用户角色）:")
    print("━" * 74)

    for idx, role in enumerate(console_roles, 1):
        name = role.get("RoleName", "未知")
        desc = role.get("Description", "无描述") or "无描述"
        add_time = role.get("AddTime", "")
        print(f"  {bold_yellow(f'{idx:2d}')}. {bold_cyan(f'{name:<25}')} 创建于 {add_time}")
        print(f"    └─ {desc}")
        print("")

    print("━" * 74)

    # 步骤 5：选择角色
    print("")
    try:
        choice_str = input(green(f"请输入角色序号 [1-{len(console_roles)}]: ")).strip()
    except (EOFError, KeyboardInterrupt):
        choice_str = ""

    try:
        choice = int(choice_str)
    except ValueError:
        print_fail(f"无效的选择: {choice_str}")
        sys.exit(1)

    if choice < 1 or choice > len(console_roles):
        print_fail(f"无效的选择: {choice}")
        sys.exit(1)

    selected = console_roles[choice - 1]
    selected_role = selected.get("RoleName", "")
    selected_desc = selected.get("Description", "无描述") or "无描述"

    print("")
    print_ok(f"已选择角色: {selected_role}")
    print(f"  描述: {selected_desc}")
    print("")

    # 步骤 6：保存配置
    print_step("步骤 5/6: 保存配置文件")
    print("")

    role_arn = f"qcs::cam::uin/{account_uin}:roleName/{selected_role}"
    save_config(account_uin, selected_role, role_arn, auto_created=False)

    print_ok("配置文件已保存")
    print(f"  路径: {CONFIG_FILE}")
    print("")

    # 步骤 7：测试配置
    print_step("步骤 6/6: 测试角色扮演")
    print("")

    success, result_data = test_role(SCRIPT_DIR)

    if success:
        print_ok("角色扮演测试成功")
        print_config_summary(account_uin, selected_role, role_arn)
        print("")
        print("现在您可以:")
        print("")
        print("  1. 查询架构列表")
        print(f"     python3 {SCRIPT_DIR / 'tcloud_api.py'} advisor advisor.tencentcloudapi.com \\")
        print('       DescribeArchList 2020-07-21 \'{"PageNumber":1,"PageSize":10}\'')
        print("")
        print("  2. 查询架构详情")
        print(f"     python3 {SCRIPT_DIR / 'tcloud_api.py'} advisor advisor.tencentcloudapi.com \\")
        print('       DescribeArch 2020-07-21 \'{"ArchId":"arch-xxx","Username":"user"}\'')
        print("")
        print("  3. 如需重新配置")
        print(f"     python3 {SCRIPT_DIR / 'setup_role.py'}")
        print("")
    else:
        print_fail("角色扮演测试失败")
        print("")
        err_msg = result_data.get("error", {}).get("message", "未知错误")
        print(f"  错误详情: {err_msg}")
        print("")
        print_warn("可能的原因:")
        print("  1. 您的账号没有 AssumeRole 权限")
        print("  2. 角色信任策略不允许您的账号扮演")
        print("  3. 角色已被删除或禁用")
        print("")
        print("  请检查角色配置:")
        print("  https://console.cloud.tencent.com/cam/role")
        print("")
        print("  您也可以尝试手动配置:")
        print('  export TENCENTCLOUD_ROLE_NAME="其他角色名称"')


if __name__ == "__main__":
    main()


path: scripts/tcloud_api.py
content: #!/usr/bin/env python3
"""
腾讯云 API 签名 v3 通用调用脚本 (Python 版)
基于腾讯云 API 签名 v3 文档实现: https://cloud.tencent.com/document/product/213/30654

纯 Python 标准库实现，支持 Windows / Linux / macOS 跨平台运行。
不依赖 curl、openssl、jq 等外部工具。

用法 (命令行):
    python3 tcloud_api.py <service> <host> <action> <version> [payload] [region]

示例:
    python3 tcloud_api.py advisor advisor.tencentcloudapi.com DescribeArchList 2020-07-21 '{"PageNumber":1,"PageSize":10}'

作为模块导入:
    from tcloud_api import call_api
    result = call_api("advisor", "advisor.tencentcloudapi.com",
                      "DescribeArchList", "2020-07-21",
                      {"PageNumber": 1, "PageSize": 10})

环境变量（必须提前设置）:
    TENCENTCLOUD_SECRET_ID  - 腾讯云 SecretId
    TENCENTCLOUD_SECRET_KEY - 腾讯云 SecretKey
    TENCENTCLOUD_TOKEN      - 临时密钥 Token（可选，使用临时密钥时设置）

输出格式（统一 JSON）:
    成功: {"success": true, "action": "<Action>", "data": {...}, "requestId": "xxx"}
    失败: {"success": false, "action": "<Action>", "error": {"code": "xxx", "message": "xxx"}, "requestId": "xxx"}
"""

import hashlib
import hmac
import json
import os
import ssl
import sys
import time
from datetime import datetime, timezone
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError


def _get_ssl_context():
    """获取 SSL 上下文，兼容各平台 CA 证书差异"""
    try:
        import certifi
        return ssl.create_default_context(cafile=certifi.where())
    except ImportError:
        ctx = ssl.create_default_context()
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
        return ctx


def _sign_tc3(key: bytes, msg: str) -> bytes:
    """TC3 HMAC-SHA256 签名辅助函数"""
    return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()


def _output_json(obj: dict) -> str:
    """统一 JSON 输出"""
    return json.dumps(obj, ensure_ascii=False)


def _make_error(action: str, code: str, message: str, request_id: str = "") -> dict:
    """构造统一错误结果"""
    return {
        "success": False,
        "action": action,
        "error": {"code": code, "message": message},
        "requestId": request_id,
    }


def _make_success(action: str, data: dict, request_id: str) -> dict:
    """构造统一成功结果"""
    return {
        "success": True,
        "action": action,
        "data": data,
        "requestId": request_id,
    }


def call_api(service: str, host: str, action: str, version: str,
             payload: dict | str = None, region: str = "ap-guangzhou",
             secret_id: str = None, secret_key: str = None,
             token: str = None) -> dict:
    """
    调用腾讯云 API（TC3-HMAC-SHA256 签名）

    Args:
        service: 服务名（如 advisor, sts, cam）
        host: 接口域名（如 advisor.tencentcloudapi.com）
        action: 接口名称（如 DescribeArchList）
        version: 接口版本（如 2020-07-21）
        payload: 请求体，dict 或 JSON 字符串
        region: 地域，默认 ap-guangzhou
        secret_id: SecretId，不传则从环境变量读取
        secret_key: SecretKey，不传则从环境变量读取
        token: 临时密钥 Token，不传则从环境变量读取

    Returns:
        dict: 统一格式的结果字典
    """
    # 密钥
    secret_id = secret_id or os.environ.get("TENCENTCLOUD_SECRET_ID", "")
    secret_key = secret_key or os.environ.get("TENCENTCLOUD_SECRET_KEY", "")
    token = token or os.environ.get("TENCENTCLOUD_TOKEN", "")

    if not secret_id or not secret_key:
        return _make_error(
            action, "MissingCredentials",
            "未配置 TENCENTCLOUD_SECRET_ID 或 TENCENTCLOUD_SECRET_KEY。"
            "密钥获取: https://console.cloud.tencent.com/cam/capi"
        )

    # 请求体
    if payload is None:
        payload_str = "{}"
    elif isinstance(payload, dict):
        payload_str = json.dumps(payload, separators=(",", ":"))
    else:
        payload_str = str(payload)

    # 签名参数
    algorithm = "TC3-HMAC-SHA256"
    timestamp = int(time.time())
    date = datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime("%Y-%m-%d")

    # 步骤 1：拼接规范请求串
    hashed_payload = hashlib.sha256(payload_str.encode("utf-8")).hexdigest()
    canonical_request = (
        f"POST\n"
        f"/\n"
        f"\n"
        f"content-type:application/json; charset=utf-8\n"
        f"host:{host}\n"
        f"x-tc-action:{action.lower()}\n"
        f"\n"
        f"content-type;host;x-tc-action\n"
        f"{hashed_payload}"
    )

    # 步骤 2：拼接待签名字符串
    credential_scope = f"{date}/{service}/tc3_request"
    hashed_canonical_request = hashlib.sha256(
        canonical_request.encode("utf-8")
    ).hexdigest()
    string_to_sign = (
        f"{algorithm}\n"
        f"{timestamp}\n"
        f"{credential_scope}\n"
        f"{hashed_canonical_request}"
    )

    # 步骤 3：计算签名
    secret_date = _sign_tc3(f"TC3{secret_key}".encode("utf-8"), date)
    secret_service = _sign_tc3(secret_date, service)
    secret_signing = _sign_tc3(secret_service, "tc3_request")
    signature = hmac.new(
        secret_signing, string_to_sign.encode("utf-8"), hashlib.sha256
    ).hexdigest()

    # 步骤 4：拼接 Authorization
    authorization = (
        f"{algorithm} "
        f"Credential={secret_id}/{credential_scope}, "
        f"SignedHeaders=content-type;host;x-tc-action, "
        f"Signature={signature}"
    )

    # 步骤 5：发送请求
    headers = {
        "Authorization": authorization,
        "Content-Type": "application/json; charset=utf-8",
        "Host": host,
        "X-TC-Action": action,
        "X-TC-Timestamp": str(timestamp),
        "X-TC-Version": version,
        "X-TC-Region": region,
    }
    if [REDACTED_SECRET]"X-TC-Token"] = token

    req = Request(
        f"https://{host}",
        data=payload_str.encode("utf-8"),
        headers=headers,
        method="POST",
    )

    try:
        ctx = _get_ssl_context()
        with urlopen(req, context=ctx, timeout=30) as resp:
            response_body = resp.read().decode("utf-8")
    except HTTPError as e:
        try:
            body = e.read().decode("utf-8")
            # 尝试解析 API 错误响应
            data = json.loads(body)
            response = data.get("Response", {})
            error = response.get("Error", {})
            if error:
                return _make_error(
                    action,
                    error.get("Code", "HTTPError"),
                    error.get("Message", f"HTTP {e.code}"),
                    response.get("RequestId", ""),
                )
        except Exception:
            pass
        return _make_error(
            action, "HTTPError",
            f"HTTP 请求失败 (状态码 {e.code}): {e.reason}"
        )
    except URLError as e:
        return _make_error(
            action, "NetworkError",
            f"网络连接失败，请检查网络和域名 {host} 是否可达: {e.reason}"
        )
    except Exception as e:
        return _make_error(
            action, "NetworkError",
            f"请求异常: {e}"
        )

    # 步骤 6：解析响应
    try:
        data = json.loads(response_body)
    except json.JSONDecodeError:
        return _make_error(action, "ParseError", "响应不是有效的 JSON")

    response = data.get("Response", {})
    request_id = response.get("RequestId", "")

    if "Error" in response:
        err = response["Error"]
        return _make_error(
            action,
            err.get("Code", "Unknown"),
            err.get("Message", "未知错误"),
            request_id,
        )

    # 成功：移除 RequestId 后将剩余字段作为 data
    result_data = {k: v for k, v in response.items() if k != "RequestId"}
    return _make_success(action, result_data, request_id)


def main():
    """命令行入口"""
    args = sys.argv[1:]

    if len(args) < 4:
        print(_output_json(_make_error(
            "", "MissingParameter",
            "缺少必要参数。用法: python3 tcloud_api.py <service> <host> <action> <version> [payload] [region]"
        )))
        sys.exit(1)

    service = args[0]
    host = args[1]
    action = args[2]
    version = args[3]
    payload_str = args[4] if len(args) > 4 else "{}"
    region = args[5] if len(args) > 5 else "ap-guangzhou"

    # 解析 payload
    try:
        payload = json.loads(payload_str)
    except json.JSONDecodeError:
        payload = payload_str

    result = call_api(service, host, action, version, payload, region)
    print(_output_json(result))

    if not result.get("success"):
        sys.exit(1)


if __name__ == "__main__":
    main()


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