Compare commits

...
21 Commits
Author SHA1 Message Date
fantiu fc22f53b92 Merge pull request #33 from fantiu/feat-application
feat(auth): persist client credentials for token refresh
2026-03-30 11:41:56 +08:00
fantiu 654bcc4ecb feat(auth): persist client credentials for token refresh
When logging in with --client-id and --client-secret, credentials are now
automatically persisted to enable automatic token refresh after expiration.
Client secret is securely stored in system Keychain, config file only
contains a reference.
2026-03-30 11:34:11 +08:00
fantiu 2175f2fe59 merge 2026-03-30 11:15:53 +08:00
fantiu 81ca098db1 调整readme 2026-03-30 11:10:15 +08:00
fantiu 3e3c17d686 Merge branch 'main' of github.com:fantiu/dingtalk-workspace-cli into feat-application
feat(auth): persist client credentials for token refresh

When logging in with --client-id and --client-secret, credentials are now
automatically persisted to enable automatic token refresh after expiration.
Client secret is securely stored in system Keychain, config file only
contains a reference.
2026-03-30 10:58:37 +08:00
fantiu 964855373e feat(auth): persist client credentials for token refresh
When logging in with --client-id and --client-secret, credentials are now
automatically persisted to enable automatic token refresh after expiration.
Client secret is securely stored in system Keychain, config file only
contains a reference.
2026-03-30 10:53:26 +08:00
coffeeBigSir 8fb1dcda86 Merge pull request #28 from audanye-sudo/feat/onboarding-experience-improvement
docs: improve onboarding experience and skill reference clarity
2026-03-29 23:38:27 +08:00
audanye-sudo 94deafbaa9 docs: update release badge version to v1.0.3 2026-03-29 23:37:31 +08:00
audanye-sudo 2555447c7b docs: enhance README onboarding flow and getting started guide
- Improve installation and getting started sections for new users
- Add clearer step-by-step guidance for first-time setup
- Update examples with more realistic use cases
2026-03-29 23:34:35 +08:00
audanye-sudo 6e91b2d142 docs: improve agent skill references and intent guide clarity
- Enhance product skill docs with clearer examples and descriptions
- Update intent-guide with better routing patterns
- Expand simple.md with comprehensive onboarding examples
- Fix inconsistent command references across skill docs
2026-03-29 23:33:41 +08:00
coffeeBigSir e64eb5db95 Merge pull request #26 from wxianfeng/main
add helper overrides for all task subcommands
2026-03-29 22:32:53 +08:00
github-actions[bot] e175115de1 chore: update coverage badge [skip ci] 2026-03-29 14:31:48 +00:00
wxianfeng 9dc4f95f60 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-03-29 22:30:17 +08:00
wxianfeng 15f53c0a6b feat(todo): add helper overrides for all task subcommands
- Implement create/update/done/get/delete with preferLegacyLeaf to replace
  dynamic commands; align MCP tools with production (create_personal_todo,
  update_todo_task, update_todo_done_status, query_todo_detail, delete_todo)
- Add ISO-8601 due time parsing, hidden title aliases, delete confirmation
- Extend zh/en i18n for todo strings; fix multiple en.json spacing/wording issues
- Expand cli_compat todo tests; adjust .gitignore for cli_compat testdata

Made-with: Cursor
2026-03-29 22:29:50 +08:00
github-actions[bot] 6bffbba817 chore: update coverage badge [skip ci] 2026-03-29 14:28:06 +00:00
xianfeng wang ab11f5d583 Merge branch 'DingTalk-Real-AI:main' into main 2026-03-29 22:27:09 +08:00
coffeeBigSir c1d2210c95 Merge pull request #25 from audanye-sudo/feat/basic-feature-upgrade
feat: basic feature upgrade — nested filtering, schema display, docs
2026-03-29 22:25:56 +08:00
audanye-sudo b12ae13047 docs: restructure README with collapsible feature sections
- Reorganize features into collapsible details blocks
- Add Pipe & File Input documentation section
- Consolidate Agent Skills into Using with Agents section
- Fix approval service command name (approval → oa)
- Update examples with placeholder values
2026-03-29 22:23:47 +08:00
audanye-sudo 8a61c038ff feat: add schema command enhancements and todo helper improvements
- Support schema output in table format for human-readable display
- Add product-level endpoint loading in CLI loader
- Wire schema-text rendering into runner output pipeline
- Add priority field support for todo task helper
2026-03-29 22:23:08 +08:00
audanye-sudo b5f3603be5 feat: enhance output filtering with nested field paths and array indexing
- Support dot-notation for nested fields (e.g. --fields response.content)
- Support array index access in --fields (e.g. response.items[0])
- Add field path parsing and recursive extraction logic
- Expand test coverage for nested and edge-case filtering
2026-03-29 22:21:48 +08:00
github-actions[bot] 802713aafe chore: update coverage badge [skip ci] 2026-03-29 13:01:16 +00:00
32 changed files with 1822 additions and 340 deletions
+1 -1
View File
@@ -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
View File
@@ -24,4 +24,6 @@ test/cli_compat/testdata/
.env.*
*.pem
*.key
credentials*
credentials*
plans
_docs
+141 -95
View File
@@ -9,7 +9,7 @@
<p align="center">
<img src="https://img.shields.io/badge/Go-1.25+-green?logo=go&logoColor=white" alt="Go 1.25+">
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-blue" alt="License Apache-2.0"></a>
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases"><img src="https://img.shields.io/badge/release-v1.1.0-red" alt="v1.1.0"></a>
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases"><img src="https://img.shields.io/badge/release-v1.0.4-red" alt="v1.0.4"></a>
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/ci.yml"><img src="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<img src=".github/badges/coverage.svg" alt="Coverage">
</p>
@@ -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)
@@ -140,50 +139,58 @@ export DWS_CLIENT_SECRET=<your-app-secret>
dws auth login
```
> CLI flags take precedence over environment variables. Credentials are used for DingTalk's OAuth device flow.
<details>
<summary><strong>Credential Configuration Priority</strong></summary>
`client-id` and `client-secret` support multiple configuration methods with the following priority (highest to lowest):
| Priority | Method | Description |
|----------|--------|-------------|
| 1 | CLI flags / Persisted config | `--client-id` / `--client-secret` command-line arguments; auto-saved after first successful login with `client-secret` stored in system Keychain |
| 2 | Environment variables | `DWS_CLIENT_ID` / `DWS_CLIENT_SECRET` |
| 3 | Default values | Hardcoded defaults (for development only) |
**Recommended usage**:
- **First login**: Use `--client-id` and `--client-secret` flags; credentials are securely persisted after successful login
- **Subsequent use**: Run `dws` commands directly; token refresh automatically reads saved credentials from Keychain
- **CI/CD environments**: Use environment variables
</details>
## 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:
@@ -192,15 +199,78 @@ Agents don't need pre-built knowledge of every command. Use `dws schema` to dyna
dws schema --jq '.products[] | {id, tool_count: (.tools | length)}'
# Step 2: Inspect target tool's parameter schema
dws schema aitable.query_records --jq '.tool.input_schema'
dws schema aitable.query_records --jq '.tool.parameters'
# 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 a complete Agent Skill system (`skills/`). After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
```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).
**What's included:**
| Component | Path | Description |
|-----------|------|-------------|
| Master Skill | `SKILL.md` | Intent routing, decision tree, safety rules, error handling |
| Product references | `references/products/*.md` | Per-product command reference (aitable, chat, calendar, etc.) |
| Intent guide | `references/intent-guide.md` | Disambiguation for confusing scenarios (e.g. report vs todo) |
| Global reference | `references/global-reference.md` | Auth, output formats, global flags |
| Error codes | `references/error-codes.md` | Error codes + debugging workflows |
| Recovery guide | `references/recovery-guide.md` | `RECOVERY_EVENT_ID` handling |
| Ready-made scripts | `scripts/*.py` | 13 batch operation scripts (see below) |
<details>
<summary><strong>Ready-made scripts</strong> — 13 Python scripts for common multi-step workflows</summary>
| Script | Description |
|--------|-------------|
| `calendar_schedule_meeting.py` | Create event + add participants + find & book available meeting room |
| `calendar_free_slot_finder.py` | Find common free slots across multiple people, recommend best meeting time |
| `calendar_today_agenda.py` | View today/tomorrow/this week's schedule |
| `import_records.py` | Batch import records from CSV/JSON into AITable |
| `bulk_add_fields.py` | Batch add fields to an AITable data table |
| `upload_attachment.py` | Upload attachment to AITable attachment field |
| `todo_batch_create.py` | Batch create todos from JSON (with priority, due date, executors) |
| `todo_daily_summary.py` | Summarize today/this week's incomplete todos |
| `todo_overdue_check.py` | Scan overdue todos and output overdue list |
| `contact_dept_members.py` | Search department by name and list all members |
| `attendance_my_record.py` | View my attendance records for today/this week/specific date |
| `attendance_team_shift.py` | Query team shift schedules and attendance statistics |
| `report_inbox_today.py` | View today's received reports with details |
</details>
**ISV 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)**.
## 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,78 +280,71 @@ 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
dws schema aitable.query_records # view parameter schema
dws schema aitable.query_records --jq '.tool.input_schema.required' # view required fields
dws schema aitable.query_records --jq '.tool.required' # view required fields
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 |
|---------|---------|-------------|
| Contact | `contact` | Users / departments |
| Chat | `chat` | Group management / members / bot messaging / webhook |
| Calendar | `calendar` | Events / meeting rooms / free-busy |
| Todo | `todo` | Task management |
| Approval | `approval` | Processes / forms / instances |
| Attendance | `attendance` | Clock-in / shifts / statistics |
| Ding | `ding` | DING messages / send / recall |
| Report | `report` | Reports / templates / statistics |
| AITable | `aitable` | AI table operations |
| Workbench | `workbench` | App query |
| DevDoc | `devdoc` | Open platform docs search |
| Service | Command | Tools | Subcommands | Description |
|---------|---------|:-----:|-------------|-------------|
| Contact | `contact` | 8 | `user` `dept` | Search users by name/mobile, batch query, departments, current user profile |
| Chat | `chat` | 14 | `message` `group` `bot` `search` | Group CRUD, member management, message history, topic replies, send as user |
| Bot | `chat bot` | 9 | — | Robot creation, group/single messaging, webhook, message recall |
| Calendar | `calendar` | 13 | `event` `room` `participant` `busy` | Events CRUD, meeting room booking, free-busy query, participant management |
| Todo | `todo` | 6 | `task` | Create, list, update, done, get detail, delete |
| Approval | `oa` | 9 | `approval` | Approve/reject/revoke, pending tasks, initiated instances, process list |
| Attendance | `attendance` | 4 | `record` `shift` `summary` `rules` | Clock-in records, shift schedules, attendance summary, group rules |
| Ding | `ding` | 3 | `message` | Send/recall DING messages |
| Report | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | Create reports, sent/received list, templates, statistics |
| AITable | `aitable` | 27 | `base` `table` `record` `field` `attachment` `template` | Full CRUD for bases/tables/records/fields, views, import/export, templates |
| Workbench | `workbench` | 2 | `app` | Batch query app details |
| DevDoc | `devdoc` | 2 | `article` | Search platform docs and error codes |
Run `dws --help` for the full list, or `dws <service> --help` for subcommands.
> 104 tools across 12 products. Run `dws --help` for the full list, or `dws <service> --help` for subcommands.
<details>
<summary>Coming soon</summary>
@@ -333,23 +396,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
+140 -94
View File
@@ -9,7 +9,7 @@
<p align="center">
<img src="https://img.shields.io/badge/Go-1.25+-green?logo=go&logoColor=white" alt="Go 1.25+">
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-blue" alt="License Apache-2.0"></a>
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases"><img src="https://img.shields.io/badge/release-v1.1.0-red" alt="v1.1.0"></a>
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases"><img src="https://img.shields.io/badge/release-v1.0.4-red" alt="v1.0.4"></a>
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/ci.yml"><img src="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<img src=".github/badges/coverage.svg" alt="Coverage">
</p>
@@ -31,10 +31,9 @@
- [开始使用](#开始使用)
- [快速开始](#快速开始)
- [在 Agent 中使用](#在-agent-中使用)
- [新功能](#新功能)
- [功能特性](#功能特性)
- [核心服务](#核心服务)
- [安全设计](#安全设计)
- [AI Agent Skills](#ai-agent-skills)
- [参考与文档](#参考与文档)
- [贡献指南](#贡献指南)
@@ -140,50 +139,58 @@ export DWS_CLIENT_SECRET=<your-app-secret>
dws auth login
```
> CLI 参数优先于环境变量。凭证用于钉钉 OAuth 设备流认证。
<details>
<summary><strong>凭证配置优先级</strong></summary>
`client-id` 和 `client-secret` 支持多种配置方式,按以下优先级生效(从高到低):
| 优先级 | 配置方式 | 说明 |
|------|----------|------|
| 1 | CLI 参数 / 持久化配置 | `--client-id` / `--client-secret` 命令行参数;首次登录成功后自动保存,`client-secret` 存储在系统 Keychain 中 |
| 2 | 环境变量 | `DWS_CLIENT_ID` / `DWS_CLIENT_SECRET` |
| 3 | 默认值 | 代码中的预设值(仅开发时使用) |
**推荐用法**:
- **首次登录**:使用 `--client-id` 和 `--client-secret` 参数,登录成功后凭证会自动安全存储
- **后续使用**:直接运行 `dws` 命令,Token 自动刷新时会从 Keychain 读取已保存的凭证
- **CI/CD 环境**:建议使用环境变量配置
</details>
## 快速开始
```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` 动态发现可用能力:
@@ -192,15 +199,78 @@ Agent 无需预置所有命令知识,通过 `dws schema` 动态发现可用能
dws schema --jq '.products[] | {id, tool_count: (.tools | length)}'
# 第二步:查看目标工具的参数结构
dws schema aitable.query_records --jq '.tool.input_schema'
dws schema aitable.query_records --jq '.tool.parameters'
# 第三步:构造正确的调用
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 体系(`skills/`),安装后 Claude Code / Cursor 等 AI 工具可通过自然语言直接操作钉钉:
```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`(当前项目)。
**包含内容:**
| 组件 | 路径 | 说明 |
|------|------|------|
| 主 Skill | `SKILL.md` | 意图路由、决策树、安全规则、错误处理 |
| 产品参考 | `references/products/*.md` | 各产品命令详细参考(aitable、chat、calendar 等) |
| 意图指南 | `references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) |
| 全局参考 | `references/global-reference.md` | 认证、输出格式、全局 flag |
| 错误码 | `references/error-codes.md` | 错误码 + 调试流程 |
| Recovery 指南 | `references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 |
| 现成脚本 | `scripts/*.py` | 13 个批量操作脚本(见下方) |
<details>
<summary><strong>现成脚本</strong> — 13 个 Python 脚本,覆盖常见多步工作流</summary>
| 脚本 | 说明 |
|------|------|
| `calendar_schedule_meeting.py` | 一键创建日程 + 添加参与者 + 搜索并预定空闲会议室 |
| `calendar_free_slot_finder.py` | 查询多人共同空闲时段,推荐最佳会议时间 |
| `calendar_today_agenda.py` | 查看今天/明天/本周的日程安排 |
| `import_records.py` | 从 CSV/JSON 批量导入记录到 AI 表格 |
| `bulk_add_fields.py` | 批量添加字段到 AI 表格数据表 |
| `upload_attachment.py` | 上传附件到 AI 表格 attachment 字段 |
| `todo_batch_create.py` | 从 JSON 文件批量创建待办(含优先级、截止时间、执行者) |
| `todo_daily_summary.py` | 汇总今天/本周未完成的待办 |
| `todo_overdue_check.py` | 扫描已过截止时间但未完成的待办,输出逾期清单 |
| `contact_dept_members.py` | 按部门名称搜索并列出所有成员 |
| `attendance_my_record.py` | 查看我今天/本周/指定日期的考勤记录 |
| `attendance_team_shift.py` | 查询团队成员本周排班和出勤统计 |
| `report_inbox_today.py` | 查看今天收到的日志列表及详情 |
</details>
**ISV 集成**:编写您自己的 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,78 +280,71 @@ 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 # 列出所有产品和工具
dws schema aitable.query_records # 查看参数 Schema
dws schema aitable.query_records --jq '.tool.input_schema.required' # 查看必填字段
dws schema aitable.query_records --jq '.tool.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>
## 核心服务
| 服务 | 命令 | 描述 |
|---------|---------|-------------|
| 通讯录 | `contact` | 用户 / 部门 |
| 群聊 | `chat` | 群管理 / 群成员 / 机器人消息 / Webhook |
| 日历 | `calendar` | 日程 / 会议室 / 闲忙 |
| 待办 | `todo` | 任务管理 |
| 审批 | `approval` | 流程 / 表单 / 实例 |
| 考勤 | `attendance` | 打卡 / 排班 / 统计 |
| DING | `ding` | DING 消息 / 发送 / 撤回 |
| 日志 | `report` | 日志 / 模版 / 统计 |
| 智能表格 | `aitable` | AI 表格操作 |
| 工作台 | `workbench` | 应用查询 |
| 开发者文档 | `devdoc` | 开放平台文档搜索 |
| 服务 | 命令 | 工具数 | 子命令 | 描述 |
|------|------|:------:|--------|------|
| 通讯录 | `contact` | 8 | `user` `dept` | 按姓名/手机号搜索、批量查询、部门树、当前用户信息 |
| 群聊 | `chat` | 14 | `message` `group` `bot` `search` | 群增删改查、成员管理、消息拉取、话题回复、以用户身份发消息 |
| 机器人 | `chat bot` | 9 | — | 机器人创建、群聊/单聊消息、Webhook、消息撤回 |
| 日历 | `calendar` | 13 | `event` `room` `participant` `busy` | 日程增删改查、会议室预订、闲忙查询、参与者管理 |
| 待办 | `todo` | 6 | `task` | 创建、列表、修改、完成、详情、删除 |
| 审批 | `oa` | 9 | `approval` | 同意/拒绝/撤销、待我审批、我发起的、流程列表 |
| 考勤 | `attendance` | 4 | `record` `shift` `summary` `rules` | 打卡记录、排班查询、考勤摘要、考勤组规则 |
| DING | `ding` | 3 | `message` | 发送/撤回 DING 消息 |
| 日志 | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | 创建日志、收发列表、模版、统计 |
| 智能表格 | `aitable` | 27 | `base` `table` `record` `field` `attachment` `template` | 多维表/数据表/记录/字段全量 CRUD、视图、导入导出、模板 |
| 工作台 | `workbench` | 2 | `app` | 批量查询应用详情 |
| 开发者文档 | `devdoc` | 2 | `article` | 搜索开放平台文档与错误码 |
运行 `dws --help` 查看完整列表,或 `dws <service> --help` 查看子命令。
> 12 个产品,104 个工具。运行 `dws --help` 查看完整列表,或 `dws <service> --help` 查看子命令。
<details>
<summary>即将推出</summary>
@@ -335,23 +398,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 补全
+3
View File
@@ -188,8 +188,11 @@ func newAuthLogoutCommand() *cobra.Command {
if err := authpkg.DeleteTokenData(configDir); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
}
// Clean up app credentials (app.json + keychain secret)
_ = authpkg.DeleteAppConfig(configDir)
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
_ = os.Remove(filepath.Join(configDir, "token"))
_ = os.Remove(filepath.Join(configDir, "token.json"))
clearCompatCache()
w := cmd.OutOrStdout()
fmt.Fprintln(w, "[OK] 已清除所有认证信息")
+3
View File
@@ -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)
+30
View File
@@ -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 {
+185
View File
@@ -0,0 +1,185 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package auth
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
)
const (
// appConfigFile is the filename for storing app credentials.
appConfigFile = "app.json"
)
// AppConfig represents the application credentials configuration.
// This is stored in ~/.dws/app.json with the client secret securely stored in keychain.
type AppConfig struct {
ClientID string `json:"clientId"`
ClientSecret SecretInput `json:"clientSecret"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt,omitempty"`
}
// Cached app config for performance (avoid repeated file reads).
var (
cachedAppConfig *AppConfig
cachedAppConfigOnce sync.Once
cachedAppConfigMu sync.RWMutex
)
// GetAppConfigPath returns the path to the app config file.
func GetAppConfigPath(configDir string) string {
return filepath.Join(configDir, appConfigFile)
}
// LoadAppConfig loads the app configuration from disk.
// Returns nil, nil if the config file does not exist.
func LoadAppConfig(configDir string) (*AppConfig, error) {
path := GetAppConfigPath(configDir)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("reading app config: %w", err)
}
var config AppConfig
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("parsing app config: %w", err)
}
return &config, nil
}
// SaveAppConfig saves the app configuration to disk.
// If the client secret is a plain string, it will be stored in keychain
// and the config file will contain a reference to it.
func SaveAppConfig(configDir string, config *AppConfig) error {
// Store plain secret in keychain, convert to reference
if config.ClientSecret.IsPlain() && config.ClientID != "" {
storedRef, err := StoreSecret(config.ClientID, config.ClientSecret)
if err != nil {
return fmt.Errorf("storing client secret: %w", err)
}
config.ClientSecret = storedRef
}
// Update timestamps
if config.CreatedAt.IsZero() {
config.CreatedAt = time.Now()
}
config.UpdatedAt = time.Now()
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("marshaling app config: %w", err)
}
path := GetAppConfigPath(configDir)
if err := helpers.AtomicWriteJSON(path, append(data, '\n')); err != nil {
return fmt.Errorf("writing app config: %w", err)
}
// Update cache
cachedAppConfigMu.Lock()
cachedAppConfig = config
cachedAppConfigMu.Unlock()
return nil
}
// DeleteAppConfig removes the app configuration and associated keychain secrets.
func DeleteAppConfig(configDir string) error {
// Load existing config to clean up keychain
existing, _ := LoadAppConfig(configDir)
if existing != nil {
RemoveSecretStore(existing.ClientSecret)
}
// Remove config file
path := GetAppConfigPath(configDir)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("removing app config: %w", err)
}
// Clear cache
cachedAppConfigMu.Lock()
cachedAppConfig = nil
cachedAppConfigMu.Unlock()
return nil
}
// GetCachedAppConfig returns the cached app configuration.
// It loads from disk on first call and caches the result.
// Returns nil if no configuration exists or loading fails.
func GetCachedAppConfig(configDir string) *AppConfig {
cachedAppConfigOnce.Do(func() {
cfg, err := LoadAppConfig(configDir)
if err == nil && cfg != nil {
cachedAppConfigMu.Lock()
cachedAppConfig = cfg
cachedAppConfigMu.Unlock()
}
})
cachedAppConfigMu.RLock()
defer cachedAppConfigMu.RUnlock()
return cachedAppConfig
}
// ReloadAppConfig forces a reload of the app configuration from disk.
// This should be called after SaveAppConfig to ensure the cache is updated.
func ReloadAppConfig(configDir string) (*AppConfig, error) {
cfg, err := LoadAppConfig(configDir)
if err != nil {
return nil, err
}
cachedAppConfigMu.Lock()
cachedAppConfig = cfg
cachedAppConfigMu.Unlock()
return cfg, nil
}
// HasAppConfig returns true if an app configuration file exists.
func HasAppConfig(configDir string) bool {
path := GetAppConfigPath(configDir)
_, err := os.Stat(path)
return err == nil
}
// ResolveAppCredentials resolves the client ID and secret from the app config.
// Returns empty strings if the config doesn't exist or resolution fails.
func ResolveAppCredentials(configDir string) (clientID, clientSecret string) {
cfg := GetCachedAppConfig(configDir)
if cfg == nil {
return "", ""
}
clientID = cfg.ClientID
secret, err := ResolveSecret(cfg.ClientSecret)
if err == nil {
clientSecret = secret
}
return clientID, clientSecret
}
+7
View File
@@ -149,9 +149,16 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
if err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), err)
}
// Save token data with associated client ID for refresh
tokenData.ClientID = p.clientID
if err := SaveTokenData(p.configDir, tokenData); err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
}
// Persist app credentials if using custom client credentials
oauthProvider.persistAppConfigIfNeeded()
return tokenData, nil
}
+41 -4
View File
@@ -15,6 +15,7 @@ package auth
import (
"os"
"path/filepath"
"sync"
)
@@ -84,8 +85,9 @@ func SetClientSecret(secret string) {
// ClientID returns the OAuth client ID with priority:
// 1. Runtime override (CLI flag --client-id)
// 2. Environment variable (DWS_CLIENT_ID)
// 3. Default hardcoded value
// 2. Persisted app config (from previous login)
// 3. Environment variable (DWS_CLIENT_ID)
// 4. Default hardcoded value
func ClientID() string {
clientMu.RLock()
override := runtimeClientID
@@ -93,6 +95,10 @@ func ClientID() string {
if override != "" {
return override
}
// Try loading from persisted app config
if id, _ := ResolveAppCredentials(getDefaultConfigDir()); id != "" {
return id
}
if v := os.Getenv("DWS_CLIENT_ID"); v != "" {
return v
}
@@ -101,8 +107,9 @@ func ClientID() string {
// ClientSecret returns the OAuth client secret with priority:
// 1. Runtime override (CLI flag --client-secret)
// 2. Environment variable (DWS_CLIENT_SECRET)
// 3. Default hardcoded value
// 2. Persisted app config (from previous login, stored in keychain)
// 3. Environment variable (DWS_CLIENT_SECRET)
// 4. Default hardcoded value
func ClientSecret() string {
clientMu.RLock()
override := runtimeClientSecret
@@ -110,8 +117,38 @@ func ClientSecret() string {
if override != "" {
return override
}
// Try loading from persisted app config (secret is in keychain)
if _, secret := ResolveAppCredentials(getDefaultConfigDir()); secret != "" {
return secret
}
if v := os.Getenv("DWS_CLIENT_SECRET"); v != "" {
return v
}
return DefaultClientSecret
}
// getRuntimeCredentials returns the runtime-override credentials if set.
// Returns empty strings if no runtime overrides were provided.
func getRuntimeCredentials() (clientID, clientSecret string) {
clientMu.RLock()
defer clientMu.RUnlock()
return runtimeClientID, runtimeClientSecret
}
// getEnvClientID returns the environment variable client ID if set.
func getEnvClientID() string {
return os.Getenv("DWS_CLIENT_ID")
}
// getDefaultConfigDir returns the default configuration directory.
// Priority: DWS_CONFIG_DIR env var > ~/.dws
func getDefaultConfigDir() string {
if envDir := os.Getenv("DWS_CONFIG_DIR"); envDir != "" {
return envDir
}
homeDir, err := os.UserHomeDir()
if err != nil {
return ".dws"
}
return filepath.Join(homeDir, ".dws")
}
+34
View File
@@ -176,9 +176,16 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
if err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), err)
}
// Save token data with associated client ID for refresh
tokenData.ClientID = p.clientID
if err := SaveTokenData(p.configDir, tokenData); err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
}
// Persist app credentials if using custom client credentials
p.persistAppConfigIfNeeded()
return tokenData, nil
}
@@ -290,3 +297,30 @@ func (p *OAuthProvider) Logout() error {
func (p *OAuthProvider) Status() (*TokenData, error) {
return LoadTokenData(p.configDir)
}
// persistAppConfigIfNeeded saves app credentials if custom ones were used.
// This ensures the client secret is available for future token refreshes.
func (p *OAuthProvider) persistAppConfigIfNeeded() {
// Check if custom credentials were provided via runtime flags
clientID, clientSecret := getRuntimeCredentials()
if clientID == "" || clientSecret == "" {
return
}
// Only persist if they differ from environment/default values
envID := getEnvClientID()
if clientID == envID || clientID == DefaultClientID {
return
}
// Save app config with secret stored in keychain
config := &AppConfig{
ClientID: clientID,
ClientSecret: PlainSecret(clientSecret),
}
if err := SaveAppConfig(p.configDir, config); err != nil {
if p.logger != nil {
p.logger.Warn("failed to persist app credentials", "error", err)
}
}
}
+147
View File
@@ -0,0 +1,147 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package auth
import (
"encoding/json"
"fmt"
"os"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
)
const (
// secretKeyPrefix is the keychain account prefix for app secrets.
secretKeyPrefix = "appsecret:"
)
// SecretRef references a secret stored externally.
type SecretRef struct {
Source string `json:"source"` // "keychain" | "file"
ID string `json:"id"` // keychain key or file path
}
// SecretInput represents a secret value: either a plain string or a SecretRef object.
type SecretInput struct {
Plain string // non-empty for plain string values
Ref *SecretRef // non-nil for SecretRef values
}
// PlainSecret creates a SecretInput from a plain string.
func PlainSecret(s string) SecretInput {
return SecretInput{Plain: s}
}
// IsZero returns true if the SecretInput has no value.
func (s SecretInput) IsZero() bool {
return s.Plain == "" && s.Ref == nil
}
// IsSecretRef returns true if this is a SecretRef object.
func (s SecretInput) IsSecretRef() bool {
return s.Ref != nil
}
// IsPlain returns true if this is a plain text string (not a SecretRef).
func (s SecretInput) IsPlain() bool {
return s.Ref == nil && s.Plain != ""
}
// MarshalJSON serializes SecretInput: plain string → JSON string, SecretRef → JSON object.
func (s SecretInput) MarshalJSON() ([]byte, error) {
if s.Ref != nil {
return json.Marshal(s.Ref)
}
return json.Marshal(s.Plain)
}
// UnmarshalJSON deserializes SecretInput from either a JSON string or a SecretRef object.
func (s *SecretInput) UnmarshalJSON(data []byte) error {
// Try string first
var plain string
if err := json.Unmarshal(data, &plain); err == nil {
s.Plain = plain
s.Ref = nil
return nil
}
// Try SecretRef object
var ref SecretRef
if err := json.Unmarshal(data, &ref); err == nil && isValidSource(ref.Source) && ref.ID != "" {
s.Ref = &ref
s.Plain = ""
return nil
}
return fmt.Errorf("clientSecret must be a string or {source, id} object")
}
// ValidSecretSources is the set of recognized SecretRef sources.
var ValidSecretSources = map[string]bool{
"file": true, "keychain": true,
}
func isValidSource(source string) bool {
return ValidSecretSources[source]
}
// secretAccountKey generates the keychain account key for an app's secret.
func secretAccountKey(clientID string) string {
return secretKeyPrefix + clientID
}
// ResolveSecret resolves a SecretInput to a plain string.
// SecretRef objects are resolved by source (file / keychain).
func ResolveSecret(input SecretInput) (string, error) {
if input.Ref == nil {
return input.Plain, nil
}
switch input.Ref.Source {
case "file":
data, err := os.ReadFile(input.Ref.ID)
if err != nil {
return "", fmt.Errorf("failed to read secret file %s: %w", input.Ref.ID, err)
}
return strings.TrimSpace(string(data)), nil
case "keychain":
val, err := keychain.Get(keychain.Service, input.Ref.ID)
if err != nil {
return "", fmt.Errorf("failed to get secret from keychain: %w", err)
}
return val, nil
default:
return "", fmt.Errorf("unknown secret source: %s", input.Ref.Source)
}
}
// StoreSecret stores a plain text secret in keychain and returns a SecretRef.
// If the input is already a SecretRef, it is returned as-is.
// Returns error if keychain is unavailable.
func StoreSecret(clientID string, input SecretInput) (SecretInput, error) {
if !input.IsPlain() {
return input, nil // SecretRef → keep as-is
}
key := secretAccountKey(clientID)
if err := keychain.Set(keychain.Service, key, input.Plain); err != nil {
return SecretInput{}, fmt.Errorf("keychain unavailable: %w\nhint: use file reference in config to bypass keychain", err)
}
return SecretInput{Ref: &SecretRef{Source: "keychain", ID: key}}, nil
}
// RemoveSecretStore cleans up keychain entries when an app is removed.
// Errors are intentionally ignored — cleanup is best-effort.
func RemoveSecretStore(input SecretInput) {
if input.IsSecretRef() && input.Ref.Source == "keychain" {
_ = keychain.Remove(keychain.Service, input.Ref.ID)
}
}
+1
View File
@@ -32,6 +32,7 @@ type TokenData struct {
UserID string `json:"user_id,omitempty"`
UserName string `json:"user_name,omitempty"`
CorpName string `json:"corp_name,omitempty"`
ClientID string `json:"client_id,omitempty"` // Associated app client ID for refresh
UpdatedAt string `json:"updated_at,omitempty"`
Source string `json:"source,omitempty"`
}
+40 -7
View File
@@ -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
+8
View File
@@ -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
View File
@@ -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
+52 -33
View File
@@ -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"
}
+19
View File
@@ -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
View File
@@ -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 {
+91 -25
View File
@@ -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)
}
}
+27 -4
View File
@@ -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, "", " ")
+1 -1
View File
@@ -39,7 +39,7 @@ cli_version: ">=1.1.0"
## 意图判断决策树
用户提到"表格/多维表/AI表格/记录/数据" → `aitable`
用户提到"审批/请假/报销/出差/加班" → `approval`
用户提到"审批/请假/报销/出差/加班" → `oa`
用户提到"考勤/打卡/排班" → `attendance`
用户提到"日程/日历/会议室/约会" → `calendar`
用户提到"群聊/建群/群成员/群管理/机器人发消息/Webhook/机器人群发/机器人单聊/通知" → `chat`
+12 -6
View File
@@ -3,7 +3,7 @@
## 认证
```bash
# 首次: 扫码登录 (浏览器自动打开)
# 首次: OAuth 设备流登录 (钉钉扫码授权)
dws auth login
# 查看状态
@@ -11,6 +11,9 @@ dws auth status
# 退出
dws auth logout
# 重置本地凭证 (Token 解密失败时使用)
dws auth reset
```
登录后自动管理 token 刷新,日常使用无需重复登录。
@@ -28,11 +31,13 @@ dws auth logout
### Headless 环境 (CI/CD)
```bash
# 桌面: 导出凭证
dws auth import credentials.json
# 通过环境变量配置认证(无需交互式登录)
export DWS_CLIENT_ID=<your-app-key>
export DWS_CLIENT_SECRET=<your-app-secret>
dws auth login
# 服务器: 导入凭证
dws auth import credentials.json
# 或使用 --device 设备流登录(远程服务器/Docker)
dws auth login --device
```
refresh_token 单设备独占,远程刷新后源设备凭证失效。
@@ -59,12 +64,13 @@ dws recovery finalize --event-id <event_id> --outcome recovered|failed|handoff -
| 标志 | 短名 | 说明 | 默认 |
|------|:---:|------|------|
| `--format` | `-f` | 输出格式: json / table / raw | json |
| `--jq` | | jq 表达式过滤输出 (如: `.items[] \| .name`) | 无 |
| `--fields` | | 筛选输出字段 (逗号分隔, 如: name,id,status) | 无 |
| `--verbose` | `-v` | 详细日志 | false |
| `--debug` | | 调试日志 | false |
| `--yes` | `-y` | 跳过确认提示 | false |
| `--dry-run` | | 预览操作不执行 | false |
| `--timeout` | | HTTP 超时 (秒) | 30 |
| `--token` | | API Token (覆盖配置) | 无 |
| `--mock` | | Mock 数据 (开发用) | false |
| `--client-id` | | 覆盖 OAuth Client ID | 无 |
| `--client-secret` | | 覆盖 OAuth Client Secret | 无 |
+3 -3
View File
@@ -12,7 +12,7 @@
| "帮我建一个明天下午的日程" | 日历日程 | `calendar` | — | 日历日程管理(可含参与者/会议室)|
| "帮我看看收到的日报" | 日志收件箱 | `report` | `todo` | 钉钉日志系统(日报/周报),不是待办 |
| "帮我创建一个待办提醒" | 个人待办 | `todo` | `report` | 个人任务提醒,不是日志汇报 |
| "帮我提交请假审批" | 发起审批 | `approval` | — | 审批流程,不是待办或日志 |
| "帮我提交请假审批" | 发起审批 | `oa` | — | 审批流程,不是待办或日志 |
| "帮我建一个项目群" | 创建群聊 | `chat group create` | — | 群聊管理,不是日历日程 |
| "把张三拉进群" | 添加群成员 | `chat group members add` | — | 先查 userId,再添加 |
| "让机器人在群里发个通知" | 机器人群发 | `chat message send-by-bot` | `chat message send-by-webhook` | 企业内部机器人发消息,需 robotCode |
@@ -91,7 +91,7 @@
```bash
# 1. 搜索同事 userId
dws contact user search --query "张三" --format json
dws contact user search --keyword "张三" --format json
# 2. 创建日程
dws calendar event create --title "会议" \
@@ -107,7 +107,7 @@ dws calendar participant add --event <EVENT_ID> --users <USER_ID> --format json
```bash
# 1. 搜索同事 userId
dws contact user search --query "张三" --format json
dws contact user search --keyword "张三" --format json
# 2. 创建待办
dws todo task create --title "任务内容" --executors <USER_ID> --format json
+2 -2
View File
@@ -236,13 +236,13 @@ Usage:
Example:
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID>
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --record-ids rec1,rec2
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --query "关键词" --limit 50
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --keyword "关键词" --limit 50
Flags:
--base-id string Base ID (必填)
--cursor string 分页游标,首次不传
--field-ids string 返回字段 ID 列表,逗号分隔,单次最多 100 个
--filters string 结构化过滤条件 JSON
--query string 全文关键词搜索
--keyword string 全文关键词搜索
--limit int 单次最大记录数,默认 100,最大 100
--record-ids string 指定记录 ID 列表,逗号分隔,单次最多 100 个
--sort string 排序条件 JSON 数组
+6 -6
View File
@@ -6,7 +6,7 @@
|-------|------|
| `search` | 搜索群聊 |
| `group create` | 创建群 |
| `group members` | 查看群成员列表 |
| `group members list` | 查看群成员列表 |
| `group members add` | 添加群成员 |
| `group members remove` | 移除群成员(⚠️ 危险操作) |
| `group members add-bot` | 添加机器人到群 |
@@ -48,13 +48,13 @@ Flags:
---
## group members — 查看群成员列表
## group members list — 查看群成员列表
```
Usage:
dws chat group members [flags]
dws chat group members list [flags]
Example:
dws chat group members --id <openConversationId> --format json
dws chat group members list --id <openConversationId> --format json
Flags:
--id string 群会话 ID (必填)
--cursor string 分页游标
@@ -226,7 +226,7 @@ Flags:
- 用户说"搜索一个群" → `search`
- 用户说"帮我建个群" → `group create`
- 用户说"看看群里有谁" → `group members`
- 用户说"看看群里有谁" → `group members list`
- 用户说"把张三拉进群" → 先 `contact user search` 获取 userId,再 `group members add`
- 用户说"把张三移出群" → 先 `contact user search` 获取 userId,再 `group members remove`(⚠️ 需确认)
- 用户说"改一下群名" → `group rename`
@@ -245,7 +245,7 @@ Flags:
# ── 工作流: 建群并添加机器人 ──
# 1. 搜索同事 userId
dws contact user search --query "张三" --format json
dws contact user search --keyword "张三" --format json
# 2. 创建群
dws chat group create --name "项目群" --users <userId1>,<userId2> --format json
+8 -8
View File
@@ -17,9 +17,9 @@ Example:
Usage:
dws contact user search [flags]
Example:
dws contact user search --query "张三"
dws contact user search --keyword "张三"
Flags:
--query string 搜索关键词 (必填)
--keyword string 搜索关键词 (必填)
```
#### 按手机号搜索用户
@@ -49,9 +49,9 @@ Flags:
Usage:
dws contact dept search [flags]
Example:
dws contact dept search --query "技术部"
dws contact dept search --keyword "技术部"
Flags:
--query string 搜索关键词 (必填)
--keyword string 搜索关键词 (必填)
```
#### 查看部门成员
@@ -79,10 +79,10 @@ Flags:
dws contact user get-self --format json
# 2. 按名字搜索同事 — 提取 userId
dws contact user search --query "张三" --format json
dws contact user search --keyword "张三" --format json
# 3. 查看部门结构 — 提取 deptId
dws contact dept search --query "技术部" --format json
dws contact dept search --keyword "技术部" --format json
# 4. 查看部门成员
dws contact dept list-members --ids <deptId> --format json
@@ -94,7 +94,7 @@ dws contact dept list-members --ids <deptId> --format json
|------|------|------|
| `user get-self/search` | `userId` | 其他产品中的 --users/--executor 参数 |
| `user get-self/search` | `orgAuthEmail` | mail message send 的 --to/--cc (跨产品) |
| `dept search/list-children` | `deptId` | dept list-children/list-members 的 --id/--ids |
| `dept search` | `deptId` | dept list-members 的 --ids |
## 注意事项
@@ -105,4 +105,4 @@ dws contact dept list-members --ids <deptId> --format json
| 脚本 | 场景 | 用法 |
|------|------|------|
| [contact_dept_members.py](../../scripts/contact_dept_members.py) | 按部门名称搜索并列出所有成员 | `python contact_dept_members.py --query "技术部"` |
| [contact_dept_members.py](../../scripts/contact_dept_members.py) | 按部门名称搜索并列出所有成员 | `python contact_dept_members.py --keyword "技术部"` |
+1 -1
View File
@@ -35,7 +35,7 @@ Flags:
--template-id string 日志模版 ID (必填),从 template list 返回中取
--contents string 日志内容 JSON 数组 (必填),每项须含 key/sort/content/contentType/type
--dd-from string 创建来源标识 (默认 dws)
--to-chat 是否发送到日志接收人单聊 (默认 false,传本 flag 则为 true)
--to-chat string 是否发送到日志接收人单聊 (传 "true" 发送)
--to-user-ids string 接收人 userId,逗号分隔 (可选)
```
+81 -15
View File
@@ -18,32 +18,90 @@ Flags:
--size string 每页数量 (默认 10)
```
---
## approval — 审批
### 查询审批表单
### 搜索错误码
```
Usage:
dws approval list-forms [flags]
dws devdoc article search-error [flags]
Example:
dws approval list-forms --format json
dws devdoc article search-error --keyword "403" --format json
Flags:
--keyword string 错误码或关键词 (必填)
```
---
## oa — 审批
### 查询可见审批流程
```
Usage:
dws oa approval list-forms [flags]
Example:
dws oa approval list-forms --format json
```
### 查询审批实例详情
```
Usage:
dws approval detail --instance-id <ID> [flags]
dws oa approval detail --instance-id <ID> [flags]
Example:
dws approval detail --instance-id <ID> --format json
dws oa approval detail --instance-id <ID> --format json
```
### 查询审批任务
### 查询审批记录
```
Usage:
dws approval tasks --instance-id <ID> [flags]
dws oa approval records --instance-id <ID> [flags]
Example:
dws approval tasks --instance-id <ID> --format json
dws oa approval records --instance-id <ID> --format json
```
### 查询待我审批的任务
```
Usage:
dws oa approval tasks [flags]
Example:
dws oa approval tasks --format json
```
### 查询待我处理的审批
```
Usage:
dws oa approval pending [flags]
Example:
dws oa approval pending --format json
```
### 查询我发起的审批
```
Usage:
dws oa approval initiated [flags]
Example:
dws oa approval initiated --format json
```
### 同意审批
```
Usage:
dws oa approval approve --instance-id <ID> --task-id <TASK_ID> [flags]
Example:
dws oa approval approve --instance-id <ID> --task-id <TASK_ID> --format json
```
### 拒绝审批
```
Usage:
dws oa approval reject --instance-id <ID> --task-id <TASK_ID> [flags]
Example:
dws oa approval reject --instance-id <ID> --task-id <TASK_ID> --remark "不符合要求" --format json
```
### 撤销审批
```
Usage:
dws oa approval revoke --instance-id <ID> [flags]
Example:
dws oa approval revoke --instance-id <ID> --format json
```
---
@@ -51,12 +109,20 @@ Example:
## 意图判断
- 用户说"开发文档/API 文档/接口文档" → `devdoc article search`
- 用户说"审批/请假/报销/出差" → `approval`
- 用户说"API 报错/错误码" → `devdoc article search-error`
- 用户说"审批/请假/报销/出差" → `oa approval`
- 用户说"同意审批/批准" → `oa approval approve`
- 用户说"拒绝审批/驳回" → `oa approval reject`
- 用户说"撤销审批/撤回" → `oa approval revoke`
- 用户说"待我审批/我要审批的" → `oa approval pending` 或 `oa approval tasks`
- 用户说"我发起的审批" → `oa approval initiated`
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `devdoc article search` | 文档链接 | 直接展示给用户 |
| `approval list-forms` | processCode | `approval detail` / `approval tasks` 等 |
| `approval tasks` | taskId | `approval approve` / `approval reject` |
| `oa approval list-forms` | processCode | detail / records 等 |
| `oa approval tasks` | taskId, instanceId | approve / reject |
| `oa approval pending` | instanceId | detail / approve / reject |
| `oa approval initiated` | instanceId | detail / revoke |
+1 -9
View File
@@ -8,12 +8,11 @@ Usage:
dws todo task create [flags]
Example:
dws todo task create --title "修复线上Bug" --executors <USER_ID_1>,<USER_ID_2> --priority 40
dws todo task create --title "每日站会" --executors <USER_ID> --due "2026-03-20T10:00:00+08:00" --recurrence "DTSTART:20260320T020000Z\nRRULE:FREQ=DAILY;INTERVAL=1"
dws todo task create --title "提交报告" --executors <USER_ID> --due "2026-03-20T10:00:00+08:00"
Flags:
--due string 截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)
--executors string 执行者 userId 列表 (必填)
--priority string 优先级: 10低/20普通/30较高/40紧急
--recurrence string 循环待办 (需先设置 --due); 仅支持按天循环,格式见下方说明
--title string 待办标题 (必填)
```
@@ -81,7 +80,6 @@ Flags:
## 意图判断
用户说"加个待办/记一下/TODO" → `task create`
用户说"每天重复/循环待办/按天重复" → `task create`(需 `--due` + `--recurrence`)
用户说"看看待办/我有啥要做" → `task list`
用户说"改个待办/修改待办标题/改优先级" → `task update`
用户说"做完了/完成待办/标记完成" → `task done`
@@ -97,11 +95,6 @@ Flags:
dws todo task create --title "修复线上Bug" --executors userId1,userId2 \
--priority 40 --due "2026-03-10T18:00:00+08:00" --format json
# 1b. 创建按天循环的待办(必须先有 --due;recurrence 与 MCP create_personal_todo 一致)
dws todo task create --title "每日站会" --executors userId1 \
--due "2026-03-20T10:00:00+08:00" \
--recurrence "DTSTART:20260320T020000Z\nRRULE:FREQ=DAILY;INTERVAL=1" --format json
# 2. 查看未完成待办
dws todo task list --page 1 --size 20 --status false --format json
@@ -129,7 +122,6 @@ dws todo task delete --task-id <taskId> --yes --format json
- 优先级值: 10=低, 20=普通, 30=较高, 40=紧急
- `--due` 截止时间使用 ISO-8601 格式(如 2026-03-10T18:00:00+08:00)
- `--recurrence`:仅在与 `--due` 同时设置时有效;当前仅支持按天循环。字符串内需含换行,示例:`DTSTART:20260320T020000Z\nRRULE:FREQ=DAILY;INTERVAL=1`(DTSTART 表示首次截止时间,需与业务约定一致)
- `task list` 的 `--status` 对应 MCP `get_user_todos_in_current_org` 的 `todoStatus` 参数
- todo 是个人待办管理产品
- `task update` 可同时修改标题/优先级/截止时间/完成状态
+307 -3
View File
@@ -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)
}
+1 -1
View File
@@ -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) {