Compare commits

...
47 Commits
Author SHA1 Message Date
tianlei.qjb f10a0599d2 chore: prepare release v1.0.5 2026-04-01 10:48:04 +08:00
coffeeBigSir fc49f3dc7a Merge pull request #59 from audanye-sudo/feat/base-experience-upgrade
fix: error diagnostics enhancement and logging reliability
2026-04-01 09:37:21 +08:00
audanye-sudo 8fb0ecb9ba fix: resolve verbosity flag lookup, FileLogger lazy binding, and business error logging
- resolveVerbosity: use cmd.Flags() instead of PersistentFlags() to correctly
  pick up inherited --verbose/--debug flags on subcommands
- FileLogger lazy binding: bind in executeInvocation since configureLogLevel
  initializes it after runner construction
- logBusinessError: log MCP tool errors and business errors (HTTP 200 +
  success=false) to file logger for offline diagnosis
2026-04-01 09:33:09 +08:00
audanye-sudo ce6c32bf53 feat: enhance error diagnostics with trace ID, server error code, and comprehensive logging
Improve CLI error output and local logging to enable offline issue diagnosis:

- Add ServerDiagnostics struct to extract and propagate trace_id, server_error_code,
  technical_detail, and server_retryable from MCP server responses
- Extract diagnostics from JSON-RPC error.data, tool call result content, and HTTP
  response headers (X-Trace-Id, X-Request-Id, x-dingtalk-trace-id)
- Redesign PrintHuman with three verbosity levels (Normal/Verbose/Debug):
  Normal shows trace ID + server code; Verbose adds technical detail;
  Debug adds internal diagnostics (RPC code, operation, reason)
- Add PrintHumanAt for explicit verbosity control, PrintHuman defaults to Normal
- Enhance local logging with request body (sanitized), response body (on error),
  retry attempts, and error classification events
- Add TruncateBody, SanitizeArguments, RedactHeaders logging utilities with
  sensitive key detection via substring matching
- Fix respRetryAfter called before nil guard in doWithRetry retry loop
- Use GetBool instead of string comparison for flag resolution
2026-04-01 09:32:59 +08:00
fantiu 7dbef98dd0 Merge pull request #58 from fantiu/feat-login-upgrade
Feat login upgrade
2026-04-01 00:53:52 +08:00
fantiu 773804ee80 feat(auth): enhance device flow with CLI auth check and admin guidance 2026-04-01 00:47:49 +08:00
fantiu 1df56cc99b Merge branch 'DingTalk-Real-AI:main' into main 2026-03-31 21:06:17 +08:00
coffeeBigSir 0606762c29 Merge pull request #51 from Gyyshen/feature/addskill
feature: add skill management command for downloading and installing skills
2026-03-31 19:29:20 +08:00
meng93 e7677df541 Merge pull request #53 from DingTalk-Real-AI/feat/support-issue-notification
feat: 支持issuie消息通知
2026-03-31 18:58:47 +08:00
meng93 dda4dacb1c feat: to #73551688 支持消息通知 2026-03-31 17:38:08 +08:00
shenyunliang 345156c605 fix: correct import path for errors package in skill_command.go 2026-03-31 16:21:22 +08:00
github-actions[bot] 75b873d231 chore: update coverage badge [skip ci] 2026-03-31 05:47:53 +00:00
coffeeBigSir a912cbc52b Merge pull request #43 from audanye-sudo/feat/stability-and-hint-optimization
docs: update DingTalk co-creation group QR code
2026-03-31 10:03:38 +08:00
audanye-sudo 5a99b84c25 docs: update DingTalk co-creation group QR code image 2026-03-31 10:00:56 +08:00
audanye-sudoandClaude Opus 4.6 7fda120d5a feat: enhance error diagnostics with trace ID, server error code, and comprehensive logging
Improve CLI error output and local logging to enable offline issue diagnosis:

- Add ServerDiagnostics struct to extract and propagate trace_id, server_error_code,
  technical_detail, and server_retryable from MCP server responses
- Extract diagnostics from JSON-RPC error.data, tool call result content, and HTTP
  response headers (X-Trace-Id, X-Request-Id, x-dingtalk-trace-id)
- Redesign PrintHuman with three verbosity levels (Normal/Verbose/Debug):
  Normal shows trace ID + server code; Verbose adds technical detail;
  Debug adds internal diagnostics (RPC code, operation, reason)
- Add PrintHumanAt for explicit verbosity control, PrintHuman defaults to Normal
- Enhance local logging with request body (sanitized), response body (on error),
  retry attempts, and error classification events
- Add TruncateBody, SanitizeArguments, RedactHeaders logging utilities with
  sensitive key detection via substring matching
- Fix respRetryAfter called before nil guard in doWithRetry retry loop
- Use GetBool instead of string comparison for flag resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:01:41 +08:00
fantiu 3b8233e6ba Merge pull request #40 from fantiu/feat-readme-typo
docs(readme): combine redirect URLs into single line for easier copy-pasteFeat readme typo
2026-03-30 16:08:15 +08:00
fantiu 675ce53c06 Merge branch 'main' of github.com:fantiu/dingtalk-workspace-cli into feat-readme-typo
docs(readme): combine redirect URLs into single line for easier copy-paste
2026-03-30 16:01:32 +08:00
fantiu d51c1ff131 docs(readme): combine redirect URLs into single line for easier copy-paste 2026-03-30 16:01:03 +08:00
fantiu 8b423b97e8 Merge pull request #39 from fantiu/feat-performance
perf(auth): cache resolved credentials to avoid repeated keychain access
2026-03-30 15:22:15 +08:00
fantiu 1a6d129fe3 调整回调地址格式 2026-03-30 15:17:56 +08:00
fantiu a0bf715ddf Merge branch 'main' of github.com:fantiu/dingtalk-workspace-cli into feat-performance
解决Windows issue bug,修改readme回调地址格式
2026-03-30 15:11:29 +08:00
github-actions[bot] d2c5a027d4 chore: update coverage badge [skip ci] 2026-03-30 07:09:07 +00:00
fantiu 077665e27b Merge branch 'DingTalk-Real-AI:main' into main 2026-03-30 15:08:10 +08:00
coffeeBigSir 5c41d2b8f4 Merge pull request #29 from DingTalk-Real-AI/xtyh
Xtyh
2026-03-30 14:55:46 +08:00
tianlei.qjb 7d9e592f84 去除message history 2026-03-30 14:49:30 +08:00
tianlei.qjb 11199e6848 add interactive confirmation for destructive dynamic commands 2026-03-30 14:23:50 +08:00
tianlei.qjb 19c38d4b94 add report helper with flexible date parsing and defaults 2026-03-30 13:44:01 +08:00
tianlei.qjb 851cf43180 Merge branch 'main' of https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli into xtyh 2026-03-30 13:32:31 +08:00
github-actions[bot] e004df38c7 chore: update coverage badge [skip ci] 2026-03-30 04:01:24 +00:00
fantiu 3bb504bb1f feat(auth): persist client credentials and optimize keychain access
- Auto-persist client-id/client-secret after successful login (stored in keychain)
- Enhanced logout to clean up app.json, keychain secrets, and token.json
- Fixed OAuth callback race condition (write response before sending code)
- Added credentials cache to avoid repeated keychain access (improves CLI speed)
- Updated README with credential priority docs and bumped to v1.0.5
2026-03-30 11:59:03 +08:00
fantiu 26263f8a17 feat(auth): persist client credentials and optimize keychain access
- Auto-persist client-id/client-secret after successful login (stored in keychain)
- Enhanced logout to clean up app.json, keychain secrets, and token.json
- Fixed OAuth callback race condition (write response before sending code)
- Added credentials cache to avoid repeated keychain access (improves CLI speed)
- Updated README with credential priority docs and bumped to v1.0.4
2026-03-30 11:57:07 +08:00
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
tianlei.qjb 3c83c0cff2 Merge branch 'xtyh' of https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli into xtyh 2026-03-29 23:57:09 +08:00
tianlei.qjb 4b555abefe docs: remove v1.0.1 version tags from feature sections 2026-03-29 23:56:35 +08:00
tianlei.qjb 4742112000 feature modified 2026-03-29 23:49:18 +08:00
tianlei.qjb 7f0567aa39 Merge branch 'main' of https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli into xtyh 2026-03-29 23:48:02 +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
tianlei.qjb 54145b65ec fix test script 2026-03-29 22:30:03 +08:00
tianlei.qjb 2fad9c95db test 2026-03-29 22:29:15 +08:00
51 changed files with 4694 additions and 227 deletions
+1 -1
View File
@@ -1 +1 @@
<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>
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 52.8%"><title>coverage: 52.8%</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">52.8%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">52.8%</text></g></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+52
View File
@@ -0,0 +1,52 @@
# Issue 变更推送到 Webhook
# 当有 Issue 变更时,发送指定格式的数据到 webhook
name: 📤 Issue Webhook Notification
on:
issues:
types: [opened, reopened, closed, edited, labeled, unlabeled]
jobs:
notify:
runs-on: ubuntu-latest
steps:
- name: 📬 Send Issue to Webhook
uses: actions/github-script@v7
with:
script: |
const webhook = process.env.ISSUE_WEBHOOK_URL;
if (!webhook) {
console.log('⚠️ ISSUE_WEBHOOK_URL not set, skipping notification');
return;
}
const payload = context.payload;
const issue = payload.issue;
const action = payload.action;
// 构建指定格式的数据
const webhookPayload = {
action: action,
issue: {
id: issue.id,
number: issue.number,
title: issue.title,
body: issue.body,
state: issue.state,
html_url: issue.html_url
}
};
const response = await fetch(webhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(webhookPayload)
});
if (response.ok) {
console.log('✅ Webhook notification sent successfully');
} else {
console.log('❌ Failed to send webhook notification:', response.status, response.statusText);
}
env:
ISSUE_WEBHOOK_URL: ${{ secrets.DINGTALK_AI_TABLE_WEBHOOK }}
+49
View File
@@ -0,0 +1,49 @@
# Issue 自动同步到钉钉群
# 当有新 Issue 时,自动发送到钉钉群(不包括 comment)
name: 🔔 Issue Notification to DingTalk
on:
issues:
types: [opened, reopened, closed, labeled]
jobs:
notify:
runs-on: ubuntu-latest
steps:
- name: 📬 Send Issue to DingTalk
uses: actions/github-script@v7
with:
script: |
const webhook = process.env.DINGTALK_WEBHOOK;
if (!webhook) {
console.log('⚠️ DINGTALK_WEBHOOK not set, skipping notification');
return;
}
const payload = context.payload;
const issue = payload.issue;
const action = payload.action;
// 构建消息标题和内容(确保包含关键字 "issue" 以支持 Custom Keywords 模式)
const title = `[${action.toUpperCase()}] Issue #${issue.number}: ${issue.title}`;
const content = issue.body?.substring(0, 500) || 'No description';
const url = issue.html_url;
// 消息内容必须包含关键字(如 "issue")以支持 Custom Keywords 安全模式
const message = {
msgtype: 'markdown',
markdown: {
title: 'GitHub Issue 通知',
text: `## 🔔 GitHub Issue 通知\n\n**${title}**\n\n${content}${content.length >= 500 ? '...' : ''}\n\n[点击查看详情](${url})\n\n---\n📦 ${context.repo.owner}/${context.repo.repo}\n\n**关键词**: issue`
}
};
await fetch(webhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message)
});
console.log('✅ DingTalk notification sent');
env:
DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }}
+6
View File
@@ -38,6 +38,12 @@ jobs:
- name: Post-release packaging
run: ./scripts/release/post-goreleaser.sh
- name: Upload skills package to release
run: |
gh release upload "${{ github.ref_name }}" dist/dws-skills.zip --clobber
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
+1
View File
@@ -69,3 +69,4 @@ release:
draft: false
prerelease: auto
name_template: "v{{.Version}}"
mode: replace
+82 -27
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.5-red" alt="v1.0.5"></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>
@@ -21,7 +21,7 @@
> [!IMPORTANT]
> **Co-creation Phase**: This project accesses DingTalk enterprise data and requires enterprise admin authorization. Please join the DingTalk DWS co-creation group to complete whitelist configuration. See [Getting Started](#getting-started) below.
>
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i1/O1CN01ZqtgeV1cImFmTZPAH_!!6000000003578-2-tps-398-372.png" alt="DingTalk Group QR Code" width="150"></a>
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
<details>
<summary><strong>Table of Contents</strong></summary>
@@ -39,6 +39,7 @@
</details>
---
<h2 id="why-dws">Why dws?</h2>
@@ -66,6 +67,11 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
**Pre-built binary**: download from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases).
> **macOS users**: If you see "cannot be opened because Apple cannot check it for malicious software", run:
> ```bash
> xattr -d com.apple.quarantine /path/to/dws
> ```
**Build from source**:
```bash
@@ -97,8 +103,7 @@ Go to the [Open Platform Console](https://open-dev.dingtalk.com/fe/app?hash=%23%
Go to app settings → **Security Settings**. Add the following redirect URLs and save:
```
http://127.0.0.1
https://login.dingtalk.com
http://127.0.0.1,https://login.dingtalk.com
```
> `http://127.0.0.1` is for local browser login; `https://login.dingtalk.com` is for `--device` device-flow login (Docker containers, remote servers, and other headless environments). We recommend configuring both.
@@ -139,7 +144,23 @@ 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
@@ -183,7 +204,7 @@ 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 BASE_ID --table-id TABLE_ID --limit 10
@@ -191,7 +212,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
### Agent Skills
The repo ships Agent Skills (`SKILL.md` files) for every DingTalk product. After installing, tools like Claude Code / Cursor can use DingTalk capabilities directly:
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
@@ -200,12 +221,45 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
Author your own Agent Skills and orchestrate them with dws skills for cross-product workflows: **ISV Skill → dws Skill → DingTalk Open Platform API (enforced auth + full audit)**.
**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>
<summary><strong>Smart Input Correction</strong> — auto-corrects common AI model parameter mistakes</summary>
Built-in pipeline engine that normalizes flag names, splits sticky arguments, and fuzzy-matches typos:
@@ -234,7 +288,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
</details>
<details>
<summary><strong>jq Filtering & Field Selection</strong> — fine-grained output control to reduce token consumption <code>v1.0.1</code></summary>
<summary><strong>jq Filtering & Field Selection</strong> — fine-grained output control to reduce token consumption</summary>
```bash
# Built-in jq expressions
@@ -248,19 +302,19 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
</details>
<details>
<summary><strong>Schema Introspection</strong> — query parameter schemas before making calls <code>v1.0.1</code></summary>
<summary><strong>Schema Introspection</strong> — query parameter schemas before making calls</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>
<summary><strong>Pipe & File Input</strong> — read flag values from files or stdin</summary>
```bash
# Read message body from a file
@@ -280,21 +334,22 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
## 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 | `oa` | 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, 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>
+82 -27
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.5-red" alt="v1.0.5"></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>
@@ -21,7 +21,7 @@
> [!IMPORTANT]
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。当前为灰度共创阶段,请加入钉钉 DWS 共创群完成白名单配置。详见下方 [开始使用](#开始使用)。
>
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i1/O1CN01ZqtgeV1cImFmTZPAH_!!6000000003578-2-tps-398-372.png" alt="DingTalk Group QR Code" width="150"></a>
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
<details>
<summary><strong>目录</strong></summary>
@@ -39,6 +39,7 @@
</details>
---
<h2 id="why-dws">为什么选择 dws?</h2>
@@ -66,6 +67,11 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
**预编译二进制文件**:从 [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) 下载。
> **macOS 用户注意**:如果提示“无法打开,因为 Apple 无法检查其是否包含恶意软件”,请执行:
> ```bash
> xattr -d com.apple.quarantine /path/to/dws
> ```
**从源码构建**:
```bash
@@ -97,8 +103,7 @@ cp dws ~/.local/bin/ # 安装到 PATH
进入应用 → **安全设置**,在「重定向 URL」中添加以下地址并保存:
```
http://127.0.0.1
https://login.dingtalk.com
http://127.0.0.1,https://login.dingtalk.com
```
> `http://127.0.0.1` 用于本地浏览器登录;`https://login.dingtalk.com` 用于 `--device` 设备流登录(Docker 容器、远程服务器等无浏览器环境)。建议两个都配置。
@@ -139,7 +144,23 @@ 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>
## 快速开始
@@ -183,7 +204,7 @@ 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 BASE_ID --table-id TABLE_ID --limit 10
@@ -191,7 +212,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
### Agent Skills
仓库为每个钉钉产品提供 Agent Skill(`SKILL.md`),安装后 Claude Code / Cursor 等工具可直接使用钉钉能力:
仓库内置完整的 Agent Skill 体系(`skills/`),安装后 Claude Code / Cursor 等 AI 工具可通过自然语言直接操作钉钉:
```bash
# 安装 skills 到当前项目
@@ -200,12 +221,45 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
编写您自己的 Agent Skill,与 dws 内置 Skill 搭配构建跨产品工作流:**ISV Skill → dws Skill → 钉钉开放平台 API(强制鉴权 + 全链路审计)**。
**包含内容:**
| 组件 | 路径 | 说明 |
|------|------|------|
| 主 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>
<summary><strong>智能输入纠错</strong> — 自动修正 AI 模型常见的参数错误</summary>
内置 Pipeline 纠错引擎,支持命名风格转换、粘连参数拆分、拼写模糊匹配:
@@ -234,7 +288,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
</details>
<details>
<summary><strong>jq 过滤 & 字段筛选</strong> — 精确控制输出,减少 token 消耗 <code>v1.0.1</code></summary>
<summary><strong>jq 过滤 & 字段筛选</strong> — 精确控制输出,减少 token 消耗</summary>
```bash
# 内置 jq 表达式
@@ -248,19 +302,19 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
</details>
<details>
<summary><strong>Schema 自省</strong> — 调用前查询任意工具的参数结构 <code>v1.0.1</code></summary>
<summary><strong>Schema 自省</strong> — 调用前查询任意工具的参数结构</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>
<summary><strong>管道 & 文件输入</strong> — 从文件或 stdin 读取 flag 值</summary>
```bash
# 从文件读取消息内容
@@ -280,21 +334,22 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
## 核心服务
| 服务 | 命令 | 描述 |
|---------|---------|-------------|
| 通讯录 | `contact` | 用户 / 部门 |
| 群聊 | `chat` | 群管理 / 群成员 / 机器人消息 / Webhook |
| 日历 | `calendar` | 日程 / 会议室 / 闲忙 |
| 待办 | `todo` | 任务管理 |
| 审批 | `oa` | 流程 / 表单 / 实例 |
| 考勤 | `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>
+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] 已清除所有认证信息")
+38 -4
View File
@@ -16,6 +16,7 @@ package app
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/http"
@@ -59,6 +60,12 @@ func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cob
// Tests may override discoveryBaseURLOverride to redirect to a local server;
// in that case the registry cache is always bypassed.
func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.Command {
totalStart := time.Now()
defer func() {
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] loadDynamicCommands total: %v\n", time.Since(totalStart))
}
}()
store := cacheStoreFromEnv()
partition := config.DefaultPartition
@@ -70,13 +77,20 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
useCache := strings.TrimSpace(os.Getenv(cli.CatalogFixtureEnv)) == ""
// --- Cache-first server registry ---
cacheLoadStart := time.Now()
snapshot, freshness, cacheErr := store.LoadRegistry(partition)
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] cache load: %v (err=%v)\n", time.Since(cacheLoadStart), cacheErr)
}
var servers []market.ServerDescriptor
now := store.Now().UTC()
usingCachedRegistry := useCache && cacheErr == nil && len(snapshot.Servers) > 0
if usingCachedRegistry {
slog.Debug("loadDynamicCommands: using cached registry", "servers", len(snapshot.Servers), "freshness", freshness)
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] using cached registry: servers=%d, freshness=%s\n", len(snapshot.Servers), freshness)
}
servers = snapshot.Servers
// Only trigger async revalidation in production (no URL override).
// Tests set discoveryBaseURLOverride and control cache expiry directly,
@@ -92,9 +106,15 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
if discoveryBaseURLOverride != "" {
baseURL = discoveryBaseURLOverride
}
slog.Debug("loadDynamicCommands: fetching servers from market API", "base_url", baseURL)
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] fetching from market API: %s\n", baseURL)
}
fetchStart := time.Now()
client := market.NewClient(baseURL, ipv4OnlyHTTPClient())
resp, fetchErr := client.FetchServers(ctx, config.DefaultFetchServersLimit)
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] market API fetch: %v (err=%v)\n", time.Since(fetchStart), fetchErr)
}
if fetchErr != nil {
slog.Debug("loadDynamicCommands: market API fetch failed", "error", fetchErr)
// Degrade to stale cache if available (production only).
@@ -106,12 +126,18 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
}
} else {
servers = market.NormalizeServers(resp, "market")
slog.Debug("loadDynamicCommands: normalized servers", "count", len(servers))
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] normalized servers: %d\n", len(servers))
}
// Persist fresh data (only in non-test mode).
if useCache {
saveStart := time.Now()
if saveErr := store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers}); saveErr != nil {
slog.Debug("loadDynamicCommands: failed to save registry cache", "error", saveErr)
}
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] cache save: %v\n", time.Since(saveStart))
}
}
}
}
@@ -122,9 +148,17 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
// Inject dynamic server data for endpoint resolution
SetDynamicServers(servers)
detailStart := time.Now()
detailsByID := loadCachedDetailsFast(store, servers)
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] load details: %v\n", time.Since(detailStart))
}
buildStart := time.Now()
cmds := compat.BuildDynamicCommands(servers, runner, detailsByID)
slog.Debug("loadDynamicCommands: built dynamic commands", "commands", len(cmds))
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] build commands: %v (count=%d)\n", time.Since(buildStart), len(cmds))
}
return cmds
}
+35 -1
View File
@@ -51,19 +51,34 @@ const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID="
// Execute runs the root command and returns the process exit code.
func Execute() int {
totalStart := time.Now()
defer func() {
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] Execute total: %v\n", time.Since(totalStart))
}
}()
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
initStart := time.Now()
recovery.ResetRuntimeState()
engine := newPipelineEngine()
root := NewRootCommandWithEngine(ctx, engine)
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] command init: %v\n", time.Since(initStart))
}
// Run PreParse handlers on raw argv before Cobra parses flags.
// This corrects model-generated errors like --userId → --user-id
// and --limit100 → --limit 100.
pipeline.RunPreParse(root, engine)
execStart := time.Now()
executed, err := root.ExecuteC()
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] cobra ExecuteC: %v\n", time.Since(execStart))
}
if err != nil {
if executed == nil {
executed = root
@@ -117,7 +132,21 @@ func printExecutionError(root *cobra.Command, stdout, stderr io.Writer, err erro
if wantsJSONErrors(root) {
return apperrors.PrintJSON(stdout, err)
}
return apperrors.PrintHuman(stderr, err)
return apperrors.PrintHumanAt(stderr, err, resolveVerbosity(root))
}
// resolveVerbosity derives the error verbosity level from the root command's flags.
func resolveVerbosity(cmd *cobra.Command) apperrors.Verbosity {
if cmd == nil {
return apperrors.VerbosityNormal
}
if debug, err := cmd.Flags().GetBool("debug"); err == nil && debug {
return apperrors.VerbosityDebug
}
if verbose, err := cmd.Flags().GetBool("verbose"); err == nil && verbose {
return apperrors.VerbosityVerbose
}
return apperrors.VerbosityNormal
}
func wantsJSONErrors(root *cobra.Command) bool {
@@ -236,6 +265,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
utilityCommands := []*cobra.Command{
newAuthCommand(),
newSkillCommand(),
newCacheCommand(),
newCompletionCommand(root),
newRecoveryCommand(rootCtx, loader, flags),
@@ -261,6 +291,10 @@ func newAuthCommand() *cobra.Command {
return buildAuthCommand()
}
func newSkillCommand() *cobra.Command {
return buildSkillCommand()
}
func newCacheCommand() *cobra.Command {
cacheCmd := newPlaceholderParent("cache", "缓存管理")
+111 -19
View File
@@ -22,6 +22,7 @@ import (
"net/http"
"os"
"strings"
"sync"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
@@ -74,6 +75,13 @@ type runtimeRunner struct {
}
func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
totalStart := time.Now()
defer func() {
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] runtimeRunner.Run total: %v\n", time.Since(totalStart))
}
}()
if r.loader == nil || r.transport == nil {
return r.fallback.Run(ctx, invocation)
}
@@ -117,7 +125,17 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
}
func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, invocation executor.Invocation) (executor.Result, error) {
// Lazy bind FileLogger: it may be nil at construction time because
// configureLogLevel runs later in PersistentPreRunE.
if r.transport.FileLogger == nil {
r.transport.FileLogger = FileLoggerInstance()
}
authStart := time.Now()
tc := r.transport.WithAuth(r.resolveAuthToken(ctx), resolveIdentityHeaders())
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] resolveAuthToken: %v\n", time.Since(authStart))
}
if invocation.DryRun {
return executor.Result{
@@ -148,19 +166,26 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
}, nil
}
callStart := time.Now()
callResult, err := tc.CallTool(ctx, endpoint, invocation.Tool, invocation.Params)
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] MCP CallTool: %v\n", time.Since(callStart))
}
if err != nil {
captureRuntimeFailure(invocation, err, err)
return executor.Result{}, err
}
if callResult.IsError {
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
logBusinessError(r.transport.FileLogger, "mcp_tool_error", invocation, callResult.Content, diag)
mcpErr := apperrors.NewAPI(
extractMCPErrorMessage(callResult),
apperrors.WithOperation("tools/call"),
apperrors.WithReason("mcp_tool_error"),
apperrors.WithServerKey(invocation.CanonicalProduct),
apperrors.WithHint("MCP tool returned a business error; check tool parameters and refer to skill documentation."),
apperrors.WithServerDiag(diag),
)
captureRuntimeFailure(invocation, mcpErr, mcpErr)
return executor.Result{}, mcpErr
@@ -172,11 +197,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
}
if bizErr := detectBusinessError(callResult.Content); bizErr != "" {
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
logBusinessError(r.transport.FileLogger, "business_error", invocation, callResult.Content, diag)
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."),
apperrors.WithServerDiag(diag),
)
}
@@ -203,25 +231,56 @@ func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) string {
if token := strings.TrimSpace(explicitToken); token != "" {
return token
}
configDir := defaultConfigDir()
provider := authpkg.NewOAuthProvider(configDir, slog.New(slog.NewTextHandler(io.Discard, nil)))
configureOAuthProviderCompatibility(provider, configDir)
token, tokenErr := provider.GetAccessToken(ctx)
if tokenErr == nil && strings.TrimSpace(token) != "" {
return strings.TrimSpace(token)
}
// If the error is a decryption failure (corrupted data), surface
// it immediately instead of falling back to empty token.
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
slog.Error(tokenErr.Error())
return ""
}
manager := authpkg.NewManager(configDir, nil)
configureLegacyAuthManagerCompatibility(manager)
if token, _, err := manager.GetToken(); err == nil && strings.TrimSpace(token) != "" {
return strings.TrimSpace(token)
}
return ""
// Use cached token to avoid repeated Keychain access (~70ms per call)
return getCachedRuntimeToken(ctx)
}
// Cached token state for process lifetime
var (
cachedRuntimeToken string
cachedRuntimeTokenOnce sync.Once
)
// getCachedRuntimeToken returns a cached access token, loading it only once per process.
// This avoids repeated Keychain access which takes ~70ms each time.
func getCachedRuntimeToken(ctx context.Context) string {
cachedRuntimeTokenOnce.Do(func() {
loadStart := time.Now()
defer func() {
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] getCachedRuntimeToken (first load): %v\n", time.Since(loadStart))
}
}()
configDir := defaultConfigDir()
provider := authpkg.NewOAuthProvider(configDir, slog.New(slog.NewTextHandler(io.Discard, nil)))
configureOAuthProviderCompatibility(provider, configDir)
token, tokenErr := provider.GetAccessToken(ctx)
if tokenErr == nil && strings.TrimSpace(token) != "" {
cachedRuntimeToken = strings.TrimSpace(token)
return
}
// If the error is a decryption failure (corrupted data), log and bail out
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
slog.Error(tokenErr.Error())
return
}
// Try legacy manager as fallback
manager := authpkg.NewManager(configDir, nil)
configureLegacyAuthManagerCompatibility(manager)
if token, _, err := manager.GetToken(); err == nil && strings.TrimSpace(token) != "" {
cachedRuntimeToken = strings.TrimSpace(token)
return
}
})
return cachedRuntimeToken
}
// ResetRuntimeTokenCache clears the cached token, forcing a reload on next access.
// This should be called after login/logout operations.
func ResetRuntimeTokenCache() {
cachedRuntimeTokenOnce = sync.Once{}
cachedRuntimeToken = ""
}
func newRuntimeContentScanner() safety.Scanner {
@@ -328,3 +387,36 @@ func extractMCPErrorMessage(result transport.ToolCallResult) string {
}
return "MCP tool returned an error response"
}
// logBusinessError logs MCP tool errors and business errors to the file logger
// so they can be diagnosed offline. These errors arrive as HTTP 200 responses
// and would otherwise not be captured by transport-level logging.
func logBusinessError(logger *slog.Logger, reason string, inv executor.Invocation, content map[string]any, diag apperrors.ServerDiagnostics) {
if logger == nil {
return
}
attrs := []any{
"product", inv.CanonicalProduct,
"tool", inv.Tool,
"reason", reason,
}
if diag.TraceID != "" {
attrs = append(attrs, "trace_id", diag.TraceID)
}
if diag.ServerErrorCode != "" {
attrs = append(attrs, "server_error_code", diag.ServerErrorCode)
}
if diag.TechnicalDetail != "" {
attrs = append(attrs, "technical_detail", diag.TechnicalDetail)
}
if msg, ok := content["error"].(string); ok {
attrs = append(attrs, "error", msg)
}
if msg, ok := content["errorMsg"].(string); ok {
attrs = append(attrs, "errorMsg", msg)
}
if msg, ok := content["message"].(string); ok {
attrs = append(attrs, "message", msg)
}
logger.Warn("business_error", attrs...)
}
+377
View File
@@ -0,0 +1,377 @@
// 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 app
import (
"archive/zip"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
const (
// skillDownloadEndpoint is the API endpoint for downloading skills.
skillDownloadEndpoint = "https://aihub.dingtalk.com/cli/download"
// skillDownloadTimeout is the timeout for skill download operations.
skillDownloadTimeout = 5 * time.Minute
)
// downloadSkillResponse represents the API response for skill download.
type downloadSkillResponse struct {
Success bool `json:"success"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMsg string `json:"errorMsg,omitempty"`
Result *downloadSkillResult `json:"result,omitempty"`
}
// downloadSkillResult contains the download URL and file name.
type downloadSkillResult struct {
DownloadURL string `json:"downloadUrl"`
FileName string `json:"fileName"`
}
// agentSkillPaths maps target names to their relative skill installation paths.
// These paths are relative to the user's home directory.
var agentSkillPaths = map[string]string{
"qoder": ".qoder/skills",
"claude": ".claude/skills",
"cursor": ".cursor/skills",
"codex": ".codex/skills",
"opencode": filepath.Join(".config", "opencode", "skills"),
}
// supportedTargets returns a comma-separated list of supported targets.
func supportedTargets() string {
targets := make([]string, 0, len(agentSkillPaths)+1)
for target := range agentSkillPaths {
targets = append(targets, target)
}
targets = append(targets, ".")
return strings.Join(targets, ", ")
}
func buildSkillCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "skill",
Short: "技能管理",
Long: "管理钉钉技能市场的技能。支持下载和安装技能到指定 Agent 目录。",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
cmd.AddCommand(newSkillAddCommand())
return cmd
}
func newSkillAddCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "add <skillId> <target>",
Short: "下载并安装技能到指定目录",
Long: fmt.Sprintf(`从钉钉技能市场下载技能并安装到指定 Agent 目录。
参数:
skillId 技能 ID(必填),可从钉钉技能市场获取
target 安装目标(必填),支持: %s
安装路径:
qoder -> ~/.qoder/skills/
claude -> ~/.claude/skills/
cursor -> ~/.cursor/skills/
codex -> ~/.codex/skills/
opencode -> ~/.config/opencode/skills/
. -> 当前目录
示例:
dws skill add skill-123 qoder # 安装到 ~/.qoder/skills/
dws skill add skill-123 claude # 安装到 ~/.claude/skills/
dws skill add skill-123 . # 安装到当前目录`, supportedTargets()),
Args: cobra.ExactArgs(2),
DisableAutoGenTag: true,
RunE: runSkillAdd,
}
return cmd
}
func runSkillAdd(cmd *cobra.Command, args []string) error {
skillID := strings.TrimSpace(args[0])
target := strings.TrimSpace(args[1])
if skillID == "" {
return apperrors.NewValidation("skillId is required")
}
// Resolve target path
destPath, err := resolveSkillTargetPath(target)
if err != nil {
return apperrors.NewValidation(fmt.Sprintf("invalid target '%s': %v. Supported targets: %s", target, err, supportedTargets()))
}
// Load auth token
configDir := defaultConfigDir()
tokenData, err := authpkg.LoadTokenData(configDir)
if err != nil || tokenData == nil || !tokenData.IsAccessTokenValid() {
return apperrors.NewAuth("not logged in or token expired. Please run 'dws auth login' first",
apperrors.WithHint("请先执行 'dws auth login' 登录"),
apperrors.WithActions("dws auth login"))
}
ctx, cancel := context.WithTimeout(cmd.Context(), skillDownloadTimeout)
defer cancel()
w := cmd.OutOrStdout()
// Step 1: Get download URL from API
fmt.Fprintf(w, "正在获取技能信息...\n")
downloadResp, err := fetchSkillDownloadInfo(ctx, tokenData.AccessToken, skillID)
if err != nil {
return err
}
if !downloadResp.Success {
errMsg := downloadResp.ErrorMsg
if errMsg == "" {
errMsg = downloadResp.ErrorCode
}
if errMsg == "" {
errMsg = "unknown error"
}
return apperrors.NewAPI(fmt.Sprintf("failed to get skill download info: %s", errMsg),
apperrors.WithReason(downloadResp.ErrorCode))
}
if downloadResp.Result == nil || downloadResp.Result.DownloadURL == "" {
return apperrors.NewAPI("skill download URL not found in response")
}
// Step 2: Download the skill zip file
fmt.Fprintf(w, "正在下载技能...\n")
tempZipPath, err := downloadSkillFile(ctx, downloadResp.Result.DownloadURL, downloadResp.Result.FileName)
if err != nil {
return err
}
defer cleanupTempFile(tempZipPath)
// Step 3: Extract zip to destination
fmt.Fprintf(w, "正在解压到 %s...\n", destPath)
if err := extractSkillZip(tempZipPath, destPath); err != nil {
return err
}
fmt.Fprintf(w, "\n[OK] 技能安装成功!\n")
fmt.Fprintf(w, "安装路径: %s\n", destPath)
return nil
}
// resolveSkillTargetPath resolves the target argument to an absolute path.
func resolveSkillTargetPath(target string) (string, error) {
target = strings.TrimSpace(target)
if target == "" {
return "", fmt.Errorf("target is required")
}
// Special case: current directory
if target == "." {
return os.Getwd()
}
// Look up predefined agent paths
relPath, ok := agentSkillPaths[strings.ToLower(target)]
if !ok {
return "", fmt.Errorf("unsupported target")
}
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
return filepath.Join(homeDir, relPath), nil
}
// fetchSkillDownloadInfo calls the download API to get the skill download URL.
func fetchSkillDownloadInfo(ctx context.Context, accessToken, skillID string) (*downloadSkillResponse, error) {
url := fmt.Sprintf("%s?skillId=%s", skillDownloadEndpoint, skillID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err))
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-user-access-token", accessToken)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, apperrors.NewAPI(fmt.Sprintf("failed to call download API: %v", err),
apperrors.WithRetryable(true))
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return nil, apperrors.NewAuth("authentication failed. Please run 'dws auth login' to refresh your token",
apperrors.WithHint("请执行 'dws auth login' 重新登录"),
apperrors.WithActions("dws auth login"))
}
if resp.StatusCode != http.StatusOK {
return nil, apperrors.NewAPI(fmt.Sprintf("download API returned HTTP %d", resp.StatusCode),
apperrors.WithRetryable(resp.StatusCode >= 500))
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // 10MB limit
if err != nil {
return nil, apperrors.NewAPI(fmt.Sprintf("failed to read response: %v", err))
}
var result downloadSkillResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, apperrors.NewAPI(fmt.Sprintf("failed to parse response: %v", err))
}
return &result, nil
}
// downloadSkillFile downloads the skill zip file to a temporary location.
func downloadSkillFile(ctx context.Context, downloadURL, fileName string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return "", apperrors.NewInternal(fmt.Sprintf("failed to create download request: %v", err))
}
client := &http.Client{Timeout: skillDownloadTimeout}
resp, err := client.Do(req)
if err != nil {
return "", apperrors.NewAPI(fmt.Sprintf("failed to download skill: %v", err),
apperrors.WithRetryable(true))
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", apperrors.NewAPI(fmt.Sprintf("download returned HTTP %d", resp.StatusCode),
apperrors.WithRetryable(resp.StatusCode >= 500))
}
// Create temp file
if fileName == "" {
fileName = "skill.zip"
}
tempFile, err := os.CreateTemp("", "dws-skill-*.zip")
if err != nil {
return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp file: %v", err))
}
tempPath := tempFile.Name()
// Copy response body to temp file
_, err = io.Copy(tempFile, resp.Body)
closeErr := tempFile.Close()
if err != nil {
os.Remove(tempPath)
return "", apperrors.NewAPI(fmt.Sprintf("failed to save downloaded file: %v", err))
}
if closeErr != nil {
os.Remove(tempPath)
return "", apperrors.NewInternal(fmt.Sprintf("failed to close temp file: %v", closeErr))
}
return tempPath, nil
}
// extractSkillZip extracts a zip file to the destination directory.
func extractSkillZip(zipPath, destDir string) error {
// Ensure destination directory exists
if err := os.MkdirAll(destDir, 0755); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to create destination directory: %v", err))
}
reader, err := zip.OpenReader(zipPath)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to open zip file: %v", err))
}
defer reader.Close()
for _, file := range reader.File {
if err := extractZipFile(file, destDir); err != nil {
return err
}
}
return nil
}
// extractZipFile extracts a single file from the zip archive.
func extractZipFile(file *zip.File, destDir string) error {
// Sanitize file path to prevent zip slip attacks
filePath := filepath.Join(destDir, file.Name)
if !strings.HasPrefix(filepath.Clean(filePath), filepath.Clean(destDir)+string(os.PathSeparator)) {
return apperrors.NewValidation(fmt.Sprintf("invalid file path in zip: %s", file.Name))
}
if file.FileInfo().IsDir() {
// Use 0755 to ensure we have write permission for creating files inside
return os.MkdirAll(filePath, 0755)
}
// Ensure parent directory exists with write permission
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to create directory: %v", err))
}
// Extract file
srcFile, err := file.Open()
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to open file in zip: %v", err))
}
defer srcFile.Close()
// Use file mode from zip but ensure at least 0644 for files
fileMode := file.Mode()
if fileMode&0600 == 0 {
fileMode = 0644
}
destFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to create file: %v", err))
}
defer destFile.Close()
if _, err := io.Copy(destFile, srcFile); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to extract file: %v", err))
}
return nil
}
// cleanupTempFile removes a temporary file, ignoring errors.
func cleanupTempFile(path string) {
if path != "" {
os.Remove(path)
}
}
+732
View File
@@ -0,0 +1,732 @@
// 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 app
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
)
func TestResolveSkillTargetPath(t *testing.T) {
homeDir, err := os.UserHomeDir()
if err != nil {
t.Fatalf("failed to get home directory: %v", err)
}
tests := []struct {
name string
target string
wantSuffix string
wantErr bool
}{
{
name: "qoder target",
target: "qoder",
wantSuffix: filepath.Join(".qoder", "skills"),
wantErr: false,
},
{
name: "claude target",
target: "claude",
wantSuffix: filepath.Join(".claude", "skills"),
wantErr: false,
},
{
name: "cursor target",
target: "cursor",
wantSuffix: filepath.Join(".cursor", "skills"),
wantErr: false,
},
{
name: "codex target",
target: "codex",
wantSuffix: filepath.Join(".codex", "skills"),
wantErr: false,
},
{
name: "opencode target",
target: "opencode",
wantSuffix: filepath.Join(".config", "opencode", "skills"),
wantErr: false,
},
{
name: "case insensitive - QODER",
target: "QODER",
wantSuffix: filepath.Join(".qoder", "skills"),
wantErr: false,
},
{
name: "case insensitive - Claude",
target: "Claude",
wantSuffix: filepath.Join(".claude", "skills"),
wantErr: false,
},
{
name: "invalid target",
target: "invalid",
wantErr: true,
},
{
name: "empty target",
target: "",
wantErr: true,
},
{
name: "whitespace only",
target: " ",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := resolveSkillTargetPath(tt.target)
if (err != nil) != tt.wantErr {
t.Errorf("resolveSkillTargetPath() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
expected := filepath.Join(homeDir, tt.wantSuffix)
if got != expected {
t.Errorf("resolveSkillTargetPath() = %v, want %v", got, expected)
}
}
})
}
}
func TestResolveSkillTargetPathCurrentDir(t *testing.T) {
// Test "." target returns current working directory
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get working directory: %v", err)
}
got, err := resolveSkillTargetPath(".")
if err != nil {
t.Errorf("resolveSkillTargetPath(\".\") error = %v", err)
return
}
if got != cwd {
t.Errorf("resolveSkillTargetPath(\".\") = %v, want %v", got, cwd)
}
}
func TestParseDownloadSkillResponse(t *testing.T) {
tests := []struct {
name string
jsonInput string
wantSuccess bool
wantURL string
wantFile string
wantErrCode string
wantErrMsg string
}{
{
name: "successful response",
jsonInput: `{
"success": true,
"result": {
"downloadUrl": "https://example.com/skill.zip",
"fileName": "my-skill.zip"
}
}`,
wantSuccess: true,
wantURL: "https://example.com/skill.zip",
wantFile: "my-skill.zip",
},
{
name: "error response",
jsonInput: `{
"success": false,
"errorCode": "SKILL_NOT_FOUND",
"errorMsg": "The skill does not exist"
}`,
wantSuccess: false,
wantErrCode: "SKILL_NOT_FOUND",
wantErrMsg: "The skill does not exist",
},
{
name: "success with empty result",
jsonInput: `{
"success": true,
"result": null
}`,
wantSuccess: true,
wantURL: "",
wantFile: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var resp downloadSkillResponse
if err := json.Unmarshal([]byte(tt.jsonInput), &resp); err != nil {
t.Fatalf("failed to unmarshal JSON: %v", err)
}
if resp.Success != tt.wantSuccess {
t.Errorf("Success = %v, want %v", resp.Success, tt.wantSuccess)
}
if tt.wantSuccess && resp.Result != nil {
if resp.Result.DownloadURL != tt.wantURL {
t.Errorf("DownloadURL = %v, want %v", resp.Result.DownloadURL, tt.wantURL)
}
if resp.Result.FileName != tt.wantFile {
t.Errorf("FileName = %v, want %v", resp.Result.FileName, tt.wantFile)
}
}
if !tt.wantSuccess {
if resp.ErrorCode != tt.wantErrCode {
t.Errorf("ErrorCode = %v, want %v", resp.ErrorCode, tt.wantErrCode)
}
if resp.ErrorMsg != tt.wantErrMsg {
t.Errorf("ErrorMsg = %v, want %v", resp.ErrorMsg, tt.wantErrMsg)
}
}
})
}
}
func TestExtractSkillZip(t *testing.T) {
// Create a temporary zip file with test content
tempDir := t.TempDir()
zipPath := filepath.Join(tempDir, "test.zip")
destDir := filepath.Join(tempDir, "extracted")
// Create zip file with test content
zipFile, err := os.Create(zipPath)
if err != nil {
t.Fatalf("failed to create zip file: %v", err)
}
zipWriter := zip.NewWriter(zipFile)
// Add a file to the zip
fileContent := []byte("test content")
writer, err := zipWriter.Create("test-file.txt")
if err != nil {
t.Fatalf("failed to create file in zip: %v", err)
}
if _, err := writer.Write(fileContent); err != nil {
t.Fatalf("failed to write file content: %v", err)
}
// Add a subdirectory with a file
writer, err = zipWriter.Create("subdir/nested-file.txt")
if err != nil {
t.Fatalf("failed to create nested file in zip: %v", err)
}
if _, err := writer.Write([]byte("nested content")); err != nil {
t.Fatalf("failed to write nested file content: %v", err)
}
if err := zipWriter.Close(); err != nil {
t.Fatalf("failed to close zip writer: %v", err)
}
if err := zipFile.Close(); err != nil {
t.Fatalf("failed to close zip file: %v", err)
}
// Extract the zip
if err := extractSkillZip(zipPath, destDir); err != nil {
t.Fatalf("extractSkillZip() error = %v", err)
}
// Verify extracted files
extractedFile := filepath.Join(destDir, "test-file.txt")
content, err := os.ReadFile(extractedFile)
if err != nil {
t.Errorf("failed to read extracted file: %v", err)
}
if string(content) != "test content" {
t.Errorf("extracted content = %v, want %v", string(content), "test content")
}
// Verify nested file
nestedFile := filepath.Join(destDir, "subdir", "nested-file.txt")
content, err = os.ReadFile(nestedFile)
if err != nil {
t.Errorf("failed to read nested file: %v", err)
}
if string(content) != "nested content" {
t.Errorf("nested content = %v, want %v", string(content), "nested content")
}
}
func TestExtractSkillZipPreventZipSlip(t *testing.T) {
tempDir := t.TempDir()
zipPath := filepath.Join(tempDir, "malicious.zip")
destDir := filepath.Join(tempDir, "extracted")
// Create a zip file with a path traversal attempt
zipFile, err := os.Create(zipPath)
if err != nil {
t.Fatalf("failed to create zip file: %v", err)
}
zipWriter := zip.NewWriter(zipFile)
// Try to create a file with path traversal
writer, err := zipWriter.Create("../../../etc/passwd")
if err != nil {
t.Fatalf("failed to create malicious file in zip: %v", err)
}
if _, err := writer.Write([]byte("malicious content")); err != nil {
t.Fatalf("failed to write malicious content: %v", err)
}
if err := zipWriter.Close(); err != nil {
t.Fatalf("failed to close zip writer: %v", err)
}
if err := zipFile.Close(); err != nil {
t.Fatalf("failed to close zip file: %v", err)
}
// Extract should fail due to zip slip protection
err = extractSkillZip(zipPath, destDir)
if err == nil {
t.Error("extractSkillZip() should have failed for zip slip attack")
}
if !strings.Contains(err.Error(), "invalid file path") {
t.Errorf("error should mention invalid file path, got: %v", err)
}
}
func TestSkillAddCommandValidation(t *testing.T) {
tests := []struct {
name string
args []string
wantErr bool
errMsg string
}{
{
name: "missing arguments",
args: []string{"skill", "add"},
wantErr: true,
errMsg: "accepts 2 arg(s)",
},
{
name: "missing target",
args: []string{"skill", "add", "skill-123"},
wantErr: true,
errMsg: "accepts 2 arg(s)",
},
{
name: "too many arguments",
args: []string{"skill", "add", "skill-123", "qoder", "extra"},
wantErr: true,
errMsg: "accepts 2 arg(s)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := NewRootCommand()
cmd.SetArgs(tt.args)
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
err := cmd.Execute()
if (err != nil) != tt.wantErr {
t.Errorf("Execute() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("error = %v, should contain %v", err, tt.errMsg)
}
})
}
}
func TestSkillAddInvalidTarget(t *testing.T) {
// Setup: Create config directory with valid token
tempDir := t.TempDir()
configDir := filepath.Join(tempDir, "config")
t.Setenv("DWS_CONFIG_DIR", configDir)
// Save a valid token
err := authpkg.SaveTokenData(configDir, &authpkg.TokenData{
AccessToken: "test-token",
RefreshToken: "refresh-token",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
})
if err != nil {
t.Fatalf("failed to save token data: %v", err)
}
cmd := NewRootCommand()
cmd.SetArgs([]string{"skill", "add", "skill-123", "invalid-target"})
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
err = cmd.Execute()
if err == nil {
t.Error("Execute() should have failed for invalid target")
}
if !strings.Contains(err.Error(), "invalid target") {
t.Errorf("error should mention invalid target, got: %v", err)
}
}
func TestSkillAddRequiresAuth(t *testing.T) {
// Setup: Create config directory without token
tempDir := t.TempDir()
configDir := filepath.Join(tempDir, "config")
t.Setenv("DWS_CONFIG_DIR", configDir)
// Ensure the config directory exists but has no token
if err := os.MkdirAll(configDir, 0755); err != nil {
t.Fatalf("failed to create config dir: %v", err)
}
cmd := NewRootCommand()
cmd.SetArgs([]string{"skill", "add", "skill-123", "qoder"})
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
err := cmd.Execute()
if err == nil {
t.Error("Execute() should have failed without auth")
}
// Check for authentication-related error (English or Chinese)
errStr := err.Error()
if !strings.Contains(errStr, "not logged in") && !strings.Contains(errStr, "token") && !strings.Contains(errStr, "未登录") && !strings.Contains(errStr, "auth") {
t.Errorf("error should mention authentication, got: %v", err)
}
}
func TestFetchSkillDownloadInfoUnauthorized(t *testing.T) {
// Create mock server that returns 401
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()
// We can't easily test the actual fetchSkillDownloadInfo function
// because it uses a hardcoded URL. This test verifies HTTP 401 handling pattern.
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(server.URL)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", resp.StatusCode)
}
}
func TestSupportedTargets(t *testing.T) {
targets := supportedTargets()
// Should contain all predefined targets
expectedTargets := []string{"qoder", "claude", "cursor", "codex", "opencode", "."}
for _, expected := range expectedTargets {
if !strings.Contains(targets, expected) {
t.Errorf("supportedTargets() should contain %s, got: %s", expected, targets)
}
}
}
func TestAgentSkillPathsCrossPlatform(t *testing.T) {
// Verify that paths use platform-appropriate separators
for target, path := range agentSkillPaths {
if runtime.GOOS == "windows" {
if strings.Contains(path, "/") && !strings.Contains(path, "\\") {
// On Windows, filepath.Join should use backslashes
// But raw map values may use forward slashes
t.Logf("Note: %s path '%s' uses forward slashes (will be converted by filepath.Join)", target, path)
}
}
// Test that resolveSkillTargetPath produces valid paths
resolved, err := resolveSkillTargetPath(target)
if err != nil {
t.Errorf("resolveSkillTargetPath(%s) failed: %v", target, err)
continue
}
// Path should be absolute
if !filepath.IsAbs(resolved) {
t.Errorf("resolveSkillTargetPath(%s) returned non-absolute path: %s", target, resolved)
}
}
}
func TestCleanupTempFile(t *testing.T) {
// Create a temporary file
tempFile, err := os.CreateTemp("", "test-cleanup-*.txt")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
tempPath := tempFile.Name()
tempFile.Close()
// Verify file exists
if _, err := os.Stat(tempPath); os.IsNotExist(err) {
t.Fatalf("temp file should exist before cleanup")
}
// Clean up
cleanupTempFile(tempPath)
// Verify file is deleted
if _, err := os.Stat(tempPath); !os.IsNotExist(err) {
t.Errorf("temp file should be deleted after cleanup")
}
// Cleanup should not panic on empty path
cleanupTempFile("")
// Cleanup should not panic on non-existent file
cleanupTempFile("/nonexistent/path/file.txt")
}
func TestDownloadSkillResponseJSON(t *testing.T) {
// Test JSON marshaling/unmarshaling round-trip
original := downloadSkillResponse{
Success: true,
Result: &downloadSkillResult{
DownloadURL: "https://example.com/skill.zip",
FileName: "skill.zip",
},
}
data, err := json.Marshal(original)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var parsed downloadSkillResponse
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if parsed.Success != original.Success {
t.Errorf("Success mismatch: got %v, want %v", parsed.Success, original.Success)
}
if parsed.Result.DownloadURL != original.Result.DownloadURL {
t.Errorf("DownloadURL mismatch: got %v, want %v", parsed.Result.DownloadURL, original.Result.DownloadURL)
}
if parsed.Result.FileName != original.Result.FileName {
t.Errorf("FileName mismatch: got %v, want %v", parsed.Result.FileName, original.Result.FileName)
}
}
func TestSkillCommandHelp(t *testing.T) {
cmd := NewRootCommand()
cmd.SetArgs([]string{"skill", "--help"})
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
output := out.String()
// Check for the Long description which is shown in help
if !strings.Contains(output, "技能") {
t.Errorf("help should mention '技能', got: %s", output)
}
if !strings.Contains(output, "add") {
t.Errorf("help should mention 'add' subcommand, got: %s", output)
}
}
func TestSkillAddCommandHelp(t *testing.T) {
cmd := NewRootCommand()
cmd.SetArgs([]string{"skill", "add", "--help"})
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
output := out.String()
// Should mention supported targets
expectedTargets := []string{"qoder", "claude", "cursor", "codex", "opencode"}
for _, target := range expectedTargets {
if !strings.Contains(output, target) {
t.Errorf("help should mention target '%s', got: %s", target, output)
}
}
}
func TestDownloadSkillFileSuccess(t *testing.T) {
// Create a mock server that returns a zip file
expectedContent := []byte("fake zip content")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/zip")
w.WriteHeader(http.StatusOK)
w.Write(expectedContent)
}))
defer server.Close()
// Download the file
ctx := context.Background()
tempPath, err := downloadSkillFile(ctx, server.URL, "test.zip")
if err != nil {
t.Fatalf("downloadSkillFile() error = %v", err)
}
defer os.Remove(tempPath)
// Verify the downloaded content
content, err := os.ReadFile(tempPath)
if err != nil {
t.Fatalf("failed to read downloaded file: %v", err)
}
if !bytes.Equal(content, expectedContent) {
t.Errorf("downloaded content mismatch: got %v, want %v", content, expectedContent)
}
}
func TestDownloadSkillFileServerError(t *testing.T) {
// Create a mock server that returns 500
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
ctx := context.Background()
_, err := downloadSkillFile(ctx, server.URL, "test.zip")
if err == nil {
t.Error("downloadSkillFile() should fail on server error")
}
}
func TestExtractSkillZipEmptyZip(t *testing.T) {
tempDir := t.TempDir()
zipPath := filepath.Join(tempDir, "empty.zip")
destDir := filepath.Join(tempDir, "extracted")
// Create an empty zip file
zipFile, err := os.Create(zipPath)
if err != nil {
t.Fatalf("failed to create zip file: %v", err)
}
zipWriter := zip.NewWriter(zipFile)
if err := zipWriter.Close(); err != nil {
t.Fatalf("failed to close zip writer: %v", err)
}
if err := zipFile.Close(); err != nil {
t.Fatalf("failed to close zip file: %v", err)
}
// Extract should succeed even for empty zip
if err := extractSkillZip(zipPath, destDir); err != nil {
t.Errorf("extractSkillZip() should not fail for empty zip: %v", err)
}
// Destination directory should be created
if _, err := os.Stat(destDir); os.IsNotExist(err) {
t.Errorf("destination directory should be created")
}
}
func TestExtractSkillZipWithDirectories(t *testing.T) {
tempDir := t.TempDir()
zipPath := filepath.Join(tempDir, "test.zip")
destDir := filepath.Join(tempDir, "extracted")
// Create zip with directory entries
zipFile, err := os.Create(zipPath)
if err != nil {
t.Fatalf("failed to create zip file: %v", err)
}
zipWriter := zip.NewWriter(zipFile)
// Add a directory entry with proper permissions
header := &zip.FileHeader{
Name: "mydir/",
Method: zip.Deflate,
}
header.SetMode(0755 | os.ModeDir)
_, err = zipWriter.CreateHeader(header)
if err != nil {
t.Fatalf("failed to create directory in zip: %v", err)
}
// Add a file in the directory
fileHeader := &zip.FileHeader{
Name: "mydir/file.txt",
Method: zip.Deflate,
}
fileHeader.SetMode(0644)
writer, err := zipWriter.CreateHeader(fileHeader)
if err != nil {
t.Fatalf("failed to create file in zip: %v", err)
}
if _, err := writer.Write([]byte("content")); err != nil {
t.Fatalf("failed to write content: %v", err)
}
if err := zipWriter.Close(); err != nil {
t.Fatalf("failed to close zip writer: %v", err)
}
if err := zipFile.Close(); err != nil {
t.Fatalf("failed to close zip file: %v", err)
}
// Extract
if err := extractSkillZip(zipPath, destDir); err != nil {
t.Fatalf("extractSkillZip() error = %v", err)
}
// Verify directory was created
dirPath := filepath.Join(destDir, "mydir")
info, err := os.Stat(dirPath)
if err != nil {
t.Errorf("directory should exist: %v", err)
} else if !info.IsDir() {
t.Errorf("mydir should be a directory")
}
// Verify file exists
filePath := filepath.Join(destDir, "mydir", "file.txt")
content, err := os.ReadFile(filePath)
if err != nil {
t.Errorf("file should exist: %v", err)
} else if string(content) != "content" {
t.Errorf("file content mismatch: got %s, want 'content'", string(content))
}
}
+1 -1
View File
@@ -13,7 +13,7 @@
package app
var version = "v1.0.2"
var version = "v1.0.6"
// Version returns the current CLI version string, including build metadata
// when injected via ldflags (buildTime, gitCommit).
+223
View File
@@ -0,0 +1,223 @@
// 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
)
// Cached resolved credentials (avoid repeated keychain access).
var (
cachedResolvedID string
cachedResolvedSecret string
cachedResolvedValid bool
cachedResolvedMu 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()
// Invalidate resolved credentials cache so next access re-resolves
cachedResolvedMu.Lock()
cachedResolvedValid = false
cachedResolvedID = ""
cachedResolvedSecret = ""
cachedResolvedMu.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()
// Clear resolved credentials cache
cachedResolvedMu.Lock()
cachedResolvedValid = false
cachedResolvedID = ""
cachedResolvedSecret = ""
cachedResolvedMu.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.
// Results are cached to avoid repeated keychain access.
// Returns empty strings if the config doesn't exist or resolution fails.
func ResolveAppCredentials(configDir string) (clientID, clientSecret string) {
// Fast path: check cache first
cachedResolvedMu.RLock()
if cachedResolvedValid {
id, secret := cachedResolvedID, cachedResolvedSecret
cachedResolvedMu.RUnlock()
return id, secret
}
cachedResolvedMu.RUnlock()
// Slow path: load and cache
cachedResolvedMu.Lock()
defer cachedResolvedMu.Unlock()
// Double-check after acquiring write lock
if cachedResolvedValid {
return cachedResolvedID, cachedResolvedSecret
}
cfg := GetCachedAppConfig(configDir)
if cfg != nil {
cachedResolvedID = cfg.ClientID
if secret, err := ResolveSecret(cfg.ClientSecret); err == nil {
cachedResolvedSecret = secret
}
}
cachedResolvedValid = true
return cachedResolvedID, cachedResolvedSecret
}
+13 -2
View File
@@ -26,6 +26,7 @@ func TestClientID_RuntimeOverride(t *testing.T) {
func TestClientID_EnvFallback(t *testing.T) {
SetClientID("")
t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config
t.Setenv("DWS_CLIENT_ID", "env-id")
if got := ClientID(); got != "env-id" {
t.Fatalf("expected env-id, got %s", got)
@@ -34,9 +35,17 @@ func TestClientID_EnvFallback(t *testing.T) {
func TestClientID_Default(t *testing.T) {
SetClientID("")
t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config
t.Setenv("DWS_CLIENT_ID", "")
if got := ClientID(); got != DefaultClientID {
t.Fatalf("expected default, got %s", got)
// When DefaultClientID is a placeholder (starts with "<"), ClientID() returns empty string
if strings.HasPrefix(DefaultClientID, "<") {
if got := ClientID(); got != "" {
t.Fatalf("expected empty string for placeholder, got %s", got)
}
} else {
if got := ClientID(); got != DefaultClientID {
t.Fatalf("expected default, got %s", got)
}
}
}
@@ -51,6 +60,7 @@ func TestClientSecret_RuntimeOverride(t *testing.T) {
func TestClientSecret_EnvFallback(t *testing.T) {
SetClientSecret("")
t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config
t.Setenv("DWS_CLIENT_SECRET", "env-secret")
if got := ClientSecret(); got != "env-secret" {
t.Fatalf("expected env-secret, got %s", got)
@@ -59,6 +69,7 @@ func TestClientSecret_EnvFallback(t *testing.T) {
func TestClientSecret_Default(t *testing.T) {
SetClientSecret("")
t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config
t.Setenv("DWS_CLIENT_SECRET", "")
if got := ClientSecret(); got != DefaultClientSecret {
t.Fatalf("expected default, got %s", got)
+61
View File
@@ -96,6 +96,23 @@ type serviceResult struct {
}
func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
// Ensure we have a valid client ID (fetch from MCP if not available)
if p.clientID == "" {
if p.logger != nil {
p.logger.Debug("client ID not configured, fetching from MCP server")
}
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
if mcpErr != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
}
p.clientID = mcpClientID
// Mark that clientID is from MCP
SetClientIDFromMCP(mcpClientID)
if p.logger != nil {
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
}
}
const maxAttempts = 3
for attempt := 1; attempt <= maxAttempts; attempt++ {
tokenData, err := p.loginOnce(ctx, attempt)
@@ -149,9 +166,53 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
if err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), err)
}
// Check if CLI auth is enabled for this organization
dfPrintStep(p.output(), 4, i18n.T("检查组织 CLI 授权状态..."), 0)
authStatus, authErr := oauthProvider.CheckCLIAuthEnabled(ctx, tokenData.AccessToken)
if authErr != nil {
if p.logger != nil {
p.logger.Warn("failed to check CLI auth status", "error", authErr)
}
// Continue anyway - fail open for better UX
} else if authStatus.Success && !authStatus.Result.CLIAuthEnabled {
// CLI auth is disabled - show detailed error with admin info
_, _ = fmt.Fprintln(p.output(), "")
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 该组织尚未开启 CLI 数据访问权限")))
_, _ = fmt.Fprintln(p.output(), i18n.T(" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。"))
_, _ = fmt.Fprintln(p.output(), "")
// Try to get super admin list
admins, adminErr := GetSuperAdmins(ctx, tokenData.AccessToken)
if adminErr == nil && admins.Success && len(admins.Result) > 0 {
// Show up to 3 admins
maxAdmins := 3
if len(admins.Result) < maxAdmins {
maxAdmins = len(admins.Result)
}
var adminNames []string
for i := 0; i < maxAdmins; i++ {
adminNames = append(adminNames, admins.Result[i].Name)
}
_, _ = fmt.Fprintf(p.output(), " %s%s\n", i18n.T("组织主管理员:"), strings.Join(adminNames, "、"))
}
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织主管理员开启后重新登录。"))
_, _ = fmt.Fprintln(p.output(), "")
_, _ = fmt.Fprintln(p.output(), i18n.T(" 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings"))
_, _ = fmt.Fprintln(p.output(), "")
return nil, errors.New(i18n.T("该组织尚未开启 CLI 数据访问权限,请联系管理员开启"))
}
// 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
}
+4
View File
@@ -46,6 +46,10 @@ func writeServiceResult(w http.ResponseWriter, success bool, result any, errCode
func TestRequestDeviceCodeSuccess(t *testing.T) {
t.Parallel()
// Set a test client ID
SetClientID("test-client-id")
t.Cleanup(func() { SetClientID("") })
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("method = %s, want POST", r.Method)
+126 -5
View File
@@ -15,6 +15,8 @@ package auth
import (
"os"
"path/filepath"
"strings"
"sync"
)
@@ -58,16 +60,86 @@ const (
LogoutURL = "https://login.dingtalk.com/oauth2/logout"
LogoutContinueURL = "https://login.dingtalk.com"
// MCP API endpoints for CLI authorization management.
DefaultMCPBaseURL = "https://mcp.dingtalk.com"
CLIAuthEnabledPath = "/cli/cliAuthEnabled"
SuperAdminPath = "/cli/superAdmin"
SendCliAuthApplyPath = "/cli/sendCliAuthApply"
ClientIDPath = "/cli/clientId"
// MCP OAuth endpoints (used when clientId is fetched from MCP).
MCPOAuthTokenPath = "/oauth2/getToken"
MCPRefreshTokenPath = "/oauth2/refreshToken"
MCPRevokeTokenPath = "/oauth2/revokeToken"
)
// GetMCPBaseURL returns the MCP base URL with priority:
// 1. ~/.dws/mcp_url file content (for pre-release environment)
// 2. Default value (https://mcp.dingtalk.com)
func GetMCPBaseURL() string {
mcpURLPath := filepath.Join(getDefaultConfigDir(), "mcp_url")
if data, err := os.ReadFile(mcpURLPath); err == nil {
if url := strings.TrimSpace(string(data)); url != "" {
return url
}
}
return DefaultMCPBaseURL
}
// Runtime overrides set via CLI flags (--client-id, --client-secret).
// These take highest priority over environment variables and defaults.
var (
clientMu sync.RWMutex
runtimeClientID string
runtimeClientSecret string
// clientIDFromMCP indicates whether the clientID was fetched from MCP server.
// When true, MCP OAuth endpoints should be used instead of direct DingTalk API.
clientIDFromMCP bool
)
// SetClientIDFromMCP sets the clientID fetched from MCP server and marks it as MCP-sourced.
func SetClientIDFromMCP(id string) {
clientMu.Lock()
defer clientMu.Unlock()
runtimeClientID = id
clientIDFromMCP = true
}
// IsClientIDFromMCP returns true if the current clientID was fetched from MCP server.
func IsClientIDFromMCP() bool {
clientMu.RLock()
defer clientMu.RUnlock()
return clientIDFromMCP
}
// GetUserAccessTokenURL returns the appropriate token exchange URL.
// Uses MCP endpoint when clientID is from MCP, otherwise uses direct DingTalk API.
func GetUserAccessTokenURL() string {
if IsClientIDFromMCP() {
return GetMCPBaseURL() + MCPOAuthTokenPath
}
return UserAccessTokenURL
}
// GetRefreshTokenURL returns the appropriate token refresh URL.
// Uses MCP endpoint when clientID is from MCP, otherwise uses direct DingTalk API.
func GetRefreshTokenURL() string {
if IsClientIDFromMCP() {
return GetMCPBaseURL() + MCPRefreshTokenPath
}
return UserAccessTokenURL // DingTalk uses same endpoint for refresh
}
// GetRevokeTokenURL returns the token revocation URL (MCP only).
// Returns empty string if not using MCP mode.
func GetRevokeTokenURL() string {
if IsClientIDFromMCP() {
return GetMCPBaseURL() + MCPRevokeTokenPath
}
return "" // Direct mode doesn't have revoke endpoint
}
// SetClientID allows runtime override of the client ID (e.g., from CLI flags).
func SetClientID(id string) {
clientMu.Lock()
@@ -84,8 +156,11 @@ 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 (if not a placeholder)
// Returns empty string if no valid client ID is available.
// Note: MCP server fetch (priority 4 in the full flow) is handled in OAuthProvider.Login()
func ClientID() string {
clientMu.RLock()
override := runtimeClientID
@@ -93,16 +168,25 @@ 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
}
return DefaultClientID
// Only return default if it's not a placeholder
if !strings.HasPrefix(DefaultClientID, "<") {
return DefaultClientID
}
return ""
}
// 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 +194,45 @@ 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
}
// HasValidClientSecret returns true if a valid client secret is available.
// A valid secret is one that is not a placeholder (e.g., <YOUR_CLIENT_SECRET>).
func HasValidClientSecret() bool {
secret := ClientSecret()
return secret != "" && !strings.HasPrefix(secret, "<")
}
// 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")
}
+429
View File
@@ -27,6 +27,11 @@ import (
)
func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenData, error) {
// Use MCP mode if clientID is from MCP server
if IsClientIDFromMCP() {
return p.exchangeCodeViaMCP(ctx, code)
}
// Direct mode with client secret
body := map[string]string{
"clientId": ClientID(),
"clientSecret": ClientSecret(),
@@ -40,7 +45,28 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa
return p.parseTokenResponse(resp)
}
// exchangeCodeViaMCP exchanges auth code for token via MCP proxy.
// This is used when client secret is not available (server-side secret management).
func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*TokenData, error) {
url := GetMCPBaseURL() + MCPOAuthTokenPath
body := map[string]string{
"clientId": ClientID(),
"authCode": code,
"grantType": "authorization_code",
}
resp, err := p.postJSON(ctx, url, body)
if err != nil {
return nil, err
}
return p.parseMCPTokenResponse(resp)
}
func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *TokenData) (*TokenData, error) {
// Use MCP mode if clientID is from MCP server
if IsClientIDFromMCP() {
return p.refreshViaMCP(ctx, data)
}
// Direct mode with client secret
body := map[string]string{
"clientId": ClientID(),
"clientSecret": ClientSecret(),
@@ -67,6 +93,34 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
return updated, nil
}
// refreshViaMCP refreshes token via MCP proxy.
func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*TokenData, error) {
url := GetMCPBaseURL() + MCPRefreshTokenPath
body := map[string]string{
"clientId": ClientID(),
"refreshToken": data.RefreshToken,
"grantType": "refresh_token",
}
resp, err := p.postJSON(ctx, url, body)
if err != nil {
return nil, err
}
updated, err := p.parseMCPTokenResponse(resp)
if err != nil {
return nil, err
}
updated.PersistentCode = data.PersistentCode
updated.CorpID = data.CorpID
updated.UserID = data.UserID
updated.UserName = data.UserName
updated.CorpName = data.CorpName
if err := SaveTokenData(p.configDir, updated); err != nil {
return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err)
}
return updated, nil
}
func (p *OAuthProvider) postJSON(ctx context.Context, endpoint string, body any) ([]byte, error) {
b, err := json.Marshal(body)
if err != nil {
@@ -132,6 +186,48 @@ func (p *OAuthProvider) parseTokenResponse(body []byte) (*TokenData, error) {
return data, nil
}
// parseMCPTokenResponse parses token response from MCP proxy.
// MCP OAuth response format: {"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "corpId": "..."}
func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
var resp struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
PersistentCode string `json:"persistentCode"`
ExpiresIn int64 `json:"expiresIn"`
CorpID string `json:"corpId"`
// Error fields (when request fails)
ErrorCode string `json:"errorCode,omitempty"`
ErrorMsg string `json:"errorMsg,omitempty"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("parsing MCP token response: %w (body: %s)", err, string(body))
}
// Check for error response
if resp.ErrorCode != "" || resp.ErrorMsg != "" {
return nil, fmt.Errorf("MCP token exchange failed: %s - %s", resp.ErrorCode, resp.ErrorMsg)
}
if resp.AccessToken == "" {
return nil, fmt.Errorf("MCP token response missing accessToken (body: %s)", string(body))
}
now := time.Now()
expiresIn := resp.ExpiresIn
if expiresIn <= 0 {
expiresIn = config.DefaultAccessTokenExpiry
}
data := &TokenData{
AccessToken: resp.AccessToken,
RefreshToken: resp.RefreshToken,
ExpiresAt: now.Add(time.Duration(expiresIn) * time.Second),
RefreshExpAt: now.Add(config.DefaultRefreshTokenLifetime),
CorpID: resp.CorpID,
}
if resp.PersistentCode != "" {
data.PersistentCode = resp.PersistentCode
}
return data, nil
}
func buildAuthURL(clientID, redirectURI string) string {
params := url.Values{
"client_id": {clientID},
@@ -149,3 +245,336 @@ const successHTML = `<!DOCTYPE html>
.card{background:#fff;border-radius:12px;padding:40px;text-align:center;box-shadow:0 2px 12px rgba(0,0,0,.08)}
h1{color:#1677ff;margin:0 0 8px}p{color:#666;margin:0}</style></head>
<body><div class="card"><h1>✅ 授权成功</h1><p>请返回终端继续操作。此页面可以关闭。</p></div></body></html>`
const notEnabledHTML = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>钉钉 CLI</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
display:flex;justify-content:center;align-items:center;min-height:100vh;background:#fff;padding:20px}
.container{text-align:center;max-width:400px;width:100%}
.lock-icon{width:80px;height:100px;margin:0 auto 32px;position:relative}
.lock-body{width:60px;height:50px;background:linear-gradient(135deg,#1890ff 0%,#69c0ff 100%);
border-radius:8px;position:absolute;bottom:0;left:50%;transform:translateX(-50%);
box-shadow:0 8px 24px rgba(24,144,255,0.3)}
.lock-body::before{content:"";position:absolute;width:12px;height:12px;background:#fff;
border-radius:50%;top:50%;left:50%;transform:translate(-50%,-50%)}
.lock-body::after{content:"";position:absolute;width:4px;height:12px;background:#fff;
top:50%;left:50%;transform:translateX(-50%);margin-top:4px;border-radius:2px}
.lock-shackle{width:36px;height:36px;border:6px solid #1890ff;border-bottom:none;
border-radius:20px 20px 0 0;position:absolute;top:8px;left:50%;transform:translateX(-50%)}
h1{font-size:22px;font-weight:600;color:#181c1f;margin-bottom:16px;line-height:1.4}
p{font-size:15px;color:#5f6368;line-height:1.6;margin-bottom:24px}
.form-group{text-align:left;margin-bottom:24px}
.form-label{font-size:14px;color:#181c1f;margin-bottom:8px;display:block}
.select-wrapper{position:relative}
select{width:100%;padding:12px 40px 12px 16px;font-size:15px;border:1px solid #d9d9d9;
border-radius:8px;background:#fff;appearance:none;cursor:pointer;color:#181c1f}
select:focus{outline:none;border-color:#1890ff;box-shadow:0 0 0 2px rgba(24,144,255,0.1)}
.select-arrow{position:absolute;right:16px;top:50%;transform:translateY(-50%);pointer-events:none;
border:5px solid transparent;border-top:6px solid #5f6368}
.btn{display:block;width:100%;padding:14px 24px;background:linear-gradient(135deg,#1890ff 0%,#40a9ff 100%);
color:#fff;font-size:16px;font-weight:500;border-radius:24px;border:none;cursor:pointer;
box-shadow:0 4px 16px rgba(24,144,255,0.3);transition:all 0.2s}
.btn:hover{transform:translateY(-1px);box-shadow:0 6px 20px rgba(24,144,255,0.4)}
.btn:disabled{background:#d9d9d9;box-shadow:none;cursor:not-allowed;transform:none}
.link{color:#1890ff;font-size:14px;text-decoration:none;margin-top:16px;display:inline-block}
.link:hover{text-decoration:underline}
.success-msg{display:none;width:100%;min-height:36px;gap:12px;padding:8px 12px;margin-top:20px;
background:linear-gradient(0deg,rgba(0,102,255,0.12) 0%,rgba(0,102,255,0.12) 100%),linear-gradient(0deg,#FFFFFF 0%,#FFFFFF 100%);
border-radius:12px;align-items:flex-start;justify-content:flex-start}
.success-msg-icon{width:16px;height:16px;flex-shrink:0;margin-top:3px}
.success-msg-text{flex:1;color:#181C1F;font-size:14px;line-height:22px}
.error-msg{color:#ff4d4f;font-size:14px;margin-top:8px;display:none}
.loading{display:inline-block;width:16px;height:16px;border:2px solid #fff;border-top-color:transparent;
border-radius:50%;animation:spin 0.8s linear infinite;margin-right:8px;vertical-align:middle}
@keyframes spin{to{transform:rotate(360deg)}}
</style></head>
<body><div class="container">
<div class="lock-icon"><div class="lock-shackle"></div><div class="lock-body"></div></div>
<h1>该组织尚未开启 CLI 数据访问权限</h1>
<p>你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。</p>
<div class="form-group">
<label class="form-label">你将使用以下账号授权登录</label>
<div class="select-wrapper">
<select id="adminSelect"><option value="">加载中...</option></select>
<div class="select-arrow"></div>
</div>
<div id="errorMsg" class="error-msg"></div>
</div>
<button id="applyBtn" class="btn" disabled>立即申请</button>
<a id="backLink" class="link" href="#">返回选择其他组织</a>
<div id="successMsg" class="success-msg">
<svg class="success-msg-icon" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 1.33333C4.32 1.33333 1.33333 4.32 1.33333 8C1.33333 11.68 4.32 14.6667 8 14.6667C11.68 14.6667 14.6667 11.68 14.6667 8C14.6667 4.32 11.68 1.33333 8 1.33333ZM8 13.3333C5.05333 13.3333 2.66667 10.9467 2.66667 8C2.66667 5.05333 5.05333 2.66667 8 2.66667C10.9467 2.66667 13.3333 5.05333 13.3333 8C13.3333 10.9467 10.9467 13.3333 8 13.3333ZM7.33333 9.33333H8.66667V10.6667H7.33333V9.33333ZM7.33333 5.33333H8.66667V8H7.33333V5.33333Z" fill="#0066FF"/>
</svg>
<span class="success-msg-text">已向管理员发送权限申请,正在等待审核<br/>审核通过后,请返回终端继续操作</span>
</div>
</div>
<script>
const select=document.getElementById('adminSelect'),btn=document.getElementById('applyBtn'),
successMsg=document.getElementById('successMsg'),errorMsg=document.getElementById('errorMsg'),
backLink=document.getElementById('backLink');
let admins=[],clientId='',applySent=false,selectedAdminId='',pollTimer=null;
// Load status first, then load admins
async function init(){
try{
const statusRes=await fetch('/api/status');
const status=await statusRes.json();
clientId=status.clientId||'';
applySent=status.applySent||false;
selectedAdminId=status.selectedAdminId||'';
// Set back link URL
if(clientId){
const port=location.port;
const redirectUri=encodeURIComponent('http://127.0.0.1:'+port+'/callback');
backLink.href='https://login.dingtalk.com/oauth2/auth?client_id='+clientId+
'&prompt=consent&redirect_uri='+redirectUri+'&response_type=code&scope=openid+corpid';
}
// Restore state if apply was sent
if(applySent){
setAppliedState();
}
}catch(e){console.error('Failed to load status',e);}
loadAdmins();
}
function setAppliedState(){
btn.style.display='none';
select.disabled=true;
successMsg.style.display='flex';
backLink.style.pointerEvents='none';
backLink.style.color='#999';
backLink.onclick=function(e){e.preventDefault();return false;};
// Start polling for auth status
startPolling();
}
function startPolling(){
if(pollTimer)return;
pollTimer=setInterval(checkAuthStatus,5000);
// Also check immediately
checkAuthStatus();
}
async function checkAuthStatus(){
try{
const res=await fetch('/api/cliAuthEnabled');
const data=await res.json();
if(data.success&&data.result&&data.result.cliAuthEnabled){
// Auth enabled! Redirect to success page
stopPolling();
location.href='/success';
}
}catch(e){console.error('Poll error',e);
}
}
function stopPolling(){
if(pollTimer){clearInterval(pollTimer);pollTimer=null;}
}
async function loadAdmins(){
try{
const res=await fetch('/api/superAdmin');
const data=await res.json();
if(data.success&&data.result&&data.result.length>0){
admins=data.result;
select.innerHTML='<option value="">请选择</option>'+
admins.map(a=>'<option value="'+a.staffId+'">'+a.name+'</option>').join('');
// Restore selected admin if available
if(selectedAdminId){
select.value=selectedAdminId;
}
btn.disabled=!select.value;
}else{
select.innerHTML='<option value="">暂无可选管理员</option>';
showError(data.errorMsg||'获取管理员列表失败');
}
}catch(e){
select.innerHTML='<option value="">加载失败</option>';
showError('网络错误,请重试');
}
}
function showError(msg){errorMsg.textContent=msg;errorMsg.style.display='block';}
function hideError(){errorMsg.style.display='none';}
select.onchange=function(){btn.disabled=!this.value;hideError();};
btn.onclick=async function(){
if(!select.value)return;
btn.disabled=true;
btn.innerHTML='<span class="loading"></span>申请中...';
hideError();
try{
const res=await fetch('/api/sendApply?adminStaffId='+encodeURIComponent(select.value));
const data=await res.json();
if(data.success&&data.result){
setAppliedState();
}else{
showError(data.errorMsg||'申请失败,请重试');
btn.disabled=false;
btn.textContent='立即申请';
}
}catch(e){
showError('网络错误,请重试');
btn.disabled=false;
btn.textContent='立即申请';
}
};
init();
</script></body></html>`
// CLIAuthStatus represents the response from /cli/cliAuthEnabled API.
type CLIAuthStatus struct {
Success bool `json:"success"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMsg string `json:"errorMsg,omitempty"`
Result struct {
CLIAuthEnabled bool `json:"cliAuthEnabled"`
} `json:"result"`
}
// SuperAdmin represents a corp super admin.
type SuperAdmin struct {
StaffID string `json:"staffId"`
Name string `json:"name"`
}
// SuperAdminResponse represents the response from /cli/superAdmin API.
type SuperAdminResponse struct {
Success bool `json:"success"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMsg string `json:"errorMsg,omitempty"`
Result []SuperAdmin `json:"result"`
}
// SendApplyResponse represents the response from /cli/sendCliAuthApply API.
type SendApplyResponse struct {
Success bool `json:"success"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMsg string `json:"errorMsg,omitempty"`
Result bool `json:"result"`
}
// CheckCLIAuthEnabled checks if CLI authorization is enabled for the current corp.
func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) {
url := GetMCPBaseURL() + CLIAuthEnabledPath
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("x-user-access-token", accessToken)
client := p.httpClient
if client == nil {
client = oauthHTTPClient
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("sending request: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
if err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}
var status CLIAuthStatus
if err := json.Unmarshal(data, &status); err != nil {
return nil, fmt.Errorf("parsing response: %w", err)
}
return &status, nil
}
// GetSuperAdmins fetches the list of corp super admins.
func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) {
url := GetMCPBaseURL() + SuperAdminPath
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("x-user-access-token", accessToken)
resp, err := oauthHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("sending request: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
if err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}
var result SuperAdminResponse
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("parsing response: %w", err)
}
return &result, nil
}
// SendCliAuthApply sends a CLI auth apply request to the specified admin.
func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) {
url := GetMCPBaseURL() + SendCliAuthApplyPath + "?adminStaffId=" + adminStaffID
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("x-user-access-token", accessToken)
resp, err := oauthHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("sending request: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
if err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}
var result SendApplyResponse
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("parsing response: %w", err)
}
return &result, nil
}
// ClientIDResponse represents the response from /cli/clientId API.
type ClientIDResponse struct {
Success bool `json:"success"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMsg string `json:"errorMsg,omitempty"`
Result string `json:"result"`
}
// FetchClientIDFromMCP fetches the CLI client ID from MCP server.
// This is used when no client ID is provided via flags, config, or env vars.
func FetchClientIDFromMCP(ctx context.Context) (string, error) {
url := GetMCPBaseURL() + ClientIDPath
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
resp, err := oauthHTTPClient.Do(req)
if err != nil {
return "", fmt.Errorf("sending request: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
if err != nil {
return "", fmt.Errorf("reading response: %w", err)
}
var result ClientIDResponse
if err := json.Unmarshal(data, &result); err != nil {
return "", fmt.Errorf("parsing response: %w", err)
}
if !result.Success {
return "", fmt.Errorf("%s: %s", result.ErrorCode, result.ErrorMsg)
}
return result.Result, nil
}
+296 -12
View File
@@ -15,6 +15,7 @@ package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -22,6 +23,7 @@ import (
"net"
"net/http"
"os"
"sync"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
@@ -92,6 +94,23 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
}
// Fall through: full browser OAuth flow.
// Ensure we have a valid client ID (fetch from MCP if not available)
if p.clientID == "" {
if p.logger != nil {
p.logger.Debug("client ID not configured, fetching from MCP server")
}
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
if mcpErr != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
}
p.clientID = mcpClientID
// Mark that clientID is from MCP, so we use MCP OAuth endpoints
SetClientIDFromMCP(mcpClientID)
if p.logger != nil {
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
}
}
// Find a free port for the callback server.
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
@@ -100,15 +119,53 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
port := listener.Addr().(*net.TCPAddr).Port
redirectURI := fmt.Sprintf("http://127.0.0.1:%d%s", port, CallbackPath)
codeCh := make(chan string, 1)
// Channel to pass callback result (token data or error with CLI auth status)
type callbackResult struct {
token *TokenData
err error
cliAuthDisabled bool
}
resultCh := make(chan callbackResult, 1)
errCh := make(chan error, 1)
// Shared state for API handlers (protected by mutex)
var (
callbackToken *TokenData
callbackProcessed bool
callbackAuthDisabled bool
callbackApplySent bool // Whether apply request was sent
callbackSelectedAdminId string // Selected admin ID for apply
callbackTokenMu sync.Mutex
)
mux := http.NewServeMux()
mux.HandleFunc(CallbackPath, func(w http.ResponseWriter, r *http.Request) {
// Get code first to check if this is a new authorization or page refresh
code := r.URL.Query().Get("authCode")
if code == "" {
code = r.URL.Query().Get("code")
}
// Check if this is a page refresh (no code) and callback was already processed
callbackTokenMu.Lock()
if code == "" && callbackProcessed {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if callbackAuthDisabled {
_, _ = fmt.Fprint(w, notEnabledHTML)
} else {
_, _ = fmt.Fprint(w, successHTML)
}
callbackTokenMu.Unlock()
return
}
// Reset state for new authorization (user switched org)
if code != "" && callbackProcessed {
callbackProcessed = false
callbackApplySent = false
callbackSelectedAdminId = ""
}
callbackTokenMu.Unlock()
if code == "" {
select {
case errCh <- errors.New(i18n.T("回调中未收到授权码")):
@@ -118,14 +175,145 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
_, _ = fmt.Fprint(w, i18n.T("授权失败:未收到授权码"))
return
}
select {
case codeCh <- code:
// Exchange code for token immediately in callback
tokenData, exchangeErr := p.exchangeCode(ctx, code)
if exchangeErr != nil {
// Check if we already have a processed state (authCode reused on refresh)
callbackTokenMu.Lock()
if callbackProcessed {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if callbackAuthDisabled {
_, _ = fmt.Fprint(w, notEnabledHTML)
} else {
_, _ = fmt.Fprint(w, successHTML)
}
callbackTokenMu.Unlock()
return
}
callbackTokenMu.Unlock()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = fmt.Fprint(w, successHTML)
default:
// Select already exited (timeout/cancel); discard late callback.
w.WriteHeader(http.StatusGone)
_, _ = fmt.Fprintf(w, "<html><body><h1>授权失败</h1><p>%s</p></body></html>", exchangeErr.Error())
select {
case resultCh <- callbackResult{err: exchangeErr}:
default:
}
return
}
// Check CLI auth enabled status
authStatus, statusErr := p.CheckCLIAuthEnabled(ctx, tokenData.AccessToken)
cliAuthDisabled := statusErr == nil && authStatus.Success && !authStatus.Result.CLIAuthEnabled
// Store token and state for API handlers and refresh handling
callbackTokenMu.Lock()
callbackToken = tokenData
callbackProcessed = true
callbackAuthDisabled = cliAuthDisabled
callbackTokenMu.Unlock()
// Display appropriate HTML based on CLI auth status
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if cliAuthDisabled {
_, _ = fmt.Fprint(w, notEnabledHTML)
} else {
_, _ = fmt.Fprint(w, successHTML)
}
// Ensure response is flushed to client
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
// Notify main goroutine with full result
select {
case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: cliAuthDisabled}:
default:
}
})
// API endpoint: get super admins
mux.HandleFunc("/api/superAdmin", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
callbackTokenMu.Lock()
token := callbackToken
callbackTokenMu.Unlock()
if token == nil {
_, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`))
return
}
result, err := GetSuperAdmins(ctx, token.AccessToken)
if err != nil {
_, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error())
return
}
data, _ := json.Marshal(result)
_, _ = w.Write(data)
})
// API endpoint: send CLI auth apply
mux.HandleFunc("/api/sendApply", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
adminStaffID := r.URL.Query().Get("adminStaffId")
if adminStaffID == "" {
_, _ = w.Write([]byte(`{"success":false,"errorMsg":"缺少 adminStaffId 参数"}`))
return
}
callbackTokenMu.Lock()
token := callbackToken
callbackTokenMu.Unlock()
if token == nil {
_, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`))
return
}
result, err := SendCliAuthApply(ctx, token.AccessToken, adminStaffID)
if err != nil {
_, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error())
return
}
// Mark apply as sent and save selected admin on success
if result.Success && result.Result {
callbackTokenMu.Lock()
callbackApplySent = true
callbackSelectedAdminId = adminStaffID
callbackTokenMu.Unlock()
}
data, _ := json.Marshal(result)
_, _ = w.Write(data)
})
// API endpoint: get current status (clientId, applySent, selectedAdminId)
mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
callbackTokenMu.Lock()
applySent := callbackApplySent
selectedAdminId := callbackSelectedAdminId
callbackTokenMu.Unlock()
_, _ = fmt.Fprintf(w, `{"clientId":"%s","applySent":%t,"selectedAdminId":"%s"}`, p.clientID, applySent, selectedAdminId)
})
// API endpoint: check CLI auth enabled status
mux.HandleFunc("/api/cliAuthEnabled", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
callbackTokenMu.Lock()
token := callbackToken
callbackTokenMu.Unlock()
if token == nil {
_, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`))
return
}
result, err := p.CheckCLIAuthEnabled(ctx, token.AccessToken)
if err != nil {
_, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error())
return
}
data, _ := json.Marshal(result)
_, _ = w.Write(data)
})
// Success page endpoint
mux.HandleFunc("/success", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = fmt.Fprint(w, successHTML)
})
server := &http.Server{Handler: mux}
@@ -161,9 +349,9 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
timeout := time.NewTimer(5 * time.Minute)
defer timeout.Stop()
var authCode string
var result callbackResult
select {
case authCode = <-codeCh:
case result = <-resultCh:
case err := <-errCh:
return nil, err
case <-timeout.C:
@@ -172,13 +360,82 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
return nil, ctx.Err()
}
tokenData, err := p.exchangeCode(ctx, authCode)
if err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), err)
// Handle callback errors
if result.err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), result.err)
}
// Handle CLI auth disabled - keep server running for user to apply
if result.cliAuthDisabled {
_, _ = fmt.Fprintln(p.output(), "")
_, _ = fmt.Fprintln(p.output(), i18n.T("⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请..."))
// Poll for CLI auth status while waiting
applyTimeout := time.NewTimer(10 * time.Minute)
defer applyTimeout.Stop()
pollTicker := time.NewTicker(5 * time.Second)
defer pollTicker.Stop()
elapsedSeconds := 0
for {
select {
case <-applyTimeout.C:
return nil, errors.New(i18n.T("操作超时,请重新登录"))
case <-ctx.Done():
return nil, ctx.Err()
case <-pollTicker.C:
elapsedSeconds += 5
// Get latest token and state (user may have switched org)
callbackTokenMu.Lock()
currentToken := callbackToken
currentAuthDisabled := callbackAuthDisabled
applySent := callbackApplySent
callbackTokenMu.Unlock()
// Check if user switched to an org with CLI auth enabled
if currentToken != nil && !currentAuthDisabled {
_, _ = fmt.Fprintf(p.output(), "\r%s\n", i18n.T("✅ 权限已开启,继续登录..."))
time.Sleep(2 * time.Second)
result.token = currentToken
result.cliAuthDisabled = false
goto continueLogin
}
// Check if CLI auth is now enabled (admin approved)
if currentToken != nil {
authStatus, err := p.CheckCLIAuthEnabled(ctx, currentToken.AccessToken)
if err == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled {
_, _ = fmt.Fprintf(p.output(), "\r%s\n", i18n.T("✅ 权限已开启,继续登录..."))
time.Sleep(2 * time.Second)
result.token = currentToken
result.cliAuthDisabled = false
goto continueLogin
}
}
// Show polling status based on apply state
if applySent {
_, _ = fmt.Fprintf(p.output(), "\r⏳ %s (%ds/600s) ", i18n.T("等待管理员审批中"), elapsedSeconds)
} else {
_, _ = fmt.Fprintf(p.output(), "\r⏳ %s (%ds/600s) ", i18n.T("等待提交申请中"), elapsedSeconds)
}
}
}
}
continueLogin:
tokenData := result.token
// 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 +547,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)
}
}
+52 -1
View File
@@ -14,7 +14,9 @@
package auth
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
@@ -32,6 +34,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"`
}
@@ -102,10 +105,16 @@ func DeleteTokenData(configDir string) error {
return legacyErr
}
// RevokeTokenRemote calls the DingTalk logout endpoint to invalidate the access token.
// RevokeTokenRemote calls the appropriate logout/revoke endpoint to invalidate the access token.
// Uses MCP revoke endpoint when clientID is from MCP, otherwise uses DingTalk logout.
// This should be called before deleting local token data.
// The function is best-effort: errors are returned but callers may choose to ignore them.
func RevokeTokenRemote(ctx context.Context) error {
// Use MCP revoke endpoint when clientID is from MCP
if IsClientIDFromMCP() {
return revokeTokenViaMCP(ctx)
}
// Direct mode: use DingTalk logout endpoint
logoutURL, err := url.Parse(LogoutURL)
if err != nil {
return fmt.Errorf("parsing logout URL: %w", err)
@@ -142,3 +151,45 @@ func RevokeTokenRemote(ctx context.Context) error {
return nil
}
// revokeTokenViaMCP revokes token via MCP endpoint.
func revokeTokenViaMCP(ctx context.Context) error {
revokeURL := GetRevokeTokenURL()
if revokeURL == "" {
return nil // No revoke endpoint available
}
// Load current token to get accessToken
tokenData, err := LoadTokenData(getDefaultConfigDir())
if err != nil || tokenData == nil {
return nil // No token to revoke
}
body := map[string]string{
"clientId": ClientID(),
"accessToken": tokenData.AccessToken,
}
bodyBytes, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshaling revoke request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, revokeURL, bytes.NewReader(bodyBytes))
if err != nil {
return fmt.Errorf("creating revoke request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("calling revoke endpoint: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("revoke endpoint returned status %d", resp.StatusCode)
}
return nil
}
+16 -1
View File
@@ -14,8 +14,10 @@
package compat
import (
"bufio"
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
@@ -143,7 +145,20 @@ func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
}
}
if blocked, _ := params["_blocked"].(bool); blocked {
return nil
// Interactive confirmation for destructive operations (consistent with Helper commands)
fmt.Fprintln(cmd.ErrOrStderr(), "⚠️ This is a destructive operation.")
fmt.Fprint(cmd.ErrOrStderr(), "Confirm? (yes/no): ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "yes" && answer != "y" {
fmt.Fprintln(cmd.ErrOrStderr(), "Operation cancelled")
return nil
}
// User confirmed, continue execution
delete(params, "_blocked")
}
invocation := executor.NewCompatibilityInvocation(
+59
View File
@@ -0,0 +1,59 @@
// 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 errors
import "strings"
// ServerDiagnostics holds server-side diagnostic fields extracted from
// MCP response bodies or HTTP response headers. Fields are populated
// on a best-effort basis during error construction.
type ServerDiagnostics struct {
TraceID string `json:"trace_id,omitempty"`
ServerErrorCode string `json:"server_error_code,omitempty"`
TechnicalDetail string `json:"technical_detail,omitempty"`
ServerRetryable *bool `json:"server_retryable,omitempty"`
}
// IsEmpty returns true when no diagnostic field has been populated.
func (d ServerDiagnostics) IsEmpty() bool {
return d.TraceID == "" && d.ServerErrorCode == "" &&
d.TechnicalDetail == "" && d.ServerRetryable == nil
}
// WithServerDiag attaches server diagnostics to the error.
func WithServerDiag(diag ServerDiagnostics) Option {
if diag.IsEmpty() {
return func(*Error) {}
}
return func(e *Error) {
e.ServerDiag = diag
// Override retryable if server explicitly specified.
if diag.ServerRetryable != nil {
e.Retryable = *diag.ServerRetryable
}
}
}
// WithTraceID records the server-provided trace identifier.
// Used when only the trace ID is available (e.g. from HTTP headers)
// without a full ServerDiagnostics struct.
func WithTraceID(id string) Option {
id = strings.TrimSpace(id)
if id == "" {
return func(*Error) {}
}
return func(e *Error) {
e.ServerDiag.TraceID = id
}
}
+100 -36
View File
@@ -14,13 +14,12 @@
package errors
import (
"bytes"
"encoding/json"
stderrors "errors"
"fmt"
"io"
"strings"
"bytes"
)
// Category represents a stable error class with a documented exit code.
@@ -36,18 +35,19 @@ const (
// Error is the structured repository-local error model for the Go rewrite.
type Error struct {
Category Category
Message string
Operation string
ServerKey string
Retryable bool
Reason string
Hint string
Actions []string
Snapshot string
RPCCode int `json:"rpc_code,omitempty"`
RPCData json.RawMessage `json:"rpc_data,omitempty"`
Cause error `json:"-"`
Category Category
Message string
Operation string
ServerKey string
Retryable bool
Reason string
Hint string
Actions []string
Snapshot string
RPCCode int `json:"rpc_code,omitempty"`
RPCData json.RawMessage `json:"rpc_data,omitempty"`
ServerDiag ServerDiagnostics `json:"-"`
Cause error `json:"-"`
}
func (e *Error) Error() string {
@@ -247,6 +247,23 @@ func PrintJSON(w io.Writer, err error) error {
errorPayload["rpc_data"] = parsed
}
}
if !typed.ServerDiag.IsEmpty() {
if typed.ServerDiag.TraceID != "" {
errorPayload["trace_id"] = typed.ServerDiag.TraceID
}
if typed.ServerDiag.ServerErrorCode != "" {
errorPayload["server_error_code"] = typed.ServerDiag.ServerErrorCode
// Add user-friendly hint for specific server error codes
switch typed.ServerDiag.ServerErrorCode {
case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED":
errorPayload["friendly_hint"] = "该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。"
errorPayload["action_url"] = "https://open-dev.dingtalk.com/fe/old#/developerSettings"
}
}
if typed.ServerDiag.TechnicalDetail != "" {
errorPayload["technical_detail"] = typed.ServerDiag.TechnicalDetail
}
}
if typed.Cause != nil {
errorPayload["cause"] = typed.Cause.Error()
}
@@ -263,8 +280,25 @@ func PrintJSON(w io.Writer, err error) error {
return writeErr
}
// PrintHuman writes a concise human-readable error rendering.
// Verbosity controls how much detail PrintHuman includes.
type Verbosity int
const (
// VerbosityNormal shows essential info: error, hint, actions, trace_id, server_code.
VerbosityNormal Verbosity = 0
// VerbosityVerbose adds technical_detail, snapshot, execution context.
VerbosityVerbose Verbosity = 1
// VerbosityDebug adds all internal diagnostics (category, operation, reason, rpc_code).
VerbosityDebug Verbosity = 2
)
// PrintHuman writes a concise human-readable error rendering at normal verbosity.
func PrintHuman(w io.Writer, err error) error {
return PrintHumanAt(w, err, VerbosityNormal)
}
// PrintHumanAt writes a human-readable error rendering at the given verbosity level.
func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
if err == nil {
return nil
}
@@ -275,21 +309,23 @@ func PrintHuman(w io.Writer, err error) error {
return writeErr
}
// Line 1: Error summary
lines := []string{
fmt.Sprintf("Error: [%s] %s", strings.ToUpper(string(typed.Category)), typed.Message),
}
if typed.Reason != "" {
lines = append(lines, fmt.Sprintf("Reason: %s", typed.Reason))
}
if typed.Operation != "" {
lines = append(lines, fmt.Sprintf("Operation: %s", typed.Operation))
}
if typed.ServerKey != "" {
lines = append(lines, fmt.Sprintf("Server: %s", typed.ServerKey))
}
// Always shown: hint, actions, retryable
if typed.Hint != "" {
lines = append(lines, fmt.Sprintf("Hint: %s", typed.Hint))
}
// Add user-friendly hint for specific server error codes
switch typed.ServerDiag.ServerErrorCode {
case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED":
lines = append(lines, "Hint: 该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。")
lines = append(lines, "Action: 开启地址: https://open-dev.dingtalk.com/fe/old#/developerSettings")
}
if len(typed.Actions) > 0 {
for _, action := range typed.Actions {
if strings.TrimSpace(action) == "" {
@@ -298,22 +334,50 @@ func PrintHuman(w io.Writer, err error) error {
lines = append(lines, fmt.Sprintf("Action: %s", action))
}
}
if typed.Snapshot != "" {
lines = append(lines, fmt.Sprintf("Snapshot: %s", typed.Snapshot))
}
if typed.RPCCode != 0 {
lines = append(lines, fmt.Sprintf("RPC Code: %d", typed.RPCCode))
}
if len(typed.RPCData) > 0 {
lines = append(lines, fmt.Sprintf("RPC Data: %s", string(typed.RPCData)))
}
if typed.Cause != nil {
lines = append(lines, fmt.Sprintf("Cause: %s", typed.Cause.Error()))
}
if typed.Retryable {
lines = append(lines, "Retryable: true")
}
// Always shown when present: Trace ID, Server Code
if typed.ServerDiag.TraceID != "" {
lines = append(lines, fmt.Sprintf("Trace ID: %s", typed.ServerDiag.TraceID))
}
if typed.ServerDiag.ServerErrorCode != "" {
lines = append(lines, fmt.Sprintf("Server Code: %s", typed.ServerDiag.ServerErrorCode))
}
// Verbose+: technical detail, snapshot, reason, server key
if v >= VerbosityVerbose {
if typed.ServerDiag.TechnicalDetail != "" {
lines = append(lines, fmt.Sprintf("Detail: %s", typed.ServerDiag.TechnicalDetail))
}
if typed.Reason != "" {
lines = append(lines, fmt.Sprintf("Reason: %s", typed.Reason))
}
if typed.ServerKey != "" {
lines = append(lines, fmt.Sprintf("Server: %s", typed.ServerKey))
}
if typed.Snapshot != "" {
lines = append(lines, fmt.Sprintf("Snapshot: %s", typed.Snapshot))
}
if typed.Cause != nil {
lines = append(lines, fmt.Sprintf("Cause: %s", typed.Cause.Error()))
}
}
// Debug: all internal diagnostics
if v >= VerbosityDebug {
if typed.Operation != "" {
lines = append(lines, fmt.Sprintf("Operation: %s", typed.Operation))
}
if typed.RPCCode != 0 {
lines = append(lines, fmt.Sprintf("RPC Code: %d", typed.RPCCode))
}
if len(typed.RPCData) > 0 {
lines = append(lines, fmt.Sprintf("RPC Data: %s", string(typed.RPCData)))
}
}
_, writeErr := fmt.Fprintln(w, strings.Join(lines, "\n"))
return writeErr
}
+17 -6
View File
@@ -94,18 +94,29 @@ func TestPrintJSON_AllFields(t *testing.T) {
}
}
func TestPrintHuman_WithCause(t *testing.T) {
func TestPrintHuman_WithCause_Verbose(t *testing.T) {
t.Parallel()
cause := fmt.Errorf("timeout")
e := &Error{Category: CategoryDiscovery, Message: "discovery failed", Cause: cause}
var buf bytes.Buffer
PrintHumanAt(&buf, e, VerbosityVerbose)
if !strings.Contains(buf.String(), "timeout") {
t.Fatalf("expected cause in verbose human output: %s", buf.String())
}
}
func TestPrintHuman_WithCause_NormalHidesCause(t *testing.T) {
t.Parallel()
cause := fmt.Errorf("timeout")
e := &Error{Category: CategoryDiscovery, Message: "discovery failed", Cause: cause}
var buf bytes.Buffer
PrintHuman(&buf, e)
if !strings.Contains(buf.String(), "timeout") {
t.Fatalf("expected cause in human output: %s", buf.String())
if strings.Contains(buf.String(), "Cause:") {
t.Fatalf("normal mode should not show Cause: %s", buf.String())
}
}
func TestPrintHuman_AllFields(t *testing.T) {
func TestPrintHuman_AllFields_Debug(t *testing.T) {
t.Parallel()
e := NewAPI("api error",
WithOperation("initialize"),
@@ -120,11 +131,11 @@ func TestPrintHuman_AllFields(t *testing.T) {
WithCause(fmt.Errorf("network")),
)
var buf bytes.Buffer
PrintHuman(&buf, e)
PrintHumanAt(&buf, e, VerbosityDebug)
out := buf.String()
for _, expected := range []string{"API", "initialize", "connection_refused", "doc", "check network", "run again", "snap", "-32601", "network", "Retryable"} {
if !strings.Contains(out, expected) {
t.Fatalf("missing %q in human output: %s", expected, out)
t.Fatalf("missing %q in debug human output: %s", expected, out)
}
}
}
+74 -8
View File
@@ -81,7 +81,7 @@ func TestPrintHuman(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintHuman(&b, NewValidation(
if err := PrintHumanAt(&b, NewValidation(
"bad flag",
WithReason("missing_required_flag"),
WithOperation("calendar.list"),
@@ -90,7 +90,7 @@ func TestPrintHuman(t *testing.T) {
WithRetryable(true),
WithActions("retry command"),
WithSnapshot("/tmp/dws-recovery/snapshot.json"),
)); err != nil {
), VerbosityVerbose); err != nil {
t.Fatalf("PrintHuman() error = %v", err)
}
@@ -108,13 +108,64 @@ func TestPrintHuman(t *testing.T) {
t.Fatalf("expected action in output, got %q", got)
}
if !strings.Contains(got, "Snapshot: /tmp/dws-recovery/snapshot.json") {
t.Fatalf("expected snapshot in output, got %q", got)
t.Fatalf("expected snapshot in verbose output, got %q", got)
}
if !strings.Contains(got, "Retryable: true") {
t.Fatalf("expected retryable marker in output, got %q", got)
}
}
func TestPrintHuman_NormalMode(t *testing.T) {
t.Parallel()
var b strings.Builder
PrintHuman(&b, NewValidation(
"bad flag",
WithHint("fix it"),
WithRetryable(true),
WithActions("retry"),
WithServerDiag(ServerDiagnostics{TraceID: "trace-abc", ServerErrorCode: "PARAM_ERROR"}),
))
got := b.String()
if !strings.Contains(got, "Error: [VALIDATION] bad flag") {
t.Fatalf("expected header, got %q", got)
}
if !strings.Contains(got, "Trace ID: trace-abc") {
t.Fatalf("expected trace id in normal output, got %q", got)
}
if !strings.Contains(got, "Server Code: PARAM_ERROR") {
t.Fatalf("expected server code in normal output, got %q", got)
}
}
func TestPrintJSONIncludesServerDiag(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, NewAPI(
"server error",
WithServerDiag(ServerDiagnostics{
TraceID: "trace-xyz",
ServerErrorCode: "TIMEOUT_ERROR",
TechnicalDetail: "deadline exceeded",
}),
)); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
if !strings.Contains(got, `"trace_id": "trace-xyz"`) {
t.Fatalf("expected trace_id in output, got %q", got)
}
if !strings.Contains(got, `"server_error_code": "TIMEOUT_ERROR"`) {
t.Fatalf("expected server_error_code in output, got %q", got)
}
if !strings.Contains(got, `"technical_detail": "deadline exceeded"`) {
t.Fatalf("expected technical_detail in output, got %q", got)
}
}
func TestPrintJSONIncludesRPCCodeAndData(t *testing.T) {
t.Parallel()
@@ -137,23 +188,38 @@ func TestPrintJSONIncludesRPCCodeAndData(t *testing.T) {
}
}
func TestPrintHumanIncludesRPCCode(t *testing.T) {
func TestPrintHumanIncludesRPCCode_Debug(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintHuman(&b, NewValidation(
if err := PrintHumanAt(&b, NewValidation(
"invalid params",
WithRPCCode(-32602),
WithRPCData([]byte(`"missing field"`)),
)); err != nil {
), VerbosityDebug); err != nil {
t.Fatalf("PrintHuman() error = %v", err)
}
got := b.String()
if !strings.Contains(got, "RPC Code: -32602") {
t.Fatalf("expected RPC Code in output, got %q", got)
t.Fatalf("expected RPC Code in debug output, got %q", got)
}
if !strings.Contains(got, "RPC Data:") {
t.Fatalf("expected RPC Data in output, got %q", got)
t.Fatalf("expected RPC Data in debug output, got %q", got)
}
}
func TestPrintHumanHidesRPCCode_Normal(t *testing.T) {
t.Parallel()
var b strings.Builder
PrintHuman(&b, NewValidation(
"invalid params",
WithRPCCode(-32602),
))
got := b.String()
if strings.Contains(got, "RPC Code:") {
t.Fatalf("normal mode should not show RPC Code, got %q", got)
}
}
+515
View File
@@ -0,0 +1,515 @@
// 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 helpers
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/spf13/cobra"
)
func init() {
RegisterPublic(func() Handler {
return reportHandler{}
})
}
type reportHandler struct{}
func (reportHandler) Name() string {
return "report"
}
func (reportHandler) Command(runner executor.Runner) *cobra.Command {
root := &cobra.Command{
Use: "report",
Aliases: []string{"log"},
Short: "日志 / 模版 / 统计",
Long: `钉钉日志:模版、创建、详情、列表、统计。
子命令:
template 日志模版(list / detail)
create 创建日志
detail 获取日志详情
list 查询收到的日志列表
stats 获取日志统计数据
sent 查询已发送的日志列表`,
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
template := &cobra.Command{
Use: "template",
Short: "日志模版",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
template.AddCommand(
newReportTemplateListCommand(runner),
newReportTemplateDetailCommand(runner),
)
root.AddCommand(
template,
newReportCreateCommand(runner),
newReportDetailCommand(runner),
newReportListCommand(runner),
newReportStatsCommand(runner),
newReportSentCommand(runner),
)
return root
}
// ── flexTimeLayouts: supported date formats, most specific first ──
var flexTimeLayouts = []string{
time.RFC3339, // 2006-01-02T15:04:05+08:00
"2006-01-02T15:04:05Z", // UTC Z suffix
"2006-01-02T15:04:05-07:00", // with offset but no colon
"2006-01-02T15:04:05", // no timezone
"2006-01-02 15:04:05", // space-separated
"2006-01-02T15:04", // no seconds
"2006-01-02 15:04", // no seconds, space
"2006-01-02", // date only
"2006/01/02 15:04:05", // slash + time
"2006/01/02", // slash date
"20060102", // compact YYYYMMDD
}
// parseFlexTimeToMillis parses a date string using multiple formats and returns Unix milliseconds.
// Supports 11 formats for maximum compatibility with user input.
func parseFlexTimeToMillis(flagName, value string) (int64, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, apperrors.NewValidation(fmt.Sprintf(
"--%s is required\n hint: example: 2026-03-10T14:00:00+08:00", flagName))
}
loc, _ := time.LoadLocation("Asia/Shanghai")
if loc == nil {
loc = time.Local
}
for _, layout := range flexTimeLayouts {
t, err := time.ParseInLocation(layout, value, loc)
if err == nil {
return t.UnixMilli(), nil
}
}
return 0, apperrors.NewValidation(fmt.Sprintf(
"cannot parse time for --%s (input: %q)\n hint: supported formats: 2026-03-23T14:00:00+08:00, 2026-03-23 14:00:00, 2026-03-23",
flagName, value))
}
// validateTimeRange checks that endMs is strictly after startMs.
func validateTimeRange(startMs, endMs int64) error {
if endMs <= startMs {
return apperrors.NewValidation("--end must be after --start\n hint: swap the values or adjust the time range")
}
return nil
}
// ── template list ──────────────────────────────────────────
func newReportTemplateListCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "获取当前用户可用的日志模版列表",
Example: " dws report template list",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
params := map[string]any{}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "get_available_report_templates", params,
))
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "get_available_report_templates", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
return cmd
}
// ── template detail ────────────────────────────────────────
func newReportTemplateDetailCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "detail",
Short: "获取日志模版详情",
Example: " dws report template detail --name <templateName>",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
name, _ := cmd.Flags().GetString("name")
if name == "" {
return apperrors.NewValidation("--name is required")
}
params := map[string]any{
"report_template_name": name,
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "get_template_details_by_name", params,
))
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "get_template_details_by_name", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
cmd.Flags().String("name", "", "模版名称 (必填)")
preferLegacyLeaf(cmd)
return cmd
}
// ── create ─────────────────────────────────────────────────
func newReportCreateCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "create",
Short: "创建日志",
Long: `按模版创建一条日志。--contents 为 JSON 数组,每项需含 key、sort、content、contentType、type,
与远程 create_report 一致;可先通过 report template list / template detail 取得 templateId 与控件定义。`,
Example: ` dws report create --template-id TPL_ID --contents '[{"content":"完成开发","sort":"0","key":"今日完成","contentType":"markdown","type":"1"}]'
dws report create --template-id TPL_ID --contents '[...]' --to-chat --to-user-ids userId1,userId2`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
tplID, _ := cmd.Flags().GetString("template-id")
if tplID == "" {
return apperrors.NewValidation("--template-id is required")
}
contentsJSON, _ := cmd.Flags().GetString("contents")
if contentsJSON == "" {
return apperrors.NewValidation("--contents is required")
}
var contents []map[string]any
if err := json.Unmarshal([]byte(contentsJSON), &contents); err != nil {
return apperrors.NewValidation(fmt.Sprintf("--contents JSON parse failed: %v", err))
}
ddFrom, _ := cmd.Flags().GetString("dd-from")
if ddFrom == "" {
ddFrom = "dws"
}
toChat, _ := cmd.Flags().GetBool("to-chat")
params := map[string]any{
"templateId": tplID,
"contents": contents,
"ddFrom": ddFrom,
"toChat": toChat,
}
if v, _ := cmd.Flags().GetString("to-user-ids"); v != "" {
params["toUserIds"] = parseUserIDs(v)
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "create_report", params,
))
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "create_report", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
cmd.Flags().String("template-id", "", "日志模版 ID (必填)")
cmd.Flags().String("contents", "", "日志内容 JSON 数组 (必填),每项含 key/sort/content/contentType/type")
cmd.Flags().String("dd-from", "dws", "创建来源标识")
cmd.Flags().Bool("to-chat", false, "是否发送到日志接收人单聊")
cmd.Flags().String("to-user-ids", "", "接收人 userId,逗号分隔 (可选)")
preferLegacyLeaf(cmd)
return cmd
}
// ── detail ─────────────────────────────────────────────────
func newReportDetailCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "detail",
Short: "获取日志详情",
Example: " dws report detail --report-id <reportId>",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
reportID, _ := cmd.Flags().GetString("report-id")
if reportID == "" {
return apperrors.NewValidation("--report-id is required")
}
params := map[string]any{
"report_id": reportID,
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "get_report_entry_details", params,
))
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "get_report_entry_details", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
cmd.Flags().String("report-id", "", "日志 ID (必填)")
preferLegacyLeaf(cmd)
return cmd
}
// ── list (received reports) ────────────────────────────────
// Key fix: cursor defaults to 0, size defaults to 20, flexible date parsing
func newReportListCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "查询当前人收到的日志列表",
Example: ` dws report list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00"
dws report list --start "2026-03-10 00:00:00" --end "2026-03-10 23:59:59" --cursor 0 --size 20`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
startStr, _ := cmd.Flags().GetString("start")
endStr, _ := cmd.Flags().GetString("end")
startMs, err := parseFlexTimeToMillis("start", startStr)
if err != nil {
return err
}
endMs, err := parseFlexTimeToMillis("end", endStr)
if err != nil {
return err
}
if err := validateTimeRange(startMs, endMs); err != nil {
return err
}
// cursor defaults to 0, size defaults to 20
cursor, _ := cmd.Flags().GetInt("cursor")
size, _ := cmd.Flags().GetInt("size")
if v, _ := cmd.Flags().GetInt("limit"); v > 0 && !cmd.Flags().Changed("size") {
size = v
}
params := map[string]any{
"startTime": float64(startMs),
"endTime": float64(endMs),
"cursor": float64(cursor),
"size": float64(size),
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "get_received_report_list", params,
))
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "get_received_report_list", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
cmd.Flags().String("start", "", "开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00) (必填)")
cmd.Flags().String("end", "", "结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00) (必填)")
cmd.Flags().Int("cursor", 0, "分页游标,首次传 0 (默认 0)")
cmd.Flags().Int("size", 20, "每页条数,最大 20 (默认 20)")
cmd.Flags().Int("limit", 0, "--size 的别名")
_ = cmd.Flags().MarkHidden("limit")
preferLegacyLeaf(cmd)
return cmd
}
// ── stats ──────────────────────────────────────────────────
func newReportStatsCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "stats",
Short: "获取日志统计数据",
Example: " dws report stats --report-id <reportId>",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
reportID, _ := cmd.Flags().GetString("report-id")
if reportID == "" {
return apperrors.NewValidation("--report-id is required")
}
params := map[string]any{
"report_id": reportID,
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "get_report_statistics_by_id", params,
))
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "get_report_statistics_by_id", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
cmd.Flags().String("report-id", "", "日志 ID (必填)")
preferLegacyLeaf(cmd)
return cmd
}
// ── sent (my created reports) ──────────────────────────────
// Key fix: cursor defaults to 0, size defaults to 20, start/end default to last 30 days
func newReportSentCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "sent",
Short: "查询当前人创建的日志列表",
Example: ` dws report sent
dws report sent --cursor 0 --size 20
dws report sent --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00"
dws report sent --template-name "日报"`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
// cursor defaults to 0, size defaults to 20
cursor, _ := cmd.Flags().GetInt("cursor")
size, _ := cmd.Flags().GetInt("size")
if v, _ := cmd.Flags().GetInt("limit"); v > 0 && !cmd.Flags().Changed("size") {
size = v
}
params := map[string]any{
"cursor": float64(cursor),
"size": float64(size),
}
// Default time range: last 30 days
now := time.Now()
startDefault := now.AddDate(0, 0, -30).Truncate(24 * time.Hour).Format(time.RFC3339)
endDefault := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).Format(time.RFC3339)
startStr, _ := cmd.Flags().GetString("start")
if startStr == "" {
startStr = startDefault
}
endStr, _ := cmd.Flags().GetString("end")
if endStr == "" {
endStr = endDefault
}
startMs, err := parseFlexTimeToMillis("start", startStr)
if err != nil {
return err
}
params["startTime"] = float64(startMs)
endMs, err := parseFlexTimeToMillis("end", endStr)
if err != nil {
return err
}
params["endTime"] = float64(endMs)
if err := validateTimeRange(startMs, endMs); err != nil {
return err
}
// Optional modified time filters
if v, _ := cmd.Flags().GetString("modified-start"); v != "" {
ms, err := parseFlexTimeToMillis("modified-start", v)
if err != nil {
return err
}
params["modifiedStartTime"] = float64(ms)
}
if v, _ := cmd.Flags().GetString("modified-end"); v != "" {
ms, err := parseFlexTimeToMillis("modified-end", v)
if err != nil {
return err
}
params["modifiedEndTime"] = float64(ms)
}
// Optional template name filter
if v, _ := cmd.Flags().GetString("template-name"); v != "" {
params["report_template_name"] = v
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "get_send_report_list", params,
))
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "report", "get_send_report_list", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
cmd.Flags().Int("cursor", 0, "分页游标,首次传 0 (默认 0)")
cmd.Flags().Int("size", 20, "每页条数,最大 20 (默认 20)")
cmd.Flags().Int("limit", 0, "--size 的别名")
_ = cmd.Flags().MarkHidden("limit")
cmd.Flags().String("start", "", "创建开始时间 ISO-8601 (默认最近 30 天)")
cmd.Flags().String("end", "", "创建结束时间 ISO-8601 (默认最近 30 天)")
cmd.Flags().String("modified-start", "", "修改开始时间 ISO-8601 (可选)")
cmd.Flags().String("modified-end", "", "修改结束时间 ISO-8601 (可选)")
cmd.Flags().String("template-name", "", "日志模板名称 (可选,不传查全部)")
preferLegacyLeaf(cmd)
return cmd
}
// ── helpers ────────────────────────────────────────────────
func parseUserIDs(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
+149
View File
@@ -0,0 +1,149 @@
// 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 helpers
import (
"testing"
"time"
)
func TestParseFlexTimeToMillis_RFC3339(t *testing.T) {
t.Parallel()
ms, err := parseFlexTimeToMillis("start", "2026-03-10T00:00:00+08:00")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ms <= 0 {
t.Errorf("expected positive milliseconds, got %d", ms)
}
}
func TestParseFlexTimeToMillis_SpaceSeparated(t *testing.T) {
t.Parallel()
// This is the format that was causing the HTTP 400 error
ms, err := parseFlexTimeToMillis("start", "2026-03-01 00:00:00")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ms <= 0 {
t.Errorf("expected positive milliseconds, got %d", ms)
}
}
func TestParseFlexTimeToMillis_DateOnly(t *testing.T) {
t.Parallel()
ms, err := parseFlexTimeToMillis("start", "2026-03-01")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ms <= 0 {
t.Errorf("expected positive milliseconds, got %d", ms)
}
}
func TestParseFlexTimeToMillis_NoTimezone(t *testing.T) {
t.Parallel()
ms, err := parseFlexTimeToMillis("start", "2026-03-10T14:00:00")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ms <= 0 {
t.Errorf("expected positive milliseconds, got %d", ms)
}
}
func TestParseFlexTimeToMillis_SlashFormat(t *testing.T) {
t.Parallel()
ms, err := parseFlexTimeToMillis("start", "2026/03/10")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ms <= 0 {
t.Errorf("expected positive milliseconds, got %d", ms)
}
}
func TestParseFlexTimeToMillis_CompactFormat(t *testing.T) {
t.Parallel()
ms, err := parseFlexTimeToMillis("start", "20260310")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ms <= 0 {
t.Errorf("expected positive milliseconds, got %d", ms)
}
}
func TestParseFlexTimeToMillis_Empty(t *testing.T) {
t.Parallel()
_, err := parseFlexTimeToMillis("start", "")
if err == nil {
t.Fatal("expected error for empty value")
}
}
func TestParseFlexTimeToMillis_Invalid(t *testing.T) {
t.Parallel()
_, err := parseFlexTimeToMillis("start", "not-a-date")
if err == nil {
t.Fatal("expected error for invalid date")
}
}
func TestValidateTimeRange_Valid(t *testing.T) {
t.Parallel()
start := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC).UnixMilli()
end := time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC).UnixMilli()
if err := validateTimeRange(start, end); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidateTimeRange_Invalid(t *testing.T) {
t.Parallel()
start := time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC).UnixMilli()
end := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC).UnixMilli()
if err := validateTimeRange(start, end); err == nil {
t.Fatal("expected error when end is before start")
}
}
func TestValidateTimeRange_Equal(t *testing.T) {
t.Parallel()
ts := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC).UnixMilli()
if err := validateTimeRange(ts, ts); err == nil {
t.Fatal("expected error when start equals end")
}
}
func TestParseUserIDs(t *testing.T) {
t.Parallel()
tests := []struct {
input string
want int
}{
{"user1,user2,user3", 3},
{"user1", 1},
{"user1, user2, user3", 3},
{"user1,,user2", 2},
{"", 0},
{" , , ", 0},
}
for _, tt := range tests {
got := parseUserIDs(tt.input)
if len(got) != tt.want {
t.Errorf("parseUserIDs(%q) = %d items, want %d", tt.input, len(got), tt.want)
}
}
}
+89 -2
View File
@@ -13,7 +13,14 @@
package logging
import "strings"
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"unicode/utf8"
)
// sensitiveKeys are header/field names whose values must be redacted in logs.
var sensitiveKeys = map[string]bool{
@@ -25,12 +32,30 @@ var sensitiveKeys = map[string]bool{
"secret": true,
"password": true,
"cookie": true,
"api_key": true,
"api-key": true,
"access_token": true,
"credential": true,
}
// sensitiveSubstrings are substrings that mark a key as sensitive.
var sensitiveSubstrings = []string{
"password", "secret", "token", "credential",
}
// IsSensitiveKey returns true if the key (case-insensitive) refers to a
// credential or secret that must not appear in log files.
func IsSensitiveKey(key string) bool {
return sensitiveKeys[strings.ToLower(key)]
lower := strings.ToLower(key)
if sensitiveKeys[lower] {
return true
}
for _, sub := range sensitiveSubstrings {
if strings.Contains(lower, sub) {
return true
}
}
return false
}
// RedactValue replaces a sensitive value with a safe placeholder.
@@ -42,3 +67,65 @@ func RedactValue(value string) string {
}
return value[:4] + "***"
}
// TruncateBody returns the body truncated to maxBytes with a UTF-8 safe
// boundary. If truncated, appends a marker showing the original size.
func TruncateBody(body []byte, maxBytes int) string {
if len(body) <= maxBytes {
return string(body)
}
safe := body[:maxBytes]
// Walk back to a valid UTF-8 boundary.
for len(safe) > 0 && !utf8.Valid(safe) {
safe = safe[:len(safe)-1]
}
return fmt.Sprintf("%s...(truncated, total=%d bytes)", string(safe), len(body))
}
// SanitizeArguments returns a JSON string of the arguments map with
// sensitive-looking values replaced by "***". Truncates to maxBytes.
func SanitizeArguments(args map[string]any, maxBytes int) string {
if len(args) == 0 {
return "{}"
}
sanitized := make(map[string]any, len(args))
for k, v := range args {
sanitized[k] = v
}
redactMapValues(sanitized)
data, err := json.Marshal(sanitized)
if err != nil {
return "{}"
}
return TruncateBody(data, maxBytes)
}
// redactMapValues replaces values of sensitive keys with "***" in-place.
func redactMapValues(m map[string]any) {
for k, v := range m {
if IsSensitiveKey(k) {
m[k] = "***"
continue
}
if nested, ok := v.(map[string]any); ok {
redactMapValues(nested)
}
}
}
// RedactHeaders returns slog attributes for HTTP headers with sensitive
// values redacted.
func RedactHeaders(headers http.Header) []slog.Attr {
if len(headers) == 0 {
return nil
}
attrs := make([]slog.Attr, 0, len(headers))
for key := range headers {
value := headers.Get(key)
if IsSensitiveKey(key) {
value = RedactValue(value)
}
attrs = append(attrs, slog.String("header."+strings.ToLower(key), value))
}
return attrs
}
+99 -1
View File
@@ -13,7 +13,11 @@
package logging
import "testing"
import (
"net/http"
"strings"
"testing"
)
func TestIsSensitiveKey(t *testing.T) {
t.Parallel()
@@ -69,3 +73,97 @@ func TestRedactValue(t *testing.T) {
})
}
}
func TestTruncateBody(t *testing.T) {
t.Parallel()
short := []byte("hello")
if got := TruncateBody(short, 100); got != "hello" {
t.Fatalf("expected no truncation, got %q", got)
}
long := []byte(strings.Repeat("a", 200))
got := TruncateBody(long, 50)
if !strings.Contains(got, "truncated") {
t.Fatalf("expected truncation marker, got %q", got)
}
if !strings.Contains(got, "total=200") {
t.Fatalf("expected total size, got %q", got)
}
}
func TestTruncateBody_Empty(t *testing.T) {
t.Parallel()
if got := TruncateBody(nil, 100); got != "" {
t.Fatalf("expected empty, got %q", got)
}
}
func TestSanitizeArguments(t *testing.T) {
t.Parallel()
args := map[string]any{
"name": "test",
"password": "secret123",
"nested": map[string]any{
"api_key": "key-value",
"safe": "ok",
},
}
got := SanitizeArguments(args, 4096)
if strings.Contains(got, "secret123") {
t.Fatalf("password should be redacted: %s", got)
}
if strings.Contains(got, "key-value") {
t.Fatalf("api_key should be redacted: %s", got)
}
if !strings.Contains(got, "test") {
t.Fatalf("non-sensitive value should remain: %s", got)
}
}
func TestSanitizeArguments_Empty(t *testing.T) {
t.Parallel()
if got := SanitizeArguments(nil, 100); got != "{}" {
t.Fatalf("expected {}, got %q", got)
}
}
func TestRedactHeaders(t *testing.T) {
t.Parallel()
headers := http.Header{
"Authorization": {"Bearer token123456"},
"Content-Type": {"application/json"},
}
attrs := RedactHeaders(headers)
if len(attrs) != 2 {
t.Fatalf("expected 2 attrs, got %d", len(attrs))
}
for _, attr := range attrs {
if attr.Key == "header.authorization" && !strings.Contains(attr.Value.String(), "***") {
t.Fatalf("authorization should be redacted: %s", attr.Value.String())
}
if attr.Key == "header.content-type" && attr.Value.String() != "application/json" {
t.Fatalf("content-type should not be redacted: %s", attr.Value.String())
}
}
}
func TestIsSensitiveKey_Substrings(t *testing.T) {
t.Parallel()
tests := []struct {
key string
want bool
}{
{"x-api-token", true},
{"user_password_hash", true},
{"my_secret_key", true},
{"x-credential-id", true},
{"safe-header", false},
}
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
t.Parallel()
if got := IsSensitiveKey(tt.key); got != tt.want {
t.Errorf("IsSensitiveKey(%q) = %v, want %v", tt.key, got, tt.want)
}
})
}
}
+118
View File
@@ -19,6 +19,13 @@ import (
"time"
)
const (
// maxBodyLogSize is the maximum bytes of request/response body to log.
maxBodyLogSize = 4096
// maxArgLogSize is the maximum bytes for sanitized argument summaries.
maxArgLogSize = 1024
)
// LogRequest logs a JSON-RPC request at Debug level.
func LogRequest(logger *slog.Logger, method, endpoint, executionId string, bodySize int) {
if logger == nil {
@@ -32,6 +39,19 @@ func LogRequest(logger *slog.Logger, method, endpoint, executionId string, bodyS
)
}
// LogRequestBody logs a truncated, redacted request body for tools/call.
func LogRequestBody(logger *slog.Logger, method, executionId string, toolName string, arguments map[string]any) {
if logger == nil || method != "tools/call" {
return
}
logger.Debug("jsonrpc_request_body",
slog.String("method", method),
slog.String("execution_id", executionId),
slog.String("tool_name", toolName),
slog.String("arguments_summary", SanitizeArguments(arguments, maxArgLogSize)),
)
}
// LogResponse logs a JSON-RPC response at Debug level.
func LogResponse(logger *slog.Logger, method, endpoint string, statusCode int, respSize int, duration time.Duration, err error) {
if logger == nil {
@@ -52,6 +72,104 @@ func LogResponse(logger *slog.Logger, method, endpoint string, statusCode int, r
logger.LogAttrs(context.TODO(), slog.LevelDebug, "jsonrpc_response", attrs...)
}
// LogResponseBody logs a truncated response body on error paths.
func LogResponseBody(logger *slog.Logger, method, executionId string, statusCode int, body []byte, traceID string) {
if logger == nil {
return
}
attrs := []slog.Attr{
slog.String("method", method),
slog.String("execution_id", executionId),
slog.Int("status", statusCode),
slog.String("body", TruncateBody(body, maxBodyLogSize)),
}
if traceID != "" {
attrs = append(attrs, slog.String("trace_id", traceID))
}
level := slog.LevelDebug
if statusCode >= 400 {
level = slog.LevelWarn
}
logger.LogAttrs(context.TODO(), level, "jsonrpc_response_body", attrs...)
}
// LogRetryAttempt logs a retry attempt at Warn level.
func LogRetryAttempt(logger *slog.Logger, method, executionId string, attempt, maxRetries int, statusCode int, delay time.Duration, lastErr error) {
if logger == nil {
return
}
attrs := []slog.Attr{
slog.String("method", method),
slog.String("execution_id", executionId),
slog.Int("attempt", attempt+1),
slog.Int("max_attempts", maxRetries+1),
slog.Int("status", statusCode),
slog.String("delay", delay.String()),
}
if lastErr != nil {
attrs = append(attrs, slog.String("error", lastErr.Error()))
}
logger.LogAttrs(context.TODO(), slog.LevelWarn, "jsonrpc_retry", attrs...)
}
// LogErrorClassified logs the final error classification at Warn level.
func LogErrorClassified(logger *slog.Logger, method, executionId, category, reason string, httpStatus, rpcCode int, retryable bool, traceID string) {
if logger == nil {
return
}
attrs := []slog.Attr{
slog.String("method", method),
slog.String("execution_id", executionId),
slog.String("category", category),
slog.String("reason", reason),
slog.Bool("retryable", retryable),
}
if httpStatus != 0 {
attrs = append(attrs, slog.Int("http_status", httpStatus))
}
if rpcCode != 0 {
attrs = append(attrs, slog.Int("rpc_code", rpcCode))
}
if traceID != "" {
attrs = append(attrs, slog.String("trace_id", traceID))
}
logger.LogAttrs(context.TODO(), slog.LevelWarn, "error_classified", attrs...)
}
// LogCommandStart logs the beginning of a command execution.
func LogCommandStart(logger *slog.Logger, executionId, command, product, tool, version string, authPresent bool) {
if logger == nil {
return
}
logger.Info("command_start",
slog.String("execution_id", executionId),
slog.String("command", command),
slog.String("product", product),
slog.String("tool", tool),
slog.String("cli_version", version),
slog.Bool("auth_token_present", authPresent),
)
}
// LogCommandEnd logs the end of a command execution.
func LogCommandEnd(logger *slog.Logger, executionId, product, tool string, success bool, duration time.Duration, errCategory, errReason string) {
if logger == nil {
return
}
attrs := []slog.Attr{
slog.String("execution_id", executionId),
slog.String("product", product),
slog.String("tool", tool),
slog.Bool("success", success),
slog.String("duration", duration.Truncate(time.Millisecond).String()),
}
if !success {
attrs = append(attrs, slog.String("error_category", errCategory))
attrs = append(attrs, slog.String("error_reason", errReason))
}
logger.LogAttrs(context.TODO(), slog.LevelInfo, "command_end", attrs...)
}
// redactEndpoint removes query parameters from endpoint URLs in logs.
func redactEndpoint(endpoint string) string {
for i := 0; i < len(endpoint); i++ {
+88
View File
@@ -88,6 +88,94 @@ func TestLogRequestNilLogger(t *testing.T) {
// Should not panic
LogRequest(nil, "test", "http://localhost", "", 0)
LogResponse(nil, "test", "http://localhost", 200, 0, 0, nil)
LogRequestBody(nil, "tools/call", "exec-1", "tool", nil)
LogResponseBody(nil, "tools/call", "exec-1", 200, nil, "")
LogRetryAttempt(nil, "tools/call", "exec-1", 0, 2, 429, 0, nil)
LogErrorClassified(nil, "tools/call", "exec-1", "api", "timeout", 0, 0, true, "")
LogCommandStart(nil, "exec-1", "dws test", "doc", "list", "1.0.0", false)
LogCommandEnd(nil, "exec-1", "doc", "list", true, 0, "", "")
}
func TestLogRequestBody(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
args := map[string]any{"name": "test", "limit": 10}
LogRequestBody(logger, "tools/call", "exec-1", "doc.list", args)
out := buf.String()
if !strings.Contains(out, "jsonrpc_request_body") {
t.Error("missing message")
}
if !strings.Contains(out, "doc.list") {
t.Error("missing tool_name")
}
if !strings.Contains(out, "exec-1") {
t.Error("missing execution_id")
}
}
func TestLogRequestBody_SkipsNonToolsCall(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
LogRequestBody(logger, "initialize", "exec-1", "", nil)
if buf.Len() != 0 {
t.Error("should not log body for non-tools/call methods")
}
}
func TestLogResponseBody(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
LogResponseBody(logger, "tools/call", "exec-1", 500, []byte(`{"error":"fail"}`), "trace-abc")
out := buf.String()
if !strings.Contains(out, "jsonrpc_response_body") {
t.Error("missing message")
}
if !strings.Contains(out, "trace-abc") {
t.Error("missing trace_id")
}
if !strings.Contains(out, "WARN") {
t.Error("expected WARN level for 500 status")
}
}
func TestLogRetryAttempt(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
LogRetryAttempt(logger, "tools/call", "exec-1", 0, 2, 429, 10*time.Millisecond, errors.New("rate limited"))
out := buf.String()
if !strings.Contains(out, "jsonrpc_retry") {
t.Error("missing message")
}
if !strings.Contains(out, "rate limited") {
t.Error("missing error")
}
}
func TestLogErrorClassified(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
LogErrorClassified(logger, "tools/call", "exec-1", "auth", "http_401", 401, 0, false, "trace-xyz")
out := buf.String()
if !strings.Contains(out, "error_classified") {
t.Error("missing message")
}
if !strings.Contains(out, "trace-xyz") {
t.Error("missing trace_id")
}
}
func TestRedactEndpoint(t *testing.T) {
+37 -5
View File
@@ -344,6 +344,12 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque
}
logging.LogRequest(c.FileLogger, request.Method, endpoint, c.ExecutionId, len(body))
// Log request body details for tools/call (arguments are sanitized).
if params, ok := request.Params.(map[string]any); ok {
toolName, _ := params["name"].(string)
args, _ := params["arguments"].(map[string]any)
logging.LogRequestBody(c.FileLogger, request.Method, c.ExecutionId, toolName, args)
}
callStart := time.Now()
resp, err := c.doWithRetry(ctx, endpoint, body)
@@ -352,6 +358,9 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque
}
defer resp.Body.Close()
// Extract trace ID from response headers for correlation.
headerTraceID := ExtractTraceIDFromHeaders(resp.Header)
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
logging.LogResponse(c.FileLogger, request.Method, endpoint, resp.StatusCode, len(data), time.Since(callStart), err)
if err != nil {
@@ -361,6 +370,7 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque
apperrors.WithReason(reasonForMethod(request.Method, "response_read_failed")),
apperrors.WithHint(i18n.T("检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。")),
apperrors.WithActions(discoveryActions("")...),
apperrors.WithTraceID(headerTraceID),
)
}
snapshotPath := ""
@@ -369,7 +379,8 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return httpStatusError(request.Method, endpoint, resp.StatusCode, snapshotPath)
logging.LogResponseBody(c.FileLogger, request.Method, c.ExecutionId, resp.StatusCode, data, headerTraceID)
return httpStatusError(request.Method, endpoint, resp.StatusCode, snapshotPath, headerTraceID)
}
if !expectResponse {
@@ -385,10 +396,12 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque
apperrors.WithHint(i18n.T("MCP 服务返回了无法解析的协议响应;检查服务版本或上游代理。")),
apperrors.WithActions(discoveryActions(snapshotPath)...),
apperrors.WithSnapshot(snapshotPath),
apperrors.WithTraceID(headerTraceID),
)
}
if envelope.Error != nil {
return jsonrpcEnvelopeError(request.Method, envelope.Error, snapshotPath)
logging.LogResponseBody(c.FileLogger, request.Method, c.ExecutionId, resp.StatusCode, data, headerTraceID)
return jsonrpcEnvelopeError(request.Method, envelope.Error, snapshotPath, headerTraceID)
}
if len(envelope.Result) == 0 {
return apperrors.NewDiscovery(
@@ -464,7 +477,14 @@ func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte)
}
if attempt < c.MaxRetries {
delay := c.retryDelayForAttempt(attempt, respRetryAfter(resp))
retryAfter := ""
statusForLog := 0
if resp != nil {
retryAfter = respRetryAfter(resp)
statusForLog = resp.StatusCode
}
delay := c.retryDelayForAttempt(attempt, retryAfter)
logging.LogRetryAttempt(c.FileLogger, "jsonrpc", c.ExecutionId, attempt, c.MaxRetries, statusForLog, delay, lastErr)
if err := c.sleepForRetry(ctx, delay); err != nil {
return nil, apperrors.NewDiscovery(
"request cancelled during retry",
@@ -657,16 +677,18 @@ func sanitizeBearerToken(raw string) string {
return token
}
func httpStatusError(method, endpoint string, statusCode int, snapshotPath string) error {
func httpStatusError(method, endpoint string, statusCode int, snapshotPath, headerTraceID string) error {
message := fmt.Sprintf("request to %s returned HTTP %d", RedactURL(endpoint), statusCode)
opts := []apperrors.Option{
apperrors.WithOperation(method),
apperrors.WithReason(fmt.Sprintf("http_%d", statusCode)),
apperrors.WithRetryable(retryable(statusCode)),
apperrors.WithSnapshot(snapshotPath),
apperrors.WithTraceID(headerTraceID),
apperrors.WithCause(&CallError{
Stage: CallStageHTTP,
HTTPStatus: statusCode,
TraceID: headerTraceID,
Cause: errors.New(message),
}),
}
@@ -706,18 +728,28 @@ func httpStatusError(method, endpoint string, statusCode int, snapshotPath strin
}
}
func jsonrpcEnvelopeError(method string, rpcErr *RPCError, snapshotPath string) error {
func jsonrpcEnvelopeError(method string, rpcErr *RPCError, snapshotPath, headerTraceID string) error {
message := fmt.Sprintf("JSON-RPC %s failed with code %d: %s", method, rpcErr.Code, rpcErr.Message)
reason := reasonForMethod(method, "jsonrpc_"+jsonrpcCodeLabel(rpcErr.Code))
// Extract structured diagnostics from rpc error data.
diag := ExtractServerDiagnostics(rpcErr.Data)
// Prefer trace ID from structured data; fall back to HTTP header.
if diag.TraceID == "" && headerTraceID != "" {
diag.TraceID = headerTraceID
}
opts := []apperrors.Option{
apperrors.WithOperation(method),
apperrors.WithReason(reason),
apperrors.WithRPCCode(rpcErr.Code),
apperrors.WithRPCData(rpcErr.Data),
apperrors.WithSnapshot(snapshotPath),
apperrors.WithServerDiag(diag),
apperrors.WithCause(&CallError{
Stage: CallStageJSONRPC,
RPCCode: rpcErr.Code,
TraceID: diag.TraceID,
Cause: errors.New(message),
}),
}
+1 -1
View File
@@ -207,7 +207,7 @@ func TestHttpStatusError(t *testing.T) {
http.StatusInternalServerError,
}
for _, code := range codes {
err := httpStatusError("tools/call", "https://api.example.com/mcp", code, "")
err := httpStatusError("tools/call", "https://api.example.com/mcp", code, "", "")
if err == nil {
t.Fatalf("expected error for status %d", code)
}
+7 -7
View File
@@ -199,7 +199,7 @@ func TestLooksAuthRPCError(t *testing.T) {
func TestJsonrpcEnvelopeError_InvalidParams(t *testing.T) {
t.Parallel()
err := jsonrpcEnvelopeError("tools/call", &RPCError{Code: -32602, Message: "invalid params"}, "")
err := jsonrpcEnvelopeError("tools/call", &RPCError{Code: -32602, Message: "invalid params"}, "", "")
if err == nil {
t.Fatal("expected error")
}
@@ -207,7 +207,7 @@ func TestJsonrpcEnvelopeError_InvalidParams(t *testing.T) {
func TestJsonrpcEnvelopeError_AuthError(t *testing.T) {
t.Parallel()
err := jsonrpcEnvelopeError("tools/call", &RPCError{Code: 401, Message: "Unauthorized"}, "")
err := jsonrpcEnvelopeError("tools/call", &RPCError{Code: 401, Message: "Unauthorized"}, "", "")
if err == nil {
t.Fatal("expected error")
}
@@ -215,7 +215,7 @@ func TestJsonrpcEnvelopeError_AuthError(t *testing.T) {
func TestJsonrpcEnvelopeError_MethodNotFound(t *testing.T) {
t.Parallel()
err := jsonrpcEnvelopeError("tools/call", &RPCError{Code: -32601, Message: "method not found"}, "/tmp/snap")
err := jsonrpcEnvelopeError("tools/call", &RPCError{Code: -32601, Message: "method not found"}, "/tmp/snap", "")
if err == nil {
t.Fatal("expected error")
}
@@ -223,7 +223,7 @@ func TestJsonrpcEnvelopeError_MethodNotFound(t *testing.T) {
func TestJsonrpcEnvelopeError_GenericToolError(t *testing.T) {
t.Parallel()
err := jsonrpcEnvelopeError("tools/call", &RPCError{Code: -32000, Message: "server error"}, "")
err := jsonrpcEnvelopeError("tools/call", &RPCError{Code: -32000, Message: "server error"}, "", "")
if err == nil {
t.Fatal("expected error")
}
@@ -231,7 +231,7 @@ func TestJsonrpcEnvelopeError_GenericToolError(t *testing.T) {
func TestJsonrpcEnvelopeError_DiscoveryMethod(t *testing.T) {
t.Parallel()
err := jsonrpcEnvelopeError("initialize", &RPCError{Code: -32000, Message: "failed"}, "")
err := jsonrpcEnvelopeError("initialize", &RPCError{Code: -32000, Message: "failed"}, "", "")
if err == nil {
t.Fatal("expected error")
}
@@ -294,7 +294,7 @@ func TestCallTool_RetriesOn502(t *testing.T) {
func TestHttpStatusError_AllCodes(t *testing.T) {
t.Parallel()
for _, code := range []int{400, 401, 403, 404, 429, 500, 502, 503} {
err := httpStatusError("tools/call", "https://api.example.com", code, "")
err := httpStatusError("tools/call", "https://api.example.com", code, "", "")
if err == nil {
t.Fatalf("expected error for status %d", code)
}
@@ -303,7 +303,7 @@ func TestHttpStatusError_AllCodes(t *testing.T) {
func TestHttpStatusError_WithSnapshot(t *testing.T) {
t.Parallel()
err := httpStatusError("initialize", "https://api.example.com", 500, "/tmp/snap.json")
err := httpStatusError("initialize", "https://api.example.com", 500, "/tmp/snap.json", "")
if err == nil {
t.Fatal("expected error")
}
+104
View File
@@ -0,0 +1,104 @@
// 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 transport
import (
"encoding/json"
"net/http"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
// serverDiagFields maps the various JSON field names the server may use
// for diagnostic information. Both snake_case and camelCase are supported.
type serverDiagFields struct {
TraceID string `json:"trace_id"`
TraceIDCamel string `json:"traceId"`
Code string `json:"code"`
ErrorCode string `json:"errorCode"`
TechnicalDetail string `json:"technical_detail"`
Retryable *bool `json:"retryable"`
}
// ExtractServerDiagnostics parses server diagnostic fields from a JSON
// payload (typically from RPCError.Data). Returns an empty struct if
// the payload is empty or unparseable.
func ExtractServerDiagnostics(data json.RawMessage) apperrors.ServerDiagnostics {
if len(data) == 0 {
return apperrors.ServerDiagnostics{}
}
var fields serverDiagFields
if json.Unmarshal(data, &fields) != nil {
return apperrors.ServerDiagnostics{}
}
return apperrors.ServerDiagnostics{
TraceID: coalesceStr(fields.TraceID, fields.TraceIDCamel),
ServerErrorCode: coalesceStr(fields.Code, fields.ErrorCode),
TechnicalDetail: fields.TechnicalDetail,
ServerRetryable: fields.Retryable,
}
}
// ExtractServerDiagnosticsFromMap parses server diagnostic fields from a
// map[string]any (typically from ToolCallResult.Content for business errors).
func ExtractServerDiagnosticsFromMap(content map[string]any) apperrors.ServerDiagnostics {
if len(content) == 0 {
return apperrors.ServerDiagnostics{}
}
diag := apperrors.ServerDiagnostics{
TraceID: stringFromMap(content, "trace_id", "traceId"),
ServerErrorCode: stringFromMap(content, "code", "errorCode"),
TechnicalDetail: stringFromMap(content, "technical_detail"),
}
if v, ok := content["retryable"].(bool); ok {
diag.ServerRetryable = &v
}
return diag
}
// ExtractTraceIDFromHeaders reads a trace ID from standard HTTP response
// headers. Returns empty string if none found.
func ExtractTraceIDFromHeaders(headers http.Header) string {
for _, key := range []string{
"X-Trace-Id",
"X-Request-Id",
"x-dingtalk-trace-id",
} {
if v := headers.Get(key); v != "" {
return v
}
}
return ""
}
// coalesceStr returns the first non-empty string.
func coalesceStr(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}
// stringFromMap returns the first non-empty string value found for any of
// the given keys in the map.
func stringFromMap(m map[string]any, keys ...string) string {
for _, key := range keys {
if v, ok := m[key].(string); ok && v != "" {
return v
}
}
return ""
}
+124
View File
@@ -0,0 +1,124 @@
package transport
import (
"encoding/json"
"net/http"
"testing"
)
func TestExtractServerDiagnostics_SnakeCase(t *testing.T) {
t.Parallel()
data := json.RawMessage(`{"trace_id":"abc123","code":"PARAM_ERROR","technical_detail":"field required","retryable":false}`)
diag := ExtractServerDiagnostics(data)
if diag.TraceID != "abc123" {
t.Fatalf("TraceID = %q, want abc123", diag.TraceID)
}
if diag.ServerErrorCode != "PARAM_ERROR" {
t.Fatalf("ServerErrorCode = %q, want PARAM_ERROR", diag.ServerErrorCode)
}
if diag.TechnicalDetail != "field required" {
t.Fatalf("TechnicalDetail = %q, want 'field required'", diag.TechnicalDetail)
}
if diag.ServerRetryable == nil || *diag.ServerRetryable != false {
t.Fatalf("ServerRetryable = %v, want false", diag.ServerRetryable)
}
}
func TestExtractServerDiagnostics_CamelCase(t *testing.T) {
t.Parallel()
data := json.RawMessage(`{"traceId":"xyz789","errorCode":"AUTH_ERROR"}`)
diag := ExtractServerDiagnostics(data)
if diag.TraceID != "xyz789" {
t.Fatalf("TraceID = %q, want xyz789", diag.TraceID)
}
if diag.ServerErrorCode != "AUTH_ERROR" {
t.Fatalf("ServerErrorCode = %q, want AUTH_ERROR", diag.ServerErrorCode)
}
}
func TestExtractServerDiagnostics_Empty(t *testing.T) {
t.Parallel()
diag := ExtractServerDiagnostics(nil)
if !diag.IsEmpty() {
t.Fatal("expected empty diagnostics for nil input")
}
diag = ExtractServerDiagnostics(json.RawMessage(`{}`))
if !diag.IsEmpty() {
t.Fatal("expected empty diagnostics for empty object")
}
}
func TestExtractServerDiagnostics_Malformed(t *testing.T) {
t.Parallel()
diag := ExtractServerDiagnostics(json.RawMessage(`not json`))
if !diag.IsEmpty() {
t.Fatal("expected empty diagnostics for malformed JSON")
}
}
func TestExtractServerDiagnosticsFromMap(t *testing.T) {
t.Parallel()
content := map[string]any{
"trace_id": "trace-001",
"code": "TIMEOUT_ERROR",
"technical_detail": "deadline exceeded",
"retryable": true,
}
diag := ExtractServerDiagnosticsFromMap(content)
if diag.TraceID != "trace-001" {
t.Fatalf("TraceID = %q, want trace-001", diag.TraceID)
}
if diag.ServerErrorCode != "TIMEOUT_ERROR" {
t.Fatalf("ServerErrorCode = %q, want TIMEOUT_ERROR", diag.ServerErrorCode)
}
if diag.ServerRetryable == nil || *diag.ServerRetryable != true {
t.Fatal("expected retryable=true")
}
}
func TestExtractServerDiagnosticsFromMap_Empty(t *testing.T) {
t.Parallel()
diag := ExtractServerDiagnosticsFromMap(nil)
if !diag.IsEmpty() {
t.Fatal("expected empty for nil map")
}
}
func TestExtractTraceIDFromHeaders(t *testing.T) {
t.Parallel()
tests := []struct {
name string
headers http.Header
want string
}{
{"x-trace-id", http.Header{"X-Trace-Id": {"abc"}}, "abc"},
{"x-request-id", http.Header{"X-Request-Id": {"def"}}, "def"},
{"dingtalk", http.Header{"X-Dingtalk-Trace-Id": {"ghi"}}, "ghi"},
{"priority", http.Header{
"X-Trace-Id": {"first"},
"X-Dingtalk-Trace-Id": {"second"},
}, "first"},
{"empty", http.Header{}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := ExtractTraceIDFromHeaders(tt.headers); got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}
func TestCoalesceStr(t *testing.T) {
t.Parallel()
if got := coalesceStr("", "b"); got != "b" {
t.Fatalf("got %q, want b", got)
}
if got := coalesceStr("a", "b"); got != "a" {
t.Fatalf("got %q, want a", got)
}
if got := coalesceStr("", ""); got != "" {
t.Fatalf("got %q, want empty", got)
}
}
+2 -2
View File
@@ -10,7 +10,7 @@ import (
)
func TestHTTPStatusErrorIncludesCallMetadata(t *testing.T) {
err := httpStatusError("tools/call", "https://mcp.dingtalk.com/server", http.StatusTooManyRequests, "")
err := httpStatusError("tools/call", "https://mcp.dingtalk.com/server", http.StatusTooManyRequests, "", "")
var callErr *CallError
if !errors.As(err, &callErr) {
@@ -33,7 +33,7 @@ func TestHTTPStatusErrorIncludesCallMetadata(t *testing.T) {
}
func TestJSONRPCEnvelopeErrorIncludesCallMetadata(t *testing.T) {
err := jsonrpcEnvelopeError("tools/call", &RPCError{Code: -32602, Message: "invalid params"}, "")
err := jsonrpcEnvelopeError("tools/call", &RPCError{Code: -32602, Message: "invalid params"}, "", "")
var callErr *CallError
if !errors.As(err, &callErr) {
+52 -5
View File
@@ -14,6 +14,7 @@
# Environment variables (all optional):
# DWS_INSTALL_DIR — where to put the binary (default: ~/.local/bin)
# DWS_VERSION — version to install (default: latest)
# DWS_ARCH — architecture override (amd64 or arm64)
# DWS_NO_SKILLS — set to 1 to skip skills install
# DWS_SKILLS_ONLY — set to 1 to install only skills
@@ -44,12 +45,58 @@ function Write-Err {
}
function Get-Arch {
$arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
switch ($arch) {
"X64" { return "amd64" }
"Arm64" { return "arm64" }
default { Write-Err "Unsupported architecture: $arch" }
# Allow manual override via environment variable
if ($env:DWS_ARCH) {
$override = $env:DWS_ARCH.ToLower()
if ($override -eq "amd64" -or $override -eq "arm64") {
return $override
}
Write-Err "Invalid DWS_ARCH value '$env:DWS_ARCH'. Must be 'amd64' or 'arm64'."
}
# Method 1: Try RuntimeInformation (available in .NET Core / PowerShell 6+)
try {
$arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
if ($arch) {
switch ($arch.ToString()) {
"X64" { return "amd64" }
"Arm64" { return "arm64" }
}
}
} catch {}
# Method 2: Check PROCESSOR_ARCHITECTURE environment variable (Windows)
$envArch = $env:PROCESSOR_ARCHITECTURE
if ($envArch) {
switch ($envArch.ToUpper()) {
"AMD64" { return "amd64" }
"ARM64" { return "arm64" }
"X86" {
# 32-bit process on 64-bit OS?
$realArch = $env:PROCESSOR_ARCHITEW6432
if ($realArch) {
switch ($realArch.ToUpper()) {
"AMD64" { return "amd64" }
"ARM64" { return "arm64" }
}
}
Write-Err "32-bit Windows is not supported"
}
}
}
# Method 3: Try WMI query as last resort
try {
$cpu = Get-WmiObject -Class Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 1
if ($cpu) {
switch ($cpu.Architecture) {
9 { return "amd64" } # x64
12 { return "arm64" } # ARM64
}
}
} catch {}
Write-Err "Unsupported architecture: Could not detect system architecture. Please set DWS_ARCH environment variable to 'amd64' or 'arm64'."
}
function Resolve-LatestVersion {
+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` 可同时修改标题/优先级/截止时间/完成状态
+7 -2
View File
@@ -32,16 +32,21 @@ func TestHiddenMCPHelpIsReachable(t *testing.T) {
}
}
func TestSkillCommandIsNotRegisteredInPublicOSSBuild(t *testing.T) {
func TestSkillCommandIsRegistered(t *testing.T) {
t.Parallel()
root := app.NewRootCommand()
var found bool
for _, cmd := range root.Commands() {
if cmd.Name() == "skill" {
t.Fatalf("skill command should not be registered in OSS build")
found = true
break
}
}
if !found {
t.Fatalf("skill command should be registered")
}
}
// TestSchemaJSONBootstrapOutput was removed