This commit is contained in:
tianlei.qjb
2026-03-27 18:36:17 +08:00
parent 112b0bb1cf
commit ea8d7993a6
8 changed files with 37 additions and 1674 deletions
+37 -24
View File
@@ -2,32 +2,45 @@
All notable changes to this project will be documented in this file.
The format is inspired by Keep a Changelog and this project follows
Semantic Versioning once versioned releases are published.
The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.0.0] - 2026-03-27
### Added
First public release of DingTalk Workspace CLI.
- Open-source governance baseline:
- `LICENSE` (Apache License 2.0)
- `CODE_OF_CONDUCT.md`
- `SECURITY.md`
- `.github/PULL_REQUEST_TEMPLATE.md`
- `.github/workflows/ci.yml`
- `.env.example`
- `docs/architecture.md`
- Open-source policy checks:
- `scripts/policy/check-open-source-assets.sh`
- `scripts/policy/open-source-audit.sh`
- CI integration through `.github/workflows/ci.yml`
- Release hardening:
- SHA256 checksum generation for packaged artifacts
- Homebrew tap publish helper
### Core
### Changed
- Discovery-driven CLI pipeline: Market → Discovery → IR → CLI → Transport
- MCP JSON-RPC transport with retries, auth injection, and response size limits
- Disk-based discovery cache with TTL and stale-fallback for offline resilience
- OAuth device flow authentication with PBKDF2 + AES-256-GCM encrypted token storage
- Structured output formats: JSON, table, raw
- Global flags: `--format`, `--verbose`, `--debug`, `--dry-run`, `--yes`, `--timeout`
- Exit codes with structured error payloads (category, reason, hint, actions)
- Project license migrated from MIT to Apache License 2.0.
- `README.md` and `CONTRIBUTING.md` expanded for open-source contribution and
governance guidance.
- `scripts/README.md` now includes open-source policy checks.
### Supported Services
- **aitable** — AI table: bases, tables, fields, records, templates
- **approval** — Approval processes, forms, instances
- **attendance** — Attendance records, shifts, statistics
- **calendar** — Events, participants, meeting rooms, free-busy
- **chat** — Bot messaging (group/batch), webhook, bot management
- **contact** — Users, departments, org structure
- **devdoc** — Open platform docs search
- **ding** — DING messages: send, recall
- **report** — Reports, templates, statistics
- **todo** — Task management: create, update, complete, delete
- **workbench** — Workbench app query
### Agent Skills
- Bundled `SKILL.md` with product reference docs, intent routing guide, error codes, and batch scripts
- One-line installer for macOS / Linux / Windows
- Skills installed to `~/.agents/skills/dws` (home) or `./.agents/skills/dws` (project)
### Packaging
- Pre-built binaries for macOS (arm64/amd64), Linux (arm64/amd64), Windows (amd64)
- One-line install scripts (`install.sh`, `install.ps1`)
- Project-level skill installer (`install-skills.sh`)
- Shell completion: Bash, Zsh, Fish
BIN
View File
Binary file not shown.
-167
View File
@@ -1,167 +0,0 @@
package cli_compat_test
import (
"testing"
)
// ── create ──────────────────────────────────────────────────
func TestAiappCreate_should_call_create_ai_app(t *testing.T) {
cap := setupTestDeps(t, "aiapp")
root := buildRoot()
err := execCmd(t, root, []string{"aiapp", "create"}, map[string]string{
"prompt": "创建一个天气查询应用",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertToolName(t, cap, "create_ai_app")
assertToolArg(t, cap, "prompt", "创建一个天气查询应用")
}
func TestAiappCreate_should_include_skills_when_provided(t *testing.T) {
cap := setupTestDeps(t, "aiapp")
root := buildRoot()
_ = execCmd(t, root, []string{"aiapp", "create"}, map[string]string{
"prompt": "翻译应用", "skills": "s1,s2",
})
assertToolArg(t, cap, "officialSkillUids", []string{"s1", "s2"})
}
func TestAiappCreate_should_not_include_skills_when_empty(t *testing.T) {
cap := setupTestDeps(t, "aiapp")
root := buildRoot()
_ = execCmd(t, root, []string{"aiapp", "create"}, map[string]string{
"prompt": "P", "skills": "",
})
assertArgNotPresent(t, cap, "officialSkillUids")
}
func TestAiappCreate_should_handle_long_prompt(t *testing.T) {
cap := setupTestDeps(t, "aiapp")
root := buildRoot()
longPrompt := "创建一个能够处理多语言翻译、支持语音输入、并且具备历史记录功能的AI翻译助手应用"
_ = execCmd(t, root, []string{"aiapp", "create"}, map[string]string{"prompt": longPrompt})
assertToolArg(t, cap, "prompt", longPrompt)
}
func TestAiappCreate_should_make_single_call(t *testing.T) {
cap := setupTestDeps(t, "aiapp")
root := buildRoot()
_ = execCmd(t, root, []string{"aiapp", "create"}, map[string]string{"prompt": "P"})
assertCallCount(t, cap, 1)
}
func TestAiappCreate_should_handle_single_skill(t *testing.T) {
cap := setupTestDeps(t, "aiapp")
root := buildRoot()
_ = execCmd(t, root, []string{"aiapp", "create"}, map[string]string{
"prompt": "P", "skills": "only_skill",
})
assertToolArg(t, cap, "officialSkillUids", []string{"only_skill"})
}
func TestAiappCreate_should_not_call_mcp_in_dry_run(t *testing.T) {
cap := setupTestDepsWithDryRun(t, "aiapp")
root := buildRoot()
_ = execCmd(t, root, []string{"aiapp", "create"}, map[string]string{"prompt": "P"})
assertCallCount(t, cap, 0)
}
// ── query ───────────────────────────────────────────────────
func TestAiappQuery_should_call_query_ai_app(t *testing.T) {
cap := setupTestDeps(t, "aiapp")
root := buildRoot()
err := execCmd(t, root, []string{"aiapp", "query"}, map[string]string{
"task-id": "TASK001",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertToolName(t, cap, "query_ai_app")
assertToolArg(t, cap, "taskId", "TASK001")
}
func TestAiappQuery_should_handle_different_task_ids(t *testing.T) {
cap := setupTestDeps(t, "aiapp")
root := buildRoot()
_ = execCmd(t, root, []string{"aiapp", "query"}, map[string]string{"task-id": "T_XYZ"})
assertToolArg(t, cap, "taskId", "T_XYZ")
}
func TestAiappQuery_should_make_single_call(t *testing.T) {
cap := setupTestDeps(t, "aiapp")
root := buildRoot()
_ = execCmd(t, root, []string{"aiapp", "query"}, map[string]string{"task-id": "T1"})
assertCallCount(t, cap, 1)
}
func TestAiappQuery_should_return_no_error(t *testing.T) {
_ = setupTestDeps(t, "aiapp")
root := buildRoot()
err := execCmd(t, root, []string{"aiapp", "query"}, map[string]string{"task-id": "T1"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestAiappQuery_should_not_call_mcp_in_dry_run(t *testing.T) {
cap := setupTestDepsWithDryRun(t, "aiapp")
root := buildRoot()
_ = execCmd(t, root, []string{"aiapp", "query"}, map[string]string{"task-id": "T1"})
assertCallCount(t, cap, 0)
}
// ── modify ──────────────────────────────────────────────────
func TestAiappModify_should_call_modify_ai_app(t *testing.T) {
cap := setupTestDeps(t, "aiapp")
root := buildRoot()
err := execCmd(t, root, []string{"aiapp", "modify"}, map[string]string{
"prompt": "改为翻译应用",
"thread-id": "THREAD001",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertToolName(t, cap, "modify_ai_app")
assertToolArg(t, cap, "prompt", "改为翻译应用")
assertToolArg(t, cap, "threadId", "THREAD001")
}
func TestAiappModify_should_include_skills_when_provided(t *testing.T) {
cap := setupTestDeps(t, "aiapp")
root := buildRoot()
_ = execCmd(t, root, []string{"aiapp", "modify"}, map[string]string{
"prompt": "P", "thread-id": "TH1", "skills": "s1,s2,s3",
})
assertToolArg(t, cap, "officialSkillUids", []string{"s1", "s2", "s3"})
}
func TestAiappModify_should_not_include_skills_when_empty(t *testing.T) {
cap := setupTestDeps(t, "aiapp")
root := buildRoot()
_ = execCmd(t, root, []string{"aiapp", "modify"}, map[string]string{
"prompt": "P", "thread-id": "TH1", "skills": "",
})
assertArgNotPresent(t, cap, "officialSkillUids")
}
func TestAiappModify_should_pass_thread_id(t *testing.T) {
cap := setupTestDeps(t, "aiapp")
root := buildRoot()
_ = execCmd(t, root, []string{"aiapp", "modify"}, map[string]string{
"prompt": "P", "thread-id": "TH_XYZ",
})
assertToolArg(t, cap, "threadId", "TH_XYZ")
}
func TestAiappModify_should_make_single_call(t *testing.T) {
cap := setupTestDeps(t, "aiapp")
root := buildRoot()
_ = execCmd(t, root, []string{"aiapp", "modify"}, map[string]string{
"prompt": "P", "thread-id": "TH1",
})
assertCallCount(t, cap, 1)
}
-90
View File
@@ -1,90 +0,0 @@
package cli_compat_test
import "testing"
// ── conference meeting create ──────────────────────────────
func TestConferenceMeetingCreate_should_call_correct_tool(t *testing.T) {
cap := setupTestDeps(t, "conference")
root := buildRoot()
err := execCmd(t, root, []string{"conference", "meeting", "create"}, map[string]string{
"title": "产品评审会", "start": "2026-03-11T14:00:00+08:00", "end": "2026-03-11T15:00:00+08:00",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertToolName(t, cap, "create_meeting_reservation")
}
func TestConferenceMeetingCreate_should_pass_title_start_end(t *testing.T) {
cap := setupTestDeps(t, "conference")
root := buildRoot()
_ = execCmd(t, root, []string{"conference", "meeting", "create"}, map[string]string{
"title": "产品评审会", "start": "2026-03-11T14:00:00+08:00", "end": "2026-03-11T15:00:00+08:00",
})
assertToolArg(t, cap, "title", "产品评审会")
// Verify startTime and endTime are numeric (may be int64 or float64)
last := cap.last()
switch last.Args["startTime"].(type) {
case int64, float64:
// ok
default:
t.Errorf("expected startTime to be numeric, got %T", last.Args["startTime"])
}
switch last.Args["endTime"].(type) {
case int64, float64:
// ok
default:
t.Errorf("expected endTime to be numeric, got %T", last.Args["endTime"])
}
}
func TestConferenceMeetingCreate_should_handle_long_title(t *testing.T) {
cap := setupTestDeps(t, "conference")
root := buildRoot()
longTitle := "这是一个很长很长的会议标题用来测试标题长度处理能力的极限情况"
_ = execCmd(t, root, []string{"conference", "meeting", "create"}, map[string]string{
"title": longTitle, "start": "2026-03-11T14:00:00+08:00", "end": "2026-03-11T15:00:00+08:00",
})
assertToolArg(t, cap, "title", longTitle)
}
func TestConferenceMeetingCreate_should_handle_empty_title(t *testing.T) {
// execCmd skips empty values, so we use root.SetArgs directly
cap := setupTestDeps(t, "conference")
root := buildRoot()
_ = execCmd(t, root, []string{"conference", "meeting", "create"}, map[string]string{
"title": "x", "start": "2026-03-11T14:00:00+08:00", "end": "2026-03-11T15:00:00+08:00",
})
// Just verify the call was made; empty title testing requires direct SetArgs
assertCallCount(t, cap, 1)
}
func TestConferenceMeetingCreate_should_make_exactly_one_call(t *testing.T) {
cap := setupTestDeps(t, "conference")
root := buildRoot()
_ = execCmd(t, root, []string{"conference", "meeting", "create"}, map[string]string{
"title": "会议", "start": "2026-01-01T00:00:00Z", "end": "2026-01-01T01:00:00Z",
})
assertCallCount(t, cap, 1)
}
func TestConferenceMeetingCreate_should_not_call_when_dry_run(t *testing.T) {
cap := setupTestDepsWithDryRun(t, "conference")
root := buildRoot()
_ = execCmd(t, root, []string{"conference", "meeting", "create"}, map[string]string{
"title": "会议", "start": "2026-01-01T00:00:00Z", "end": "2026-01-01T01:00:00Z",
})
assertCallCount(t, cap, 0)
}
func TestConferenceMeetingCreate_should_reject_invalid_start(t *testing.T) {
_ = setupTestDeps(t, "conference")
root := buildRoot()
err := execCmd(t, root, []string{"conference", "meeting", "create"}, map[string]string{
"title": "测试", "start": "not-a-date", "end": "2026-01-01T01:00:00Z",
})
if err == nil {
t.Fatal("expected error for invalid start time")
}
}
-39
View File
@@ -1,39 +0,0 @@
package cli_compat_test
import (
"testing"
)
// ── law consult ─────────────────────────────────────────────
func TestLawConsult_should_pass_deep_think(t *testing.T) {
t.Skip("bool flag --deep-think=true not reliably passed through execCmd")
}
func TestLawConsult_should_pass_online_search(t *testing.T) {
t.Skip("bool flag --online-search=true not reliably passed through execCmd")
}
func TestLawConsult_should_not_pass_bool_when_false(t *testing.T) {
t.Skip("bool flag default handling not reliably testable through execCmd")
}
func TestLawConsult_should_make_single_call(t *testing.T) {
cap := setupTestDeps(t, "law")
root := buildRoot()
_ = execCmd(t, root, []string{"law", "consult"}, map[string]string{
"query": "test",
})
assertCallCount(t, cap, 1)
}
func TestLawConsult_should_not_call_mcp_in_dry_run(t *testing.T) {
cap := setupTestDepsWithDryRun(t, "law")
root := buildRoot()
_ = execCmd(t, root, []string{"law", "consult"}, map[string]string{
"query": "test",
})
assertCallCount(t, cap, 0)
}
// ── law search ──────────────────────────────────────────────
-48
View File
@@ -1,48 +0,0 @@
package cli_compat_test
import "testing"
// ── live stream list ───────────────────────────────────────
func TestLiveStreamList_should_call_correct_tool(t *testing.T) {
cap := setupTestDeps(t, "live")
root := buildRoot()
err := execCmd(t, root, []string{"live", "stream", "list"}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertToolName(t, cap, "get_my_lives")
}
func TestLiveStreamList_should_pass_nil_args(t *testing.T) {
cap := setupTestDeps(t, "live")
root := buildRoot()
_ = execCmd(t, root, []string{"live", "stream", "list"}, nil)
assertNilArgs(t, cap)
}
func TestLiveStreamList_should_make_exactly_one_call(t *testing.T) {
cap := setupTestDeps(t, "live")
root := buildRoot()
_ = execCmd(t, root, []string{"live", "stream", "list"}, nil)
assertCallCount(t, cap, 1)
}
func TestLiveStreamList_should_not_call_when_dry_run(t *testing.T) {
cap := setupTestDepsWithDryRun(t, "live")
root := buildRoot()
err := execCmd(t, root, []string{"live", "stream", "list"}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertCallCount(t, cap, 0)
}
func TestLiveStreamList_should_succeed_without_errors(t *testing.T) {
_ = setupTestDeps(t, "live")
root := buildRoot()
err := execCmd(t, root, []string{"live", "stream", "list"}, nil)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
}
-56
View File
@@ -1,56 +0,0 @@
package cli_compat_test
import "testing"
// ── oa approval list-pending ───────────────────────────────
func TestOaApprovalListPending_should_call_correct_tool(t *testing.T) {
cap := setupTestDeps(t, "oa")
root := buildRoot()
err := execCmd(t, root, []string{"oa", "approval", "list-pending"}, map[string]string{
"start": "2026-03-10T00:00:00+08:00", "end": "2026-03-10T23:59:59+08:00",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertToolName(t, cap, "list_pending_approvals")
}
func TestOaApprovalListPending_should_pass_timestamps(t *testing.T) {
cap := setupTestDeps(t, "oa")
root := buildRoot()
_ = execCmd(t, root, []string{"oa", "approval", "list-pending"}, map[string]string{
"start": "2026-03-10T00:00:00+08:00", "end": "2026-03-10T23:59:59+08:00",
})
// Source converts ISO-8601 to float64 millis; MCP uses starTime (not startTime)
last := cap.last()
if last == nil {
t.Fatal("no MCP call captured")
}
if _, ok := last.Args["starTime"].(float64); !ok {
t.Errorf("expected starTime as float64, got %T", last.Args["starTime"])
}
if _, ok := last.Args["endTime"].(float64); !ok {
t.Errorf("expected endTime as float64, got %T", last.Args["endTime"])
}
}
func TestOaApprovalListPending_should_not_call_when_dry_run(t *testing.T) {
cap := setupTestDepsWithDryRun(t, "oa")
root := buildRoot()
_ = execCmd(t, root, []string{"oa", "approval", "list-pending"}, map[string]string{
"start": "2026-03-10T00:00:00+08:00", "end": "2026-03-10T23:59:59+08:00",
})
assertCallCount(t, cap, 0)
}
func TestOaApprovalListPending_should_reject_invalid_iso_start(t *testing.T) {
_ = setupTestDeps(t, "oa")
root := buildRoot()
err := execCmd(t, root, []string{"oa", "approval", "list-pending"}, map[string]string{
"start": "not-a-date", "end": "2026-03-10T23:59:59+08:00",
})
if err == nil {
t.Fatal("expected error for invalid start time")
}
}
File diff suppressed because it is too large Load Diff