Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e64eb5db95 | ||
|
|
e175115de1 | ||
|
|
9dc4f95f60 | ||
|
|
15f53c0a6b | ||
|
|
6bffbba817 | ||
|
|
ab11f5d583 | ||
|
|
c1d2210c95 | ||
|
|
b12ae13047 | ||
|
|
8a61c038ff | ||
|
|
b5f3603be5 | ||
|
|
802713aafe |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 50.0%"><title>coverage: 50.0%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">50.0%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">50.0%</text></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 53.9%"><title>coverage: 53.9%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">53.9%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">53.9%</text></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
+3
-1
@@ -24,4 +24,6 @@ test/cli_compat/testdata/
|
||||
.env.*
|
||||
*.pem
|
||||
*.key
|
||||
credentials*
|
||||
credentials*
|
||||
plans
|
||||
_docs
|
||||
|
||||
@@ -31,10 +31,9 @@
|
||||
- [Getting Started](#getting-started)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Using with Agents](#using-with-agents)
|
||||
- [New Features](#new-features)
|
||||
- [Features](#features)
|
||||
- [Key Services](#key-services)
|
||||
- [Security by Design](#security-by-design)
|
||||
- [AI Agent Skills](#ai-agent-skills)
|
||||
- [Reference & Docs](#reference--docs)
|
||||
- [Contributing](#contributing)
|
||||
|
||||
@@ -145,45 +144,37 @@ dws auth login
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
dws contact user search --keyword "Alice" # search contacts
|
||||
dws contact user search --keyword "engineering" # search contacts
|
||||
dws calendar event list # list calendar events
|
||||
dws todo task create --title "Quarterly report" --executors "<userId>" # create a todo
|
||||
dws todo task create --title "Quarterly report" --executors "<your-userId>" # create a todo (replace <your-userId>)
|
||||
dws todo task list --dry-run # preview without executing
|
||||
```
|
||||
|
||||
## Using with Agents
|
||||
|
||||
dws is designed as an AI-native CLI. Here's how to integrate it with your agent:
|
||||
|
||||
### 1. Setup
|
||||
dws is designed as an AI-native CLI. Complete [Installation](#installation) and [Getting Started](#getting-started) first, then configure your agent:
|
||||
|
||||
```bash
|
||||
# Install dws
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh
|
||||
|
||||
# Configure auth via environment variables (recommended for agents, no interactive login)
|
||||
export DWS_CLIENT_ID=<your-app-key>
|
||||
export DWS_CLIENT_SECRET=<your-app-secret>
|
||||
dws auth login
|
||||
```
|
||||
|
||||
### 2. Agent Invocation Patterns
|
||||
### Agent Invocation Patterns
|
||||
|
||||
```bash
|
||||
# Use --yes to skip confirmation prompts (required for agents)
|
||||
dws todo task create --title "Review PR" --yes
|
||||
dws todo task create --title "Review PR" --executors "<your-userId>" --yes
|
||||
|
||||
# Use --dry-run to preview operations (safe execution)
|
||||
dws contact user search --keyword "test" --dry-run
|
||||
dws contact user search --keyword "engineering" --dry-run
|
||||
|
||||
# Use --jq to extract precisely (save tokens)
|
||||
dws contact user search --keyword "test" --jq '.response.content'
|
||||
|
||||
# Use --fields to return only needed fields
|
||||
dws calendar event list --fields response
|
||||
dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserName, dept: .depts[0].deptName, userId}'
|
||||
```
|
||||
|
||||
### 3. Schema Discovery (Agent Self-Exploration)
|
||||
### Schema Discovery
|
||||
|
||||
Agents don't need pre-built knowledge of every command. Use `dws schema` to dynamically discover capabilities:
|
||||
|
||||
@@ -195,12 +186,42 @@ dws schema --jq '.products[] | {id, tool_count: (.tools | length)}'
|
||||
dws schema aitable.query_records --jq '.tool.input_schema'
|
||||
|
||||
# Step 3: Construct the correct call
|
||||
dws aitable record query --base-id "xxx" --table-id "yyy" --limit 10
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
```
|
||||
|
||||
### 4. Error Tolerance
|
||||
### Agent Skills
|
||||
|
||||
The smart correction engine is highly tolerant of common agent-generated mistakes:
|
||||
The repo ships Agent Skills (`SKILL.md` files) for every DingTalk product. After installing, tools like Claude Code / Cursor can use DingTalk capabilities directly:
|
||||
|
||||
```bash
|
||||
# Install skills into current project
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
|
||||
|
||||
Author your own Agent Skills and orchestrate them with dws skills for cross-product workflows: **ISV Skill → dws Skill → DingTalk Open Platform API (enforced auth + full audit)**.
|
||||
|
||||
## Features
|
||||
|
||||
<details>
|
||||
<summary><strong>Smart Input Correction</strong> — auto-corrects common AI model parameter mistakes <code>v1.0.1</code></summary>
|
||||
|
||||
Built-in pipeline engine that normalizes flag names, splits sticky arguments, and fuzzy-matches typos:
|
||||
|
||||
```bash
|
||||
# Naming convention auto-conversion (camelCase / snake_case / UPPER -> kebab-case)
|
||||
dws aitable record query --baseId BASE_ID --tableId TABLE_ID # auto-corrected to --base-id --table-id
|
||||
|
||||
# Sticky argument splitting
|
||||
dws contact user search --keyword "engineering" --timeout30 # auto-split to --timeout 30
|
||||
|
||||
# Fuzzy flag name matching
|
||||
dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-id -> --table-id
|
||||
|
||||
# Value normalization (boolean / number / date / enum)
|
||||
# "yes" -> true, "1,000" -> 1000, "2024/03/29" -> "2024-03-29", "ACTIVE" -> "active"
|
||||
```
|
||||
|
||||
| Agent Output | dws Auto-Corrects To |
|
||||
|-----------|--------------|
|
||||
@@ -210,53 +231,24 @@ The smart correction engine is highly tolerant of common agent-generated mistake
|
||||
| `--USER-ID` | `--user-id` |
|
||||
| `--user_name` | `--user-name` |
|
||||
|
||||
### 5. Agent Skill Integration
|
||||
</details>
|
||||
|
||||
After installing Agent Skills, tools like Claude Code / Cursor can use DingTalk capabilities directly:
|
||||
<details>
|
||||
<summary><strong>jq Filtering & Field Selection</strong> — fine-grained output control to reduce token consumption <code>v1.0.1</code></summary>
|
||||
|
||||
```bash
|
||||
# Install skills into current project
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> See the [AI Agent Skills](#ai-agent-skills) section for details.
|
||||
|
||||
## New Features
|
||||
|
||||
### Smart Input Correction
|
||||
|
||||
dws includes a built-in pipeline engine that auto-corrects common AI model parameter mistakes:
|
||||
|
||||
```bash
|
||||
# Naming convention auto-conversion (camelCase / snake_case / UPPER -> kebab-case)
|
||||
dws aitable record query --baseId x --tableId y # auto-corrected to --base-id --table-id
|
||||
|
||||
# Sticky argument splitting
|
||||
dws contact user search --keyword "test" --timeout30 # auto-split to --timeout 30
|
||||
|
||||
# Fuzzy flag name matching
|
||||
dws aitable record query --base-id x --tabel-id y # --tabel-id -> --table-id
|
||||
|
||||
# Value normalization (boolean / number / date / enum)
|
||||
# "yes" -> true, "1,000" -> 1000, "2024/03/29" -> "2024-03-29", "ACTIVE" -> "active"
|
||||
```
|
||||
|
||||
### JSON Field Selection & jq Filtering
|
||||
|
||||
Fine-grained output control to reduce token consumption:
|
||||
|
||||
```bash
|
||||
# Return only specific fields
|
||||
dws aitable record query --base-id x --table-id y --fields invocation,response
|
||||
|
||||
# Built-in jq expressions
|
||||
dws aitable record query --base-id x --table-id y --jq '.invocation.params'
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
|
||||
dws schema --jq '.products[] | {id, tools: (.tools | length)}'
|
||||
|
||||
# Return only specific fields
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
|
||||
```
|
||||
|
||||
### Schema Introspection
|
||||
</details>
|
||||
|
||||
Agents can query parameter schemas before making calls:
|
||||
<details>
|
||||
<summary><strong>Schema Introspection</strong> — query parameter schemas before making calls <code>v1.0.1</code></summary>
|
||||
|
||||
```bash
|
||||
dws schema # list all products and tools
|
||||
@@ -265,6 +257,27 @@ dws schema aitable.query_records --jq '.tool.input_schema.required' # view req
|
||||
dws schema --jq '.products[].id' # extract all product IDs
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Pipe & File Input</strong> — read flag values from files or stdin <code>v1.0.1</code></summary>
|
||||
|
||||
```bash
|
||||
# Read message body from a file
|
||||
dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
--title "Weekly Report" --text @report.md
|
||||
|
||||
# Pipe content via stdin
|
||||
cat report.md | dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
--title "Weekly Report"
|
||||
|
||||
# Read from stdin explicitly
|
||||
dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
--title "Weekly Report" --text @-
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Key Services
|
||||
|
||||
| Service | Command | Description |
|
||||
@@ -273,7 +286,7 @@ dws schema --jq '.products[].id' # extract all product ID
|
||||
| Chat | `chat` | Group management / members / bot messaging / webhook |
|
||||
| Calendar | `calendar` | Events / meeting rooms / free-busy |
|
||||
| Todo | `todo` | Task management |
|
||||
| Approval | `approval` | Processes / forms / instances |
|
||||
| Approval | `oa` | Processes / forms / instances |
|
||||
| Attendance | `attendance` | Clock-in / shifts / statistics |
|
||||
| Ding | `ding` | DING messages / send / recall |
|
||||
| Report | `report` | Reports / templates / statistics |
|
||||
@@ -333,23 +346,6 @@ Run `dws --help` for the full list, or `dws <service> --help` for subcommands.
|
||||
|
||||
> Found a vulnerability? Report via [GitHub Security Advisories](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/security/advisories/new). See [SECURITY.md](./SECURITY.md).
|
||||
|
||||
## AI Agent Skills
|
||||
|
||||
The repo ships Agent Skills (`SKILL.md` files) for every DingTalk product. The install script deploys them to `~/.agents/skills/dws` automatically.
|
||||
|
||||
```bash
|
||||
# Install skills into the current project only
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
|
||||
|
||||
### ISV Skill Integration
|
||||
|
||||
Author your own Agent Skills and orchestrate them with dws skills for cross-product workflows: **ISV Skill → dws Skill → DingTalk Open Platform API (enforced auth + full audit)**.
|
||||
|
||||
**Example**: A CRM Skill invokes the Calendar Skill to create a client meeting, then triggers the Todo Skill to assign follow-ups — the AI agent completes cross-system collaboration in a single conversation.
|
||||
|
||||
## Reference & Docs
|
||||
|
||||
- [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion
|
||||
|
||||
+73
-77
@@ -31,10 +31,9 @@
|
||||
- [开始使用](#开始使用)
|
||||
- [快速开始](#快速开始)
|
||||
- [在 Agent 中使用](#在-agent-中使用)
|
||||
- [新功能](#新功能)
|
||||
- [功能特性](#功能特性)
|
||||
- [核心服务](#核心服务)
|
||||
- [安全设计](#安全设计)
|
||||
- [AI Agent Skills](#ai-agent-skills)
|
||||
- [参考与文档](#参考与文档)
|
||||
- [贡献指南](#贡献指南)
|
||||
|
||||
@@ -147,43 +146,35 @@ dws auth login
|
||||
```bash
|
||||
dws contact user search --keyword "悟空" # 搜索联系人
|
||||
dws calendar event list # 查看日历日程
|
||||
dws todo task create --title "季度汇报" --executors "<userId>" # 创建待办
|
||||
dws todo task create --title "季度汇报" --executors "<your-userId>" # 创建待办(请替换为真实 userId)
|
||||
dws todo task list --dry-run # 预览操作但不执行
|
||||
```
|
||||
|
||||
## 在 Agent 中使用
|
||||
|
||||
dws 是为 AI Agent 设计的 CLI 工具。以下是接入指南。
|
||||
|
||||
### 1. 环境配置
|
||||
dws 是为 AI Agent 设计的 CLI 工具。请先完成[安装](#安装)和[开始使用](#开始使用),然后配置 Agent 环境:
|
||||
|
||||
```bash
|
||||
# 安装 dws
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh
|
||||
|
||||
# 通过环境变量配置认证(Agent 推荐方式,无需交互式登录)
|
||||
export DWS_CLIENT_ID=<your-app-key>
|
||||
export DWS_CLIENT_SECRET=<your-app-secret>
|
||||
dws auth login
|
||||
```
|
||||
|
||||
### 2. Agent 调用模式
|
||||
### Agent 调用模式
|
||||
|
||||
```bash
|
||||
# 使用 --yes 跳过确认提示(Agent 必须)
|
||||
dws todo task create --title "Review PR" --yes
|
||||
dws todo task create --title "Review PR" --executors "<your-userId>" --yes
|
||||
|
||||
# 使用 --dry-run 预览操作(安全执行)
|
||||
dws contact user search --keyword "test" --dry-run
|
||||
dws contact user search --keyword "张三" --dry-run
|
||||
|
||||
# 使用 --jq 精确提取(节省 token)
|
||||
dws contact user search --keyword "test" --jq '.response.content'
|
||||
|
||||
# 使用 --fields 只返回需要的字段
|
||||
dws calendar event list --fields response
|
||||
dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserName, dept: .depts[0].deptName, userId}'
|
||||
```
|
||||
|
||||
### 3. Schema 发现(Agent 自主探索)
|
||||
### Schema 发现
|
||||
|
||||
Agent 无需预置所有命令知识,通过 `dws schema` 动态发现可用能力:
|
||||
|
||||
@@ -195,12 +186,42 @@ dws schema --jq '.products[] | {id, tool_count: (.tools | length)}'
|
||||
dws schema aitable.query_records --jq '.tool.input_schema'
|
||||
|
||||
# 第三步:构造正确的调用
|
||||
dws aitable record query --base-id "xxx" --table-id "yyy" --limit 10
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
```
|
||||
|
||||
### 4. 错误容忍
|
||||
### Agent Skills
|
||||
|
||||
dws 的智能纠错引擎对 Agent 生成的常见错误有高容忍度:
|
||||
仓库为每个钉钉产品提供 Agent Skill(`SKILL.md`),安装后 Claude Code / Cursor 等工具可直接使用钉钉能力:
|
||||
|
||||
```bash
|
||||
# 安装 skills 到当前项目
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
|
||||
|
||||
编写您自己的 Agent Skill,与 dws 内置 Skill 搭配构建跨产品工作流:**ISV Skill → dws Skill → 钉钉开放平台 API(强制鉴权 + 全链路审计)**。
|
||||
|
||||
## 功能特性
|
||||
|
||||
<details>
|
||||
<summary><strong>智能输入纠错</strong> — 自动修正 AI 模型常见的参数错误 <code>v1.0.1</code></summary>
|
||||
|
||||
内置 Pipeline 纠错引擎,支持命名风格转换、粘连参数拆分、拼写模糊匹配:
|
||||
|
||||
```bash
|
||||
# 命名风格自动转换 (camelCase / snake_case / UPPER → kebab-case)
|
||||
dws aitable record query --baseId BASE_ID --tableId TABLE_ID # 自动纠正为 --base-id --table-id
|
||||
|
||||
# 粘连参数自动拆分
|
||||
dws contact user search --keyword "张三" --timeout30 # 自动拆分为 --timeout 30
|
||||
|
||||
# 拼写错误模糊匹配
|
||||
dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-id → --table-id
|
||||
|
||||
# 参数值归一化 (布尔 / 数字 / 日期 / 枚举)
|
||||
# "yes" → true, "1,000" → 1000, "2024/03/29" → "2024-03-29", "ACTIVE" → "active"
|
||||
```
|
||||
|
||||
| Agent 输出 | dws 自动纠正为 |
|
||||
|-----------|--------------|
|
||||
@@ -210,53 +231,24 @@ dws 的智能纠错引擎对 Agent 生成的常见错误有高容忍度:
|
||||
| `--USER-ID` | `--user-id` |
|
||||
| `--user_name` | `--user-name` |
|
||||
|
||||
### 5. Agent Skill 集成
|
||||
</details>
|
||||
|
||||
安装 Agent Skills 后,Claude Code / Cursor 等工具可直接使用钉钉能力:
|
||||
<details>
|
||||
<summary><strong>jq 过滤 & 字段筛选</strong> — 精确控制输出,减少 token 消耗 <code>v1.0.1</code></summary>
|
||||
|
||||
```bash
|
||||
# 安装 skills 到当前项目
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> 详见 [AI Agent Skills](#ai-agent-skills) 章节。
|
||||
|
||||
## 新功能
|
||||
|
||||
### 智能输入纠错
|
||||
|
||||
dws 内置 Pipeline 纠错引擎,自动修正 AI 模型常见的参数错误,无需人工干预:
|
||||
|
||||
```bash
|
||||
# 命名风格自动转换 (camelCase / snake_case / UPPER → kebab-case)
|
||||
dws aitable record query --baseId x --tableId y # 自动纠正为 --base-id --table-id
|
||||
|
||||
# 粘连参数自动拆分
|
||||
dws contact user search --keyword "test" --timeout30 # 自动拆分为 --timeout 30
|
||||
|
||||
# 拼写错误模糊匹配
|
||||
dws aitable record query --base-id x --tabel-id y # --tabel-id → --table-id
|
||||
|
||||
# 参数值归一化 (布尔 / 数字 / 日期 / 枚举)
|
||||
# "yes" → true, "1,000" → 1000, "2024/03/29" → "2024-03-29", "ACTIVE" → "active"
|
||||
```
|
||||
|
||||
### JSON 字段筛选 & jq 过滤
|
||||
|
||||
精确控制输出内容,减少 token 消耗:
|
||||
|
||||
```bash
|
||||
# 只返回指定字段
|
||||
dws aitable record query --base-id x --table-id y --fields invocation,response
|
||||
|
||||
# 内置 jq 表达式
|
||||
dws aitable record query --base-id x --table-id y --jq '.invocation.params'
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
|
||||
dws schema --jq '.products[] | {id, tools: (.tools | length)}'
|
||||
|
||||
# 只返回指定字段
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
|
||||
```
|
||||
|
||||
### Schema 自省
|
||||
</details>
|
||||
|
||||
Agent 可以在调用前查询任何工具的参数结构:
|
||||
<details>
|
||||
<summary><strong>Schema 自省</strong> — 调用前查询任意工具的参数结构 <code>v1.0.1</code></summary>
|
||||
|
||||
```bash
|
||||
dws schema # 列出所有产品和工具
|
||||
@@ -265,6 +257,27 @@ dws schema aitable.query_records --jq '.tool.input_schema.required' # 查看
|
||||
dws schema --jq '.products[].id' # 提取所有产品 ID
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>管道 & 文件输入</strong> — 从文件或 stdin 读取 flag 值 <code>v1.0.1</code></summary>
|
||||
|
||||
```bash
|
||||
# 从文件读取消息内容
|
||||
dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
--title "周报" --text @report.md
|
||||
|
||||
# 通过管道传入内容
|
||||
cat report.md | dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
--title "周报"
|
||||
|
||||
# 显式从 stdin 读取
|
||||
dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
--title "周报" --text @-
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 核心服务
|
||||
|
||||
| 服务 | 命令 | 描述 |
|
||||
@@ -273,7 +286,7 @@ dws schema --jq '.products[].id' # 提取所有产品 ID
|
||||
| 群聊 | `chat` | 群管理 / 群成员 / 机器人消息 / Webhook |
|
||||
| 日历 | `calendar` | 日程 / 会议室 / 闲忙 |
|
||||
| 待办 | `todo` | 任务管理 |
|
||||
| 审批 | `approval` | 流程 / 表单 / 实例 |
|
||||
| 审批 | `oa` | 流程 / 表单 / 实例 |
|
||||
| 考勤 | `attendance` | 打卡 / 排班 / 统计 |
|
||||
| DING | `ding` | DING 消息 / 发送 / 撤回 |
|
||||
| 日志 | `report` | 日志 / 模版 / 统计 |
|
||||
@@ -335,23 +348,6 @@ dws schema --jq '.products[].id' # 提取所有产品 ID
|
||||
|
||||
> 发现安全漏洞?请通过 [GitHub Security Advisories](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/security/advisories/new) 报告,详见 [SECURITY.md](./SECURITY.md)。
|
||||
|
||||
## AI Agent Skills
|
||||
|
||||
仓库为每个钉钉产品提供 Agent Skill(`SKILL.md`),安装脚本会自动部署到 `~/.agents/skills/dws`。
|
||||
|
||||
```bash
|
||||
# 仅安装 skills 到当前项目
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
|
||||
|
||||
### ISV Skill 联调
|
||||
|
||||
编写您自己的 Agent Skill,与 dws 内置 skill 搭配构建跨产品工作流:**ISV Skill → dws Skill → 钉钉开放平台 API(强制鉴权 + 全链路审计)**。
|
||||
|
||||
**示例**:CRM Skill 调用日历 Skill 为客户创建会议,再通过待办 Skill 分配跟进任务 — AI Agent 在一次对话中完成跨系统协作。
|
||||
|
||||
## 参考与文档
|
||||
|
||||
- [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全
|
||||
|
||||
@@ -189,6 +189,9 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
loader := cli.EnvironmentLoader{
|
||||
LookupEnv: os.LookupEnv,
|
||||
CatalogBaseURLOverride: DiscoveryBaseURL(),
|
||||
AuthTokenFunc: func(ctx context.Context) string {
|
||||
return resolveRuntimeAuthToken(ctx, "")
|
||||
},
|
||||
}
|
||||
runner := newCommandRunnerWithFlags(loader, flags)
|
||||
|
||||
|
||||
@@ -171,6 +171,15 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
return executor.Result{}, err
|
||||
}
|
||||
|
||||
if bizErr := detectBusinessError(callResult.Content); bizErr != "" {
|
||||
return executor.Result{}, apperrors.NewAPI(bizErr,
|
||||
apperrors.WithOperation("tools/call"),
|
||||
apperrors.WithReason("business_error"),
|
||||
apperrors.WithServerKey(invocation.CanonicalProduct),
|
||||
apperrors.WithHint("The API returned a business-level error. Check required parameters and values."),
|
||||
)
|
||||
}
|
||||
|
||||
invocation.Implemented = true
|
||||
response := map[string]any{
|
||||
"endpoint": transport.RedactURL(endpoint),
|
||||
@@ -279,6 +288,27 @@ func resolveIdentityHeaders() map[string]string {
|
||||
return headers
|
||||
}
|
||||
|
||||
// detectBusinessError checks the MCP response content for DingTalk business
|
||||
// errors (success=false + errorCode/errorMsg) that are not flagged at the MCP
|
||||
// protocol level. Returns the error message, or "" if the response is OK.
|
||||
func detectBusinessError(content map[string]any) string {
|
||||
success, ok := content["success"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
b, ok := success.(bool)
|
||||
if !ok || b {
|
||||
return ""
|
||||
}
|
||||
if msg, ok := content["errorMsg"].(string); ok && strings.TrimSpace(msg) != "" {
|
||||
return strings.TrimSpace(msg)
|
||||
}
|
||||
if code, ok := content["errorCode"].(string); ok && strings.TrimSpace(code) != "" {
|
||||
return "business error: code " + strings.TrimSpace(code)
|
||||
}
|
||||
return "business error: success=false"
|
||||
}
|
||||
|
||||
// extractMCPErrorMessage builds an error message from a ToolCallResult with
|
||||
// isError=true. It extracts text from content blocks when available.
|
||||
func extractMCPErrorMessage(result transport.ToolCallResult) string {
|
||||
|
||||
@@ -585,10 +585,23 @@ func collectOverrides(cmd *cobra.Command, specs []FlagSpec, guard *StdinGuard) (
|
||||
|
||||
func schemaPayload(catalog ir.Catalog, args []string) (map[string]any, error) {
|
||||
if len(args) == 0 {
|
||||
products := make([]map[string]any, 0, len(catalog.Products))
|
||||
for _, p := range catalog.Products {
|
||||
tools := make([]map[string]any, 0, len(p.Tools))
|
||||
for _, t := range p.Tools {
|
||||
tools = append(tools, compactTool(t))
|
||||
}
|
||||
products = append(products, map[string]any{
|
||||
"id": p.ID,
|
||||
"name": p.DisplayName,
|
||||
"description": p.Description,
|
||||
"tools": tools,
|
||||
})
|
||||
}
|
||||
return map[string]any{
|
||||
"kind": "schema",
|
||||
"products": catalog.Products,
|
||||
"count": len(catalog.Products),
|
||||
"count": len(products),
|
||||
"products": products,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -597,14 +610,34 @@ func schemaPayload(catalog ir.Catalog, args []string) (map[string]any, error) {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("unknown canonical schema path %q", args[0]))
|
||||
}
|
||||
return map[string]any{
|
||||
"kind": "schema",
|
||||
"path": args[0],
|
||||
"product": product,
|
||||
"tool": tool,
|
||||
"required": requiredFields(tool.InputSchema),
|
||||
"kind": "schema",
|
||||
"path": args[0],
|
||||
"product": map[string]any{"id": product.ID, "name": product.DisplayName},
|
||||
"tool": compactTool(tool),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// compactTool returns a lean representation of a tool for schema
|
||||
// output, keeping only the fields that AI agents and developers
|
||||
// need: name, description, parameters, and sensitivity flag.
|
||||
func compactTool(t ir.ToolDescriptor) map[string]any {
|
||||
tool := map[string]any{
|
||||
"name": t.RPCName,
|
||||
"title": t.Title,
|
||||
"description": t.Description,
|
||||
"sensitive": t.Sensitive,
|
||||
}
|
||||
|
||||
if props, ok := t.InputSchema["properties"]; ok {
|
||||
tool["parameters"] = props
|
||||
}
|
||||
if req := requiredFields(t.InputSchema); len(req) > 0 {
|
||||
tool["required"] = req
|
||||
}
|
||||
|
||||
return tool
|
||||
}
|
||||
|
||||
func confirmSensitiveTool(cmd *cobra.Command, tool ir.ToolDescriptor, guard *StdinGuard) error {
|
||||
if !tool.Sensitive {
|
||||
return nil
|
||||
|
||||
@@ -89,6 +89,9 @@ type EnvironmentLoader struct {
|
||||
// DiscoveryTimeout overrides the default timeout for live registry discovery.
|
||||
// Zero means use defaultDiscoveryTimeout.
|
||||
DiscoveryTimeout time.Duration
|
||||
// AuthTokenFunc returns an access token for MCP discovery requests
|
||||
// (initialize, tools/list). When nil, discovery runs without auth.
|
||||
AuthTokenFunc func(context.Context) string
|
||||
}
|
||||
|
||||
type cachedCatalogState struct {
|
||||
@@ -125,6 +128,11 @@ func (l EnvironmentLoader) Load(ctx context.Context) (ir.Catalog, error) {
|
||||
}
|
||||
|
||||
transportClient := transport.NewClient(nil)
|
||||
if l.AuthTokenFunc != nil {
|
||||
if token := l.AuthTokenFunc(ctx); token != "" {
|
||||
transportClient = transportClient.WithAuth(token, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Use a bounded context so discovery doesn't hang in test or CI environments.
|
||||
timeout := defaultDiscoveryTimeout
|
||||
|
||||
+337
-4
@@ -14,13 +14,15 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -41,7 +43,8 @@ func (todoHandler) Name() string {
|
||||
func (todoHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "todo",
|
||||
Short: "Todo helper overrides",
|
||||
Short: i18n.T("待办任务管理"),
|
||||
Long: i18n.T("管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。"),
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
@@ -60,11 +63,94 @@ func (todoHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
task.AddCommand(newTodoTaskListCommand(runner))
|
||||
task.AddCommand(
|
||||
newTodoTaskCreateCommand(runner),
|
||||
newTodoTaskListCommand(runner),
|
||||
newTodoTaskUpdateCommand(runner),
|
||||
newTodoTaskDoneCommand(runner),
|
||||
newTodoTaskGetCommand(runner),
|
||||
newTodoTaskDeleteCommand(runner),
|
||||
)
|
||||
root.AddCommand(task)
|
||||
return root
|
||||
}
|
||||
|
||||
// ── create ─────────────────────────────────────────────────
|
||||
|
||||
func newTodoTaskCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: i18n.T("创建待办"),
|
||||
Example: ` dws todo task create --title "修复线上Bug" --executors userId1,userId2 --priority 40
|
||||
dws todo task create --title "提交报告" --executors userId1 --due "2026-03-10T18:00:00+08:00"
|
||||
|
||||
# 查询 userId: dws contact user search --keyword "姓名"`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
title := flagOrFallback(cmd, "title", "subject", "content")
|
||||
if strings.TrimSpace(title) == "" {
|
||||
return apperrors.NewValidation("--title is required")
|
||||
}
|
||||
executorsStr, _ := cmd.Flags().GetString("executors")
|
||||
if strings.TrimSpace(executorsStr) == "" {
|
||||
return apperrors.NewValidation("--executors is required")
|
||||
}
|
||||
executorIds := parseExecutorIds(executorsStr)
|
||||
|
||||
vo := map[string]any{
|
||||
"subject": title,
|
||||
"executorIds": executorIds,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("due"); v != "" {
|
||||
ms, err := parseISOTimeToMillis("due", v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vo["dueTime"] = ms
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("priority"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
vo["priority"] = n
|
||||
}
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("recurrence"); v != "" {
|
||||
vo["recurrence"] = v
|
||||
}
|
||||
params := map[string]any{"PersonalTodoCreateVO": vo}
|
||||
|
||||
invocation := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
"todo",
|
||||
"create_personal_todo",
|
||||
params,
|
||||
)
|
||||
invocation.DryRun = commandDryRun(cmd)
|
||||
result, err := runner.Run(cmd.Context(), invocation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
|
||||
cmd.Flags().String("title", "", i18n.T("待办标题 (必填)"))
|
||||
cmd.Flags().String("executors", "", i18n.T("执行者 userId 列表 (必填)"))
|
||||
cmd.Flags().String("due", "", i18n.T("截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)"))
|
||||
cmd.Flags().String("priority", "", i18n.T("优先级: 10低/20普通/30较高/40紧急"))
|
||||
cmd.Flags().String("recurrence", "", i18n.T("循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1"))
|
||||
|
||||
cmd.Flags().String("subject", "", i18n.T("--title 的别名"))
|
||||
cmd.Flags().String("content", "", i18n.T("--title 的别名"))
|
||||
_ = cmd.Flags().MarkHidden("subject")
|
||||
_ = cmd.Flags().MarkHidden("content")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── list ───────────────────────────────────────────────────
|
||||
|
||||
func newTodoTaskListCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -88,6 +174,7 @@ func newTodoTaskListCommand(runner executor.Runner) *cobra.Command {
|
||||
)
|
||||
|
||||
if size <= todoListPageSizeMax {
|
||||
invocation.DryRun = commandDryRun(cmd)
|
||||
result, err := runner.Run(cmd.Context(), invocation)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -158,6 +245,252 @@ func newTodoTaskListCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── update ─────────────────────────────────────────────────
|
||||
|
||||
func newTodoTaskUpdateCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: i18n.T("修改待办任务"),
|
||||
Example: ` dws todo task update --task-id <taskId> --title "新标题"
|
||||
dws todo task update --task-id <taskId> --priority 40 --due "2026-03-10T18:00:00+08:00"
|
||||
dws todo task update --task-id <taskId> --done true
|
||||
|
||||
# 查询 taskId: dws todo task list`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
taskID, _ := cmd.Flags().GetString("task-id")
|
||||
if strings.TrimSpace(taskID) == "" {
|
||||
return apperrors.NewValidation("--task-id is required")
|
||||
}
|
||||
inner := map[string]any{
|
||||
"taskId": taskID,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("title"); v != "" {
|
||||
inner["subject"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("due"); v != "" {
|
||||
ms, err := parseISOTimeToMillis("due", v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inner["dueTime"] = ms
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("priority"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
inner["priority"] = n
|
||||
}
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("done"); v != "" {
|
||||
inner["isDone"] = v == "true"
|
||||
}
|
||||
params := map[string]any{"TodoUpdateRequest": inner}
|
||||
|
||||
invocation := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
"todo",
|
||||
"update_todo_task",
|
||||
params,
|
||||
)
|
||||
invocation.DryRun = commandDryRun(cmd)
|
||||
result, err := runner.Run(cmd.Context(), invocation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
|
||||
cmd.Flags().String("task-id", "", i18n.T("待办任务 ID (必填)"))
|
||||
cmd.Flags().String("title", "", i18n.T("新标题"))
|
||||
cmd.Flags().String("due", "", i18n.T("截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)"))
|
||||
cmd.Flags().String("priority", "", i18n.T("优先级: 10低/20普通/30较高/40紧急"))
|
||||
cmd.Flags().String("done", "", i18n.T("完成状态: true/false"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── done ───────────────────────────────────────────────────
|
||||
|
||||
func newTodoTaskDoneCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "done",
|
||||
Short: i18n.T("修改执行者的待办完成状态"),
|
||||
Example: ` dws todo task done --task-id <taskId> --status true
|
||||
dws todo task done --task-id <taskId> --status false
|
||||
|
||||
# 查询 taskId: dws todo task list`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
taskID, _ := cmd.Flags().GetString("task-id")
|
||||
if strings.TrimSpace(taskID) == "" {
|
||||
return apperrors.NewValidation("--task-id is required")
|
||||
}
|
||||
status, _ := cmd.Flags().GetString("status")
|
||||
if strings.TrimSpace(status) == "" {
|
||||
return apperrors.NewValidation("--status is required")
|
||||
}
|
||||
params := map[string]any{
|
||||
"taskId": taskID,
|
||||
"isDone": status,
|
||||
}
|
||||
|
||||
invocation := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
"todo",
|
||||
"update_todo_done_status",
|
||||
params,
|
||||
)
|
||||
invocation.DryRun = commandDryRun(cmd)
|
||||
result, err := runner.Run(cmd.Context(), invocation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
|
||||
cmd.Flags().String("task-id", "", i18n.T("待办任务 ID (必填)"))
|
||||
cmd.Flags().String("status", "", i18n.T("完成状态: true=已完成, false=未完成 (必填)"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── get ────────────────────────────────────────────────────
|
||||
|
||||
func newTodoTaskGetCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "get",
|
||||
Short: i18n.T("待办详情"),
|
||||
Example: ` dws todo task get --task-id <taskId>
|
||||
|
||||
# 查询 taskId: dws todo task list`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
taskID, _ := cmd.Flags().GetString("task-id")
|
||||
if strings.TrimSpace(taskID) == "" {
|
||||
return apperrors.NewValidation("--task-id is required")
|
||||
}
|
||||
params := map[string]any{
|
||||
"taskId": taskID,
|
||||
}
|
||||
|
||||
invocation := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
"todo",
|
||||
"query_todo_detail",
|
||||
params,
|
||||
)
|
||||
invocation.DryRun = commandDryRun(cmd)
|
||||
result, err := runner.Run(cmd.Context(), invocation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
|
||||
cmd.Flags().String("task-id", "", i18n.T("待办任务 ID (必填)"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── delete ─────────────────────────────────────────────────
|
||||
|
||||
func newTodoTaskDeleteCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: i18n.T("删除待办"),
|
||||
Example: ` dws todo task delete --task-id <taskId>
|
||||
dws todo task delete --task-id <taskId> --yes
|
||||
|
||||
# 查询 taskId: dws todo task list`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
taskID, _ := cmd.Flags().GetString("task-id")
|
||||
if strings.TrimSpace(taskID) == "" {
|
||||
return apperrors.NewValidation("--task-id is required")
|
||||
}
|
||||
if !confirmDeletePrompt(cmd, i18n.T("待办"), taskID) {
|
||||
return nil
|
||||
}
|
||||
params := map[string]any{
|
||||
"taskId": taskID,
|
||||
}
|
||||
|
||||
invocation := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
"todo",
|
||||
"delete_todo",
|
||||
params,
|
||||
)
|
||||
invocation.DryRun = commandDryRun(cmd)
|
||||
result, err := runner.Run(cmd.Context(), invocation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
|
||||
cmd.Flags().String("task-id", "", i18n.T("待办任务 ID (必填)"))
|
||||
cmd.Flags().Bool("yes", false, i18n.T("跳过确认直接删除"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────
|
||||
|
||||
// flagOrFallback returns the first non-empty value among the given flag names.
|
||||
func flagOrFallback(cmd *cobra.Command, names ...string) string {
|
||||
for _, name := range names {
|
||||
if v, _ := cmd.Flags().GetString(name); strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseExecutorIds splits "id1,id2" into []string for the MCP executorIds array.
|
||||
func parseExecutorIds(s string) []string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
ids := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if id := strings.TrimSpace(p); id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// parseISOTimeToMillis parses an ISO-8601 datetime string and returns Unix
|
||||
// milliseconds. It supports timezone offsets (e.g. +08:00) and UTC "Z" suffix.
|
||||
func parseISOTimeToMillis(flagName, value string) (int64, error) {
|
||||
formats := []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05Z07:00",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04:05",
|
||||
}
|
||||
for _, layout := range formats {
|
||||
if t, err := time.Parse(layout, value); err == nil {
|
||||
return t.UnixMilli(), nil
|
||||
}
|
||||
}
|
||||
return 0, apperrors.NewValidation(
|
||||
fmt.Sprintf("--%s format error, use ISO-8601 e.g. 2026-03-10T18:00:00+08:00", flagName),
|
||||
)
|
||||
}
|
||||
|
||||
// ── list pagination helpers ────────────────────────────────
|
||||
|
||||
func normalizePage(raw string) string {
|
||||
if trimmed := strings.TrimSpace(raw); trimmed != "" {
|
||||
return trimmed
|
||||
|
||||
@@ -9,49 +9,51 @@
|
||||
"AI诚聘": "AI Recruitment",
|
||||
"Base ID (必填)": "Base ID (required)",
|
||||
"MCP 服务返回了无法解析的协议响应;检查服务版本或上游代理。": "MCP service returned an unparseable protocol response; check service version or upstream proxy.",
|
||||
"OSS 上传失败 HTTP %d: %s": "OSS uploadfailed HTTP %d: %s",
|
||||
"OSS 上传失败 HTTP %d: %s": "OSS upload failed HTTP %d: %s",
|
||||
"[%d] 轮询中... (%ds)": "[%d] polling ... (%ds)",
|
||||
"prepare_attachment_upload 返回格式异常": "prepare_attachment_upload returned abnormal format",
|
||||
"refresh_token 刷新失败": "refresh_token refreshfailed",
|
||||
"refresh_token 刷新失败": "refresh_token refresh failed",
|
||||
"refresh_token 刷新失败,将尝试扫码登录": "refresh_token refresh failed, will attempt QR code login",
|
||||
"true=已完成, false=未完成": "true=alreadycompleted, false=un-completed",
|
||||
"true=已完成, false=未完成": "true=completed, false=not completed",
|
||||
"⏳ 等待授权中...": "⏳ Waiting for authorization...",
|
||||
"⚠️ 即将删除 %s: %s\\\n": "⚠️ About to delete %s: %s\\\n",
|
||||
"上传失败: %w": "uploadfailed: %w",
|
||||
"上传失败: %w": "upload failed: %w",
|
||||
"上游服务异常;可稍后重试,若持续失败请查看 recovery snapshot。": "Upstream service error; retry later. If persistent, check recovery snapshot.",
|
||||
"下载失败 (HTTP %d): %s": "downloadfailed (HTTP %d): %s",
|
||||
"下载失败 (HTTP %d): %s": "download failed (HTTP %d): %s",
|
||||
"下载失败: %w": "Download failed: %w",
|
||||
"不是文件: ": "notisfile:",
|
||||
"不是文件: ": "not a file: ",
|
||||
"人才查询": "Talent query",
|
||||
"使用授权码换取 Access Token...": "Exchanging authorization code for Access Token...",
|
||||
"保存 token 失败": "Failed to save token",
|
||||
"保存Keyword subscription规则": "saveKeyword subscriptionrule",
|
||||
"写入文件失败: %w": "writefilefailed: %w",
|
||||
"准备上传失败: %w": "prepareuploadfailed: %w",
|
||||
"分页查询审批实例": "paginated queryapproval instance",
|
||||
"创建 / 查询 / 更新 / 删除待办": "create / query / update / deletetodo",
|
||||
"创建文件失败: %w": "createfilefailed: %w",
|
||||
"保存Keyword subscription规则": "Save keyword subscription rule",
|
||||
"写入文件失败: %w": "write file failed: %w",
|
||||
"准备上传失败: %w": "prepare upload failed: %w",
|
||||
"分页查询审批实例": "Paginated query approval instances",
|
||||
"创建 / 查询 / 更新 / 删除待办": "Create / query / update / delete todo",
|
||||
"创建待办": "Create todo",
|
||||
"创建文件失败: %w": "create file failed: %w",
|
||||
"创建请求失败": "Failed to create request",
|
||||
"删除 AI 表格": "delete AI table",
|
||||
"删除原因(可选)": "deletereason(optional)",
|
||||
"删除字段": "deletefield",
|
||||
"删除待办": "Delete todo",
|
||||
"删除 AI 表格": "Delete AI table",
|
||||
"删除原因(可选)": "Delete reason (optional)",
|
||||
"删除字段": "Delete field",
|
||||
"删除指定 Base(高风险、不可逆)。使用 --yes 跳过确认。": "Delete specified Base (high risk, irreversible). Use --yes to skip confirmation.",
|
||||
"删除指定字段(高风险、不可逆)。使用 --yes 跳过确认。": "Delete specified field (high risk, irreversible). Use --yes to skip confirmation.",
|
||||
"删除指定数据表(高风险、不可逆)。使用 --yes 跳过确认。": "Delete specified data table (high risk, irreversible). Use --yes to skip confirmation.",
|
||||
"删除数据表": "deletedata table",
|
||||
"删除数据表": "Delete data table",
|
||||
"删除行记录": "Delete row records",
|
||||
"参数不符合工具输入 schema;请检查 --json/--params/flags。": "Parameters do not match tool input schema; check --json/--params/flags.",
|
||||
"发送请求失败": "Failed to send request",
|
||||
"回调中未收到授权码": "callback un-receivetoauthorization code",
|
||||
"回调中未收到授权码": "Callback did not receive authorization code",
|
||||
"如果浏览器未自动打开,请手动访问:\n %s\n\n": "If the browser did not open automatically, please visit:\n %s\n\n",
|
||||
"字段": "field",
|
||||
"字段 ID (必填)": "field ID (required)",
|
||||
"字段管理": "fieldmanagement",
|
||||
"审批实例管理": "approval instancemanagement",
|
||||
"字段管理": "Field management",
|
||||
"审批实例管理": "Approval instance management",
|
||||
"岗位查询": "Position query",
|
||||
"工具协议不兼容;请检查服务版本、工具名或刷新发现缓存。": "Tool protocol incompatible; check service version, tool name, or refresh discovery cache.",
|
||||
"工具调用失败;请检查参数和上游服务状态。": "Tool invocation failed; check parameters and upstream service status.",
|
||||
"已取消操作": "alreadycanceloperations",
|
||||
"已取消操作": "Operation cancelled",
|
||||
"当前平台 %s 没有可用的预编译二进制": "No pre-built binary available for platform %s",
|
||||
"或者直接打开以下链接:": "Or open the following link:",
|
||||
"所有凭证已失效,请运行 dws auth login 重新登录": "All credentials have expired, please run dws auth login to re-authenticate",
|
||||
@@ -65,9 +67,9 @@
|
||||
"授权超时(5分钟),请重试": "Authorization timeout (5 minutes), please retry",
|
||||
"数据表": "data table",
|
||||
"数据表 ID (必填)": "data table ID (required)",
|
||||
"数据表管理": "data tablemanagement",
|
||||
"文件不存在: ": "filenot found: ",
|
||||
"文件为空": "fileis empty",
|
||||
"数据表管理": "Data table management",
|
||||
"文件不存在: ": "File not found: ",
|
||||
"文件为空": "File is empty",
|
||||
"文件过大 (%d 字节,限制 %d 字节)": "File too large (%d bytes, limit %d bytes)",
|
||||
"无法打开文件: %w": "Cannot open file: %w",
|
||||
"无法自动打开浏览器": "Cannot automatically open browser",
|
||||
@@ -82,7 +84,7 @@
|
||||
"服务返回了空结果;请稍后重试,必要时查看 recovery snapshot。": "Service returned empty results; retry later. Check recovery snapshot if persistent.",
|
||||
"未找到 MCP Server URL": "MCP Server URL not found",
|
||||
"未找到认证信息,请运行 dws auth login": "No credentials found, please run dws auth login",
|
||||
"未登录,请运行 dws auth login": "not logged in,please run dws auth login",
|
||||
"未登录,请运行 dws auth login": "Not logged in, please run dws auth login",
|
||||
"未知错误: %s": "Unknown error: %s",
|
||||
"本地文件一键上传到 AITable 附件字段": "One-click upload local file to AITable attachment field",
|
||||
"本地文件路径 (必填)": "Local file path (required)",
|
||||
@@ -90,36 +92,51 @@
|
||||
"构建上传请求失败: %w": "Failed to build upload request: %w",
|
||||
"查询入职人才": "Query onboarding candidates",
|
||||
"查询在招岗位": "Query open positions",
|
||||
"修改待办任务": "Update todo task",
|
||||
"修改执行者的待办完成状态": "Update executor todo done status",
|
||||
"优先级: 10低/20普通/30较高/40紧急": "Priority: 10=low/20=normal/30=high/40=urgent",
|
||||
"查询待办列表": "List todos",
|
||||
"待办": "todo",
|
||||
"待办任务 ID (必填)": "Todo task ID (required)",
|
||||
"待办任务管理": "Todo task management",
|
||||
"待办标题 (必填)": "Todo title (required)",
|
||||
"待办详情": "Todo detail",
|
||||
"循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1": "Recurring todo (requires --due); format: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1",
|
||||
"截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)": "Due time ISO-8601 (e.g. 2026-03-10T18:00:00+08:00)",
|
||||
"执行者 userId 列表 (必填)": "Executor userId list (required)",
|
||||
"检查登录状态后重试": "Check login status and retry",
|
||||
"检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。": "Check service connectivity and retry; if persistent, verify MCP service is responding.",
|
||||
"检查服务连通性和协议版本后重试": "Check service connectivity and protocol version, then retry",
|
||||
"检查认证、权限和参数后重试原命令": "Check authentication, permissions, and parameters, then retry",
|
||||
"新标题": "New title",
|
||||
"管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。": "Manage DingTalk personal todos: create, list, view detail, update, mark done, delete.",
|
||||
"步骤 1/3: 准备上传 %s (%d 字节, %s)...\\\n": "Step 1/3: Preparing upload %s (%d bytes, %s)...\\\n",
|
||||
"步骤 2/3: 上传文件到 OSS...": "Step 2/3: Uploading file to OSS...",
|
||||
"步骤 3/3: 上传完成!": "Step 3/3: Upload complete!",
|
||||
"用户拒绝了授权请求": "User rejected the authorization request",
|
||||
"确认删除? (yes/no): ": "confirmdelete? (yes/no): ",
|
||||
"完成状态: true/false": "Done status: true/false",
|
||||
"完成状态: true=已完成, false=未完成 (必填)": "Done status: true=completed, false=not completed (required)",
|
||||
"确认删除? (yes/no): ": "Confirm delete? (yes/no): ",
|
||||
"等待用户授权...": "Waiting for user authorization...",
|
||||
"结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。": "Result format does not match client expectations; check service protocol changes or rollback to latest working version.",
|
||||
"网络错误,继续重试...": "Network error, retrying...",
|
||||
"获取使用指引": "Get usage guide",
|
||||
"获取审批实例详情": "getapproval instancedetails",
|
||||
"获取审批实例详情": "Get approval instance details",
|
||||
"获取数量,超过 20 自动分页 (默认 20)": "Fetch count, auto-paginate if over 20 (default 20)",
|
||||
"获取版本信息失败 (HTTP %d)": "getversioninfofailed (HTTP %d)",
|
||||
"获取版本信息失败 (HTTP %d)": "Get version info failed (HTTP %d)",
|
||||
"解析令牌数据失败": "Failed to parse token data",
|
||||
"解析响应失败": "Failed to parse response",
|
||||
"解析版本信息失败: %w": "parseversioninfofailed: %w",
|
||||
"解析版本信息失败: %w": "Parse version info failed: %w",
|
||||
"解析设备授权数据失败": "Failed to parse device authorization data",
|
||||
"认证信息已失效,请重新执行上一条命令(最多重试两次)": "Credentials expired, re-execute the last command (max 2 retries)",
|
||||
"认证失败;请检查登录状态或产品 URL 覆盖。": "Authentication failed; check login status or product URL override.",
|
||||
"记录": "record",
|
||||
"记录 ID 列表,逗号分隔 (必填)": "record ID list,comma-separated (required)",
|
||||
"记录管理": "recordmanagement",
|
||||
"记录 ID 列表,逗号分隔 (必填)": "Record ID list, comma-separated (required)",
|
||||
"记录管理": "Record management",
|
||||
"设备授权流程失败(已重试 %d 次)": "Device authorization flow failed (retried %d times)",
|
||||
"设备授权码已过期": "Device authorization code has expired",
|
||||
"设备授权码已过期(%d 秒),请重试": "Device authorization code expired (%d seconds), please retry",
|
||||
"设置权限失败: %w": "settingspermissionfailed: %w",
|
||||
"设置权限失败: %w": "Set permission failed: %w",
|
||||
"请在浏览器中完成扫码授权。": "Please complete QR code authorization in the browser.",
|
||||
"请在浏览器中打开以下链接,并输入授权码:": "Please open the following link in your browser and enter the authorization code:",
|
||||
"请检查服务 endpoint 是否为空或格式不合法。": "Please check if the service endpoint is empty or has invalid format.",
|
||||
@@ -130,11 +147,13 @@
|
||||
"请求设备授权码失败": "Failed to request device authorization code",
|
||||
"读取 zip 条目失败: %w": "Failed to read zip entry: %w",
|
||||
"读取响应失败": "Failed to read response",
|
||||
"读取版本信息失败: %w": "readversioninfofailed: %w",
|
||||
"读取版本信息失败: %w": "Read version info failed: %w",
|
||||
"跳过确认直接删除": "Skip confirmation and delete directly",
|
||||
"--title 的别名": "Alias for --title",
|
||||
"调用被拒绝;请检查认证状态、租户身份或访问权限。": "Call rejected; check authentication status, tenant identity, or access permissions.",
|
||||
"轮询过快,间隔增加至 %ds": "Polling too fast, interval increased to %ds",
|
||||
"返回数据缺少 uploadUrl 或 fileToken": "Response data missing uploadUrl or fileToken",
|
||||
"附件工作流": "attachmentworkflow",
|
||||
"附件工作流": "Attachment workflow",
|
||||
"页码 (必填)": "page number (required)",
|
||||
"🔐 登录钉钉": "🔐 Login to DingTalk"
|
||||
}
|
||||
|
||||
@@ -30,8 +30,10 @@
|
||||
"准备上传失败: %w": "准备上传失败: %w",
|
||||
"分页查询审批实例": "分页查询审批实例",
|
||||
"创建 / 查询 / 更新 / 删除待办": "创建 / 查询 / 更新 / 删除待办",
|
||||
"创建待办": "创建待办",
|
||||
"创建文件失败: %w": "创建文件失败: %w",
|
||||
"创建请求失败": "创建请求失败",
|
||||
"删除待办": "删除待办",
|
||||
"删除 AI 表格": "删除 AI 表格",
|
||||
"删除原因(可选)": "删除原因(可选)",
|
||||
"删除字段": "删除字段",
|
||||
@@ -90,15 +92,30 @@
|
||||
"构建上传请求失败: %w": "构建上传请求失败: %w",
|
||||
"查询入职人才": "查询入职人才",
|
||||
"查询在招岗位": "查询在招岗位",
|
||||
"修改待办任务": "修改待办任务",
|
||||
"修改执行者的待办完成状态": "修改执行者的待办完成状态",
|
||||
"优先级: 10低/20普通/30较高/40紧急": "优先级: 10低/20普通/30较高/40紧急",
|
||||
"查询待办列表": "查询待办列表",
|
||||
"待办": "待办",
|
||||
"待办任务 ID (必填)": "待办任务 ID (必填)",
|
||||
"待办任务管理": "待办任务管理",
|
||||
"待办标题 (必填)": "待办标题 (必填)",
|
||||
"待办详情": "待办详情",
|
||||
"循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1": "循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1",
|
||||
"截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)": "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)",
|
||||
"执行者 userId 列表 (必填)": "执行者 userId 列表 (必填)",
|
||||
"检查登录状态后重试": "检查登录状态后重试",
|
||||
"检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。": "检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。",
|
||||
"检查服务连通性和协议版本后重试": "检查服务连通性和协议版本后重试",
|
||||
"检查认证、权限和参数后重试原命令": "检查认证、权限和参数后重试原命令",
|
||||
"新标题": "新标题",
|
||||
"管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。": "管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。",
|
||||
"步骤 1/3: 准备上传 %s (%d 字节, %s)...\\\n": "步骤 1/3: 准备上传 %s (%d 字节, %s)...\\\n",
|
||||
"步骤 2/3: 上传文件到 OSS...": "步骤 2/3: 上传文件到 OSS...",
|
||||
"步骤 3/3: 上传完成!": "步骤 3/3: 上传完成!",
|
||||
"用户拒绝了授权请求": "用户拒绝了授权请求",
|
||||
"完成状态: true/false": "完成状态: true/false",
|
||||
"完成状态: true=已完成, false=未完成 (必填)": "完成状态: true=已完成, false=未完成 (必填)",
|
||||
"确认删除? (yes/no): ": "确认删除? (yes/no): ",
|
||||
"等待用户授权...": "等待用户授权...",
|
||||
"结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。": "结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。",
|
||||
@@ -131,6 +148,8 @@
|
||||
"读取 zip 条目失败: %w": "读取 zip 条目失败: %w",
|
||||
"读取响应失败": "读取响应失败",
|
||||
"读取版本信息失败: %w": "读取版本信息失败: %w",
|
||||
"跳过确认直接删除": "跳过确认直接删除",
|
||||
"--title 的别名": "--title 的别名",
|
||||
"调用被拒绝;请检查认证状态、租户身份或访问权限。": "调用被拒绝;请检查认证状态、租户身份或访问权限。",
|
||||
"轮询过快,间隔增加至 %ds": "轮询过快,间隔增加至 %ds",
|
||||
"返回数据缺少 uploadUrl 或 fileToken": "返回数据缺少 uploadUrl 或 fileToken",
|
||||
|
||||
+89
-17
@@ -24,21 +24,22 @@ import (
|
||||
)
|
||||
|
||||
// SelectFields filters a JSON-serialisable payload to include only
|
||||
// the specified field names. It works on both objects and arrays:
|
||||
// - Object: returns a new object with only the matching keys.
|
||||
// - Array of objects: returns a new array where each element
|
||||
// contains only the matching keys.
|
||||
// - Other types: returned unchanged.
|
||||
// the specified field names, following the gh CLI convention:
|
||||
//
|
||||
// Field names are matched case-insensitively against top-level keys.
|
||||
// Nested field selection (e.g. "response.data") is not supported;
|
||||
// use --jq for complex queries.
|
||||
// 1. If the payload is an array of objects, filter each element.
|
||||
// 2. If the payload is an object containing a "data list" (a nested
|
||||
// array under a well-known key like "value", "items", "data",
|
||||
// "records", etc.), filter each element of that list.
|
||||
// 3. Otherwise, filter top-level keys.
|
||||
//
|
||||
// This approach lets callers write `--fields title,memberCount` and
|
||||
// get the right result regardless of the response envelope structure.
|
||||
// Field names are matched case-insensitively.
|
||||
func SelectFields(payload any, fields []string) any {
|
||||
if len(fields) == 0 {
|
||||
return payload
|
||||
}
|
||||
|
||||
// Normalise to a generic JSON structure.
|
||||
normalised := toGeneric(payload)
|
||||
|
||||
wanted := make(map[string]bool, len(fields))
|
||||
@@ -47,23 +48,85 @@ func SelectFields(payload any, fields []string) any {
|
||||
}
|
||||
|
||||
switch typed := normalised.(type) {
|
||||
case map[string]any:
|
||||
return filterMap(typed, wanted)
|
||||
case []any:
|
||||
result := make([]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
result = append(result, filterMap(m, wanted))
|
||||
return filterSlice(typed, wanted)
|
||||
case map[string]any:
|
||||
// Try to find a nested data list and filter its elements.
|
||||
if loc := findDataList(typed); loc != nil {
|
||||
filtered := filterSlice(loc.list, wanted)
|
||||
result := shallowCopyMap(typed)
|
||||
if loc.outerKey == "" {
|
||||
// Top-level: {items: [...]}
|
||||
result[loc.innerKey] = filtered
|
||||
} else {
|
||||
result = append(result, item)
|
||||
// Nested: {result: {value: [...]}}
|
||||
inner := shallowCopyMap(typed[loc.outerKey].(map[string]any))
|
||||
inner[loc.innerKey] = filtered
|
||||
result[loc.outerKey] = inner
|
||||
}
|
||||
return result
|
||||
}
|
||||
return result
|
||||
return filterMap(typed, wanted)
|
||||
default:
|
||||
return normalised
|
||||
}
|
||||
}
|
||||
|
||||
// dataListLocation describes where a data list was found in the
|
||||
// object tree.
|
||||
type dataListLocation struct {
|
||||
list []any
|
||||
outerKey string // "" if top-level
|
||||
innerKey string // the key containing the array
|
||||
}
|
||||
|
||||
// findDataList walks the object tree looking for the first array of
|
||||
// objects under well-known keys. It searches both top-level and one
|
||||
// level deep (e.g. result.value, response.items).
|
||||
func findDataList(m map[string]any) *dataListLocation {
|
||||
listKeys := []string{"value", "items", "results", "data", "list", "records", "tools", "servers", "products"}
|
||||
|
||||
// Top-level: {value: [...]}
|
||||
for _, key := range listKeys {
|
||||
if arr, ok := m[key].([]any); ok && len(arr) > 0 {
|
||||
if _, isMap := arr[0].(map[string]any); isMap {
|
||||
return &dataListLocation{list: arr, innerKey: key}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One level deep: {result: {value: [...]}}
|
||||
for _, outerKey := range []string{"result", "response", "data"} {
|
||||
inner, ok := m[outerKey].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, key := range listKeys {
|
||||
if arr, ok := inner[key].([]any); ok && len(arr) > 0 {
|
||||
if _, isMap := arr[0].(map[string]any); isMap {
|
||||
return &dataListLocation{list: arr, outerKey: outerKey, innerKey: key}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// filterSlice applies field filtering to each object element in a
|
||||
// slice. Non-object elements are passed through unchanged.
|
||||
func filterSlice(items []any, wanted map[string]bool) []any {
|
||||
result := make([]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
result = append(result, filterMap(m, wanted))
|
||||
} else {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ApplyJQ applies a jq expression to a JSON-serialisable payload and
|
||||
// writes the results to w. Each result value is written as a separate
|
||||
// line of JSON.
|
||||
@@ -112,6 +175,15 @@ func ApplyJQ(w io.Writer, payload any, expr string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// shallowCopyMap returns a shallow copy of the map.
|
||||
func shallowCopyMap(m map[string]any) map[string]any {
|
||||
result := make(map[string]any, len(m))
|
||||
for k, v := range m {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// filterMap returns a new map containing only keys that match the
|
||||
// wanted set (case-insensitive).
|
||||
func filterMap(m map[string]any, wanted map[string]bool) map[string]any {
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
|
||||
// --- SelectFields tests ---
|
||||
|
||||
func TestSelectFieldsObject(t *testing.T) {
|
||||
func TestSelectFieldsTopLevel(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"id": "123",
|
||||
"name": "Alice",
|
||||
@@ -60,11 +60,6 @@ func TestSelectFieldsObject(t *testing.T) {
|
||||
fields: []string{},
|
||||
want: payload,
|
||||
},
|
||||
{
|
||||
name: "whitespace in field names trimmed",
|
||||
fields: []string{" name ", " id "},
|
||||
want: map[string]any{"id": "123", "name": "Alice"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -115,24 +110,87 @@ func TestSelectFieldsArray(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectFieldsStruct(t *testing.T) {
|
||||
type testStruct struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Age int `json:"age"`
|
||||
func TestSelectFieldsNestedDataList(t *testing.T) {
|
||||
// Simulates: dws chat search --query "CLI" --fields title,memberCount
|
||||
payload := map[string]any{
|
||||
"result": map[string]any{
|
||||
"total": float64(3),
|
||||
"hasMore": false,
|
||||
"value": []any{
|
||||
map[string]any{"title": "CLI开源", "memberCount": float64(7), "extension": nil},
|
||||
map[string]any{"title": "CLI X", "memberCount": float64(10), "extension": nil},
|
||||
map[string]any{"title": "CLI Review", "memberCount": float64(3), "extension": nil},
|
||||
},
|
||||
},
|
||||
"success": true,
|
||||
}
|
||||
payload := testStruct{ID: "1", Name: "Alice", Age: 30}
|
||||
|
||||
got := SelectFields(payload, []string{"id", "name"})
|
||||
got := SelectFields(payload, []string{"title", "memberCount"})
|
||||
gotMap, ok := got.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected map after struct normalisation, got %T", got)
|
||||
t.Fatalf("expected map, got %T", got)
|
||||
}
|
||||
if _, hasAge := gotMap["age"]; hasAge {
|
||||
t.Error("should not have 'age' field")
|
||||
|
||||
// Top-level structure preserved
|
||||
if gotMap["success"] != true {
|
||||
t.Error("top-level 'success' should be preserved")
|
||||
}
|
||||
if gotMap["id"] != "1" || gotMap["name"] != "Alice" {
|
||||
t.Errorf("got %v", gotMap)
|
||||
|
||||
result, ok := gotMap["result"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("result should be map, got %T", gotMap["result"])
|
||||
}
|
||||
|
||||
value, ok := result["value"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("result.value should be array, got %T", result["value"])
|
||||
}
|
||||
|
||||
if len(value) != 3 {
|
||||
t.Fatalf("expected 3 items, got %d", len(value))
|
||||
}
|
||||
|
||||
first, ok := value[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("first item should be map, got %T", value[0])
|
||||
}
|
||||
if first["title"] != "CLI开源" {
|
||||
t.Errorf("title = %v, want CLI开源", first["title"])
|
||||
}
|
||||
if first["memberCount"] != float64(7) {
|
||||
t.Errorf("memberCount = %v, want 7", first["memberCount"])
|
||||
}
|
||||
if _, hasExt := first["extension"]; hasExt {
|
||||
t.Error("extension should be filtered out")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectFieldsTopLevelList(t *testing.T) {
|
||||
// Top-level items array
|
||||
payload := map[string]any{
|
||||
"items": []any{
|
||||
map[string]any{"id": "1", "name": "A", "extra": "x"},
|
||||
map[string]any{"id": "2", "name": "B", "extra": "y"},
|
||||
},
|
||||
"total": float64(2),
|
||||
}
|
||||
|
||||
got := SelectFields(payload, []string{"id", "name"})
|
||||
gotMap := got.(map[string]any)
|
||||
items := gotMap["items"].([]any)
|
||||
|
||||
for i, item := range items {
|
||||
m := item.(map[string]any)
|
||||
if _, has := m["extra"]; has {
|
||||
t.Errorf("item %d: should not have 'extra'", i)
|
||||
}
|
||||
if m["id"] == nil || m["name"] == nil {
|
||||
t.Errorf("item %d: missing id or name", i)
|
||||
}
|
||||
}
|
||||
// total preserved
|
||||
if gotMap["total"] != float64(2) {
|
||||
t.Error("total should be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,10 +322,15 @@ func TestApplyJQEmptyResult(t *testing.T) {
|
||||
// --- WriteFiltered integration tests ---
|
||||
|
||||
func TestWriteFilteredWithFields(t *testing.T) {
|
||||
// Simulates a typical API response with nested data list
|
||||
payload := map[string]any{
|
||||
"id": "123",
|
||||
"name": "test",
|
||||
"data": "secret",
|
||||
"result": map[string]any{
|
||||
"value": []any{
|
||||
map[string]any{"id": "1", "name": "test", "secret": "xxx"},
|
||||
map[string]any{"id": "2", "name": "test2", "secret": "yyy"},
|
||||
},
|
||||
},
|
||||
"success": true,
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := WriteFiltered(&buf, FormatJSON, payload, "id,name", ""); err != nil {
|
||||
@@ -277,11 +340,14 @@ func TestWriteFilteredWithFields(t *testing.T) {
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("unmarshal error: %v", err)
|
||||
}
|
||||
if _, hasData := got["data"]; hasData {
|
||||
t.Error("should not have 'data' field")
|
||||
result := got["result"].(map[string]any)
|
||||
items := result["value"].([]any)
|
||||
first := items[0].(map[string]any)
|
||||
if _, hasSecret := first["secret"]; hasSecret {
|
||||
t.Error("should not have 'secret' field")
|
||||
}
|
||||
if got["id"] != "123" || got["name"] != "test" {
|
||||
t.Errorf("got %v", got)
|
||||
if first["id"] != "1" || first["name"] != "test" {
|
||||
t.Errorf("got %v", first)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,13 @@ func WriteCommandPayload(cmd *cobra.Command, payload any, fallback Format) error
|
||||
if cmd == nil {
|
||||
return Write(io.Discard, fallback, payload)
|
||||
}
|
||||
return Write(cmd.OutOrStdout(), ResolveFormat(cmd, fallback), payload)
|
||||
return WriteFiltered(
|
||||
cmd.OutOrStdout(),
|
||||
ResolveFormat(cmd, fallback),
|
||||
payload,
|
||||
ResolveFields(cmd),
|
||||
ResolveJQ(cmd),
|
||||
)
|
||||
}
|
||||
|
||||
func Write(w io.Writer, format Format, payload any) error {
|
||||
@@ -146,7 +152,11 @@ func ResolveFields(cmd *cobra.Command) string {
|
||||
if cmd == nil {
|
||||
return ""
|
||||
}
|
||||
for _, flags := range []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags()} {
|
||||
for _, flags := range []*pflag.FlagSet{
|
||||
cmd.Flags(),
|
||||
cmd.InheritedFlags(),
|
||||
rootPersistentFlags(cmd),
|
||||
} {
|
||||
if flags == nil {
|
||||
continue
|
||||
}
|
||||
@@ -159,12 +169,18 @@ func ResolveFields(cmd *cobra.Command) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// ResolveJQ extracts the --jq flag value from the command.
|
||||
// ResolveJQ extracts the --jq flag value from the command. It checks
|
||||
// local flags, inherited flags, and root persistent flags because
|
||||
// --jq is registered as a root PersistentFlag.
|
||||
func ResolveJQ(cmd *cobra.Command) string {
|
||||
if cmd == nil {
|
||||
return ""
|
||||
}
|
||||
for _, flags := range []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags()} {
|
||||
for _, flags := range []*pflag.FlagSet{
|
||||
cmd.Flags(),
|
||||
cmd.InheritedFlags(),
|
||||
rootPersistentFlags(cmd),
|
||||
} {
|
||||
if flags == nil {
|
||||
continue
|
||||
}
|
||||
@@ -177,6 +193,13 @@ func ResolveJQ(cmd *cobra.Command) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func rootPersistentFlags(cmd *cobra.Command) *pflag.FlagSet {
|
||||
if root := cmd.Root(); root != nil {
|
||||
return root.PersistentFlags()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteJSON marshals payload as indented JSON and writes it to w.
|
||||
func WriteJSON(w io.Writer, payload any) error {
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
|
||||
@@ -6,12 +6,316 @@ import (
|
||||
|
||||
// ── task create ─────────────────────────────────────────────
|
||||
|
||||
func TestTodoTaskCreate_should_call_create_todo_task(t *testing.T) {
|
||||
func TestTodoTaskCreate_should_call_create_personal_todo(t *testing.T) {
|
||||
cap := setupTestDeps(t, "todo")
|
||||
root := buildRoot()
|
||||
err := execCmd(t, root, []string{"todo", "task", "create"}, nil)
|
||||
err := execCmd(t, root, []string{"todo", "task", "create"}, map[string]string{
|
||||
"title": "修复线上Bug",
|
||||
"executors": "userId1,userId2",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertToolName(t, cap, "create_todo_task")
|
||||
assertToolName(t, cap, "create_personal_todo")
|
||||
}
|
||||
|
||||
func TestTodoTaskCreate_should_pass_subject_and_executorIds(t *testing.T) {
|
||||
cap := setupTestDeps(t, "todo")
|
||||
root := buildRoot()
|
||||
_ = execCmd(t, root, []string{"todo", "task", "create"}, map[string]string{
|
||||
"title": "提交报告",
|
||||
"executors": "userId1",
|
||||
})
|
||||
assertToolArg(t, cap, "PersonalTodoCreateVO", map[string]any{
|
||||
"subject": "提交报告",
|
||||
"executorIds": []any{"userId1"},
|
||||
})
|
||||
}
|
||||
|
||||
func TestTodoTaskCreate_should_pass_priority(t *testing.T) {
|
||||
cap := setupTestDeps(t, "todo")
|
||||
root := buildRoot()
|
||||
_ = execCmd(t, root, []string{"todo", "task", "create"}, map[string]string{
|
||||
"title": "紧急任务",
|
||||
"executors": "userId1",
|
||||
"priority": "40",
|
||||
})
|
||||
last := cap.last()
|
||||
if last == nil {
|
||||
t.Fatal("no MCP call captured")
|
||||
}
|
||||
vo, ok := last.Args["PersonalTodoCreateVO"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected PersonalTodoCreateVO map, got %T", last.Args["PersonalTodoCreateVO"])
|
||||
}
|
||||
if vo["priority"] != float64(40) {
|
||||
t.Errorf("expected priority=40, got %v", vo["priority"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodoTaskCreate_should_error_when_title_missing(t *testing.T) {
|
||||
_ = setupTestDeps(t, "todo")
|
||||
root := buildRoot()
|
||||
err := execCmd(t, root, []string{"todo", "task", "create"}, map[string]string{
|
||||
"executors": "userId1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --title is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodoTaskCreate_should_error_when_executors_missing(t *testing.T) {
|
||||
_ = setupTestDeps(t, "todo")
|
||||
root := buildRoot()
|
||||
err := execCmd(t, root, []string{"todo", "task", "create"}, map[string]string{
|
||||
"title": "测试待办",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --executors is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodoTaskCreate_should_accept_subject_alias(t *testing.T) {
|
||||
cap := setupTestDeps(t, "todo")
|
||||
root := buildRoot()
|
||||
err := execCmd(t, root, []string{"todo", "task", "create"}, map[string]string{
|
||||
"subject": "通过别名创建",
|
||||
"executors": "userId1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertToolName(t, cap, "create_personal_todo")
|
||||
}
|
||||
|
||||
func TestTodoTaskCreate_should_not_call_mcp_in_dry_run(t *testing.T) {
|
||||
cap := setupTestDepsWithDryRun(t, "todo")
|
||||
root := buildRoot()
|
||||
_ = execCmd(t, root, []string{"todo", "task", "create"}, map[string]string{
|
||||
"title": "Dry Run",
|
||||
"executors": "userId1",
|
||||
})
|
||||
assertCallCount(t, cap, 0)
|
||||
}
|
||||
|
||||
// ── task list ───────────────────────────────────────────────
|
||||
|
||||
func TestTodoTaskList_should_call_get_user_todos(t *testing.T) {
|
||||
cap := setupTestDeps(t, "todo")
|
||||
root := buildRoot()
|
||||
err := execCmd(t, root, []string{"todo", "task", "list"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertToolName(t, cap, "get_user_todos_in_current_org")
|
||||
}
|
||||
|
||||
func TestTodoTaskList_should_pass_status_filter(t *testing.T) {
|
||||
cap := setupTestDeps(t, "todo")
|
||||
root := buildRoot()
|
||||
_ = execCmd(t, root, []string{"todo", "task", "list"}, map[string]string{
|
||||
"status": "false",
|
||||
})
|
||||
assertToolArg(t, cap, "isDone", "false")
|
||||
assertToolArg(t, cap, "todoStatus", "false")
|
||||
}
|
||||
|
||||
// ── task update ─────────────────────────────────────────────
|
||||
|
||||
func TestTodoTaskUpdate_should_call_update_todo_task(t *testing.T) {
|
||||
cap := setupTestDeps(t, "todo")
|
||||
root := buildRoot()
|
||||
err := execCmd(t, root, []string{"todo", "task", "update"}, map[string]string{
|
||||
"task-id": "TASK_001",
|
||||
"title": "新标题",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertToolName(t, cap, "update_todo_task")
|
||||
}
|
||||
|
||||
func TestTodoTaskUpdate_should_pass_TodoUpdateRequest(t *testing.T) {
|
||||
cap := setupTestDeps(t, "todo")
|
||||
root := buildRoot()
|
||||
_ = execCmd(t, root, []string{"todo", "task", "update"}, map[string]string{
|
||||
"task-id": "TASK_001",
|
||||
"title": "更新标题",
|
||||
})
|
||||
last := cap.last()
|
||||
if last == nil {
|
||||
t.Fatal("no MCP call captured")
|
||||
}
|
||||
req, ok := last.Args["TodoUpdateRequest"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected TodoUpdateRequest map, got %T", last.Args["TodoUpdateRequest"])
|
||||
}
|
||||
if req["taskId"] != "TASK_001" {
|
||||
t.Errorf("expected taskId=TASK_001, got %v", req["taskId"])
|
||||
}
|
||||
if req["subject"] != "更新标题" {
|
||||
t.Errorf("expected subject=更新标题, got %v", req["subject"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodoTaskUpdate_should_pass_done_flag(t *testing.T) {
|
||||
cap := setupTestDeps(t, "todo")
|
||||
root := buildRoot()
|
||||
_ = execCmd(t, root, []string{"todo", "task", "update"}, map[string]string{
|
||||
"task-id": "TASK_001",
|
||||
"done": "true",
|
||||
})
|
||||
last := cap.last()
|
||||
if last == nil {
|
||||
t.Fatal("no MCP call captured")
|
||||
}
|
||||
req, ok := last.Args["TodoUpdateRequest"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected TodoUpdateRequest map, got %T", last.Args["TodoUpdateRequest"])
|
||||
}
|
||||
if req["isDone"] != true {
|
||||
t.Errorf("expected isDone=true, got %v", req["isDone"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodoTaskUpdate_should_error_when_task_id_missing(t *testing.T) {
|
||||
_ = setupTestDeps(t, "todo")
|
||||
root := buildRoot()
|
||||
err := execCmd(t, root, []string{"todo", "task", "update"}, map[string]string{
|
||||
"title": "新标题",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --task-id is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodoTaskUpdate_should_not_call_mcp_in_dry_run(t *testing.T) {
|
||||
cap := setupTestDepsWithDryRun(t, "todo")
|
||||
root := buildRoot()
|
||||
_ = execCmd(t, root, []string{"todo", "task", "update"}, map[string]string{
|
||||
"task-id": "TASK_001",
|
||||
"title": "Dry Run",
|
||||
})
|
||||
assertCallCount(t, cap, 0)
|
||||
}
|
||||
|
||||
// ── task done ───────────────────────────────────────────────
|
||||
|
||||
func TestTodoTaskDone_should_call_update_todo_done_status(t *testing.T) {
|
||||
cap := setupTestDeps(t, "todo")
|
||||
root := buildRoot()
|
||||
err := execCmd(t, root, []string{"todo", "task", "done"}, map[string]string{
|
||||
"task-id": "TASK_001",
|
||||
"status": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertToolName(t, cap, "update_todo_done_status")
|
||||
}
|
||||
|
||||
func TestTodoTaskDone_should_pass_taskId_and_isDone(t *testing.T) {
|
||||
cap := setupTestDeps(t, "todo")
|
||||
root := buildRoot()
|
||||
_ = execCmd(t, root, []string{"todo", "task", "done"}, map[string]string{
|
||||
"task-id": "TASK_001",
|
||||
"status": "true",
|
||||
})
|
||||
assertToolArg(t, cap, "taskId", "TASK_001")
|
||||
assertToolArg(t, cap, "isDone", "true")
|
||||
}
|
||||
|
||||
func TestTodoTaskDone_should_error_when_task_id_missing(t *testing.T) {
|
||||
_ = setupTestDeps(t, "todo")
|
||||
root := buildRoot()
|
||||
err := execCmd(t, root, []string{"todo", "task", "done"}, map[string]string{
|
||||
"status": "true",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --task-id is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodoTaskDone_should_error_when_status_missing(t *testing.T) {
|
||||
_ = setupTestDeps(t, "todo")
|
||||
root := buildRoot()
|
||||
err := execCmd(t, root, []string{"todo", "task", "done"}, map[string]string{
|
||||
"task-id": "TASK_001",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --status is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// ── task get ────────────────────────────────────────────────
|
||||
|
||||
func TestTodoTaskGet_should_call_query_todo_detail(t *testing.T) {
|
||||
cap := setupTestDeps(t, "todo")
|
||||
root := buildRoot()
|
||||
err := execCmd(t, root, []string{"todo", "task", "get"}, map[string]string{
|
||||
"task-id": "TASK_001",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertToolName(t, cap, "query_todo_detail")
|
||||
}
|
||||
|
||||
func TestTodoTaskGet_should_pass_taskId(t *testing.T) {
|
||||
cap := setupTestDeps(t, "todo")
|
||||
root := buildRoot()
|
||||
_ = execCmd(t, root, []string{"todo", "task", "get"}, map[string]string{
|
||||
"task-id": "TASK_002",
|
||||
})
|
||||
assertToolArg(t, cap, "taskId", "TASK_002")
|
||||
}
|
||||
|
||||
func TestTodoTaskGet_should_error_when_task_id_missing(t *testing.T) {
|
||||
_ = setupTestDeps(t, "todo")
|
||||
root := buildRoot()
|
||||
err := execCmd(t, root, []string{"todo", "task", "get"}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --task-id is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// ── task delete ─────────────────────────────────────────────
|
||||
|
||||
func TestTodoTaskDelete_should_call_delete_todo(t *testing.T) {
|
||||
cap := setupTestDepsAutoConfirm(t, "todo")
|
||||
root := buildRoot()
|
||||
err := execCmd(t, root, []string{"todo", "task", "delete"}, map[string]string{
|
||||
"task-id": "TASK_DEL",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertToolName(t, cap, "delete_todo")
|
||||
}
|
||||
|
||||
func TestTodoTaskDelete_should_pass_taskId(t *testing.T) {
|
||||
cap := setupTestDepsAutoConfirm(t, "todo")
|
||||
root := buildRoot()
|
||||
_ = execCmd(t, root, []string{"todo", "task", "delete"}, map[string]string{
|
||||
"task-id": "TASK_DEL",
|
||||
})
|
||||
assertToolArg(t, cap, "taskId", "TASK_DEL")
|
||||
}
|
||||
|
||||
func TestTodoTaskDelete_should_error_when_task_id_missing(t *testing.T) {
|
||||
_ = setupTestDeps(t, "todo")
|
||||
root := buildRoot()
|
||||
err := execCmd(t, root, []string{"todo", "task", "delete"}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --task-id is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodoTaskDelete_should_not_call_mcp_in_dry_run(t *testing.T) {
|
||||
cap := setupTestDepsWithDryRun(t, "todo")
|
||||
root := buildRoot()
|
||||
_ = execCmd(t, root, []string{"todo", "task", "delete"}, map[string]string{
|
||||
"task-id": "TASK_001",
|
||||
})
|
||||
assertCallCount(t, cap, 0)
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) {
|
||||
}
|
||||
for _, want := range []string{
|
||||
"\"name\": \"dingtalk-workspace-cli\"",
|
||||
"\"DingTalk Workspace CLI\"",
|
||||
"DingTalk Workspace CLI",
|
||||
"\"postinstall\": \"node install.js\"",
|
||||
} {
|
||||
if !strings.Contains(string(packageJSON), want) {
|
||||
|
||||
Reference in New Issue
Block a user